Rotate the provisioning QR code every 45s
This commit is contained in:
parent
ede169a28d
commit
f94673f9f1
@ -8,7 +8,7 @@ import SignalServiceKit
|
||||
|
||||
class DeviceProvisioningURL {
|
||||
|
||||
public static let ephemeralDeviceIdParamName = "uuid"
|
||||
public static let uuidParamName = "uuid"
|
||||
public static let publicKeyParamName = "pub_key"
|
||||
public static let capabilitiesParamName = "capabilities"
|
||||
|
||||
@ -40,7 +40,7 @@ class DeviceProvisioningURL {
|
||||
var capabilities: [Capability] = []
|
||||
for queryItem in queryItems {
|
||||
switch queryItem.name {
|
||||
case Self.ephemeralDeviceIdParamName:
|
||||
case Self.uuidParamName:
|
||||
ephemeralDeviceId = queryItem.value
|
||||
case Self.publicKeyParamName:
|
||||
publicKey = Self.decodePublicKey(queryItem.value)
|
||||
|
||||
@ -24,17 +24,40 @@ class ProvisioningNavigationController: OWSNavigationController {
|
||||
|
||||
class ProvisioningController: NSObject {
|
||||
|
||||
private struct ProvisioningUrlParams {
|
||||
let uuid: String
|
||||
let cipher: ProvisioningCipher
|
||||
}
|
||||
|
||||
private struct DecryptableProvisionEnvelope {
|
||||
let cipher: ProvisioningCipher
|
||||
let envelope: ProvisioningProtoProvisionEnvelope
|
||||
|
||||
func decrypt() throws -> ProvisionMessage {
|
||||
return try cipher.decrypt(envelope: envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an attempt to communicate with the primary.
|
||||
private struct ProvisioningUrlCommunicationAttempt {
|
||||
/// The socket from which we hope to receive a provisioning envelope
|
||||
/// from a primary.
|
||||
let socket: ProvisioningSocket
|
||||
/// The cipher to be used in encrypting the provisioning envelope.
|
||||
let cipher: ProvisioningCipher
|
||||
/// A continuation waiting for us to fetch the parameters necessary for
|
||||
/// us to construct a provisioning URL, which we will present to the
|
||||
/// primary via QR code. The provisioning URL will contain the necessary
|
||||
/// data for the primary to send us a provisioning envelope over our
|
||||
/// provisioning socket, via the server.
|
||||
var fetchProvisioningUrlParamsContinuation: CheckedContinuation<ProvisioningUrlParams, Error>?
|
||||
}
|
||||
|
||||
private var urlCommunicationAttempts: AtomicValue<[ProvisioningUrlCommunicationAttempt]> = AtomicValue([], lock: .init())
|
||||
private var awaitProvisionEnvelopeContinuation: AtomicValue<CheckedContinuation<DecryptableProvisionEnvelope?, Never>?> = AtomicValue(nil, lock: .init())
|
||||
|
||||
private let appReadiness: AppReadinessSetter
|
||||
|
||||
private let provisioningCipher: ProvisioningCipher
|
||||
private let provisioningSocket: ProvisioningSocket
|
||||
|
||||
private var deviceIdPromise: Promise<String>
|
||||
private var deviceIdFuture: Future<String>
|
||||
|
||||
private var provisionEnvelopePromise: Promise<ProvisioningProtoProvisionEnvelope>
|
||||
private var provisionEnvelopeFuture: Future<ProvisioningProtoProvisionEnvelope>
|
||||
|
||||
private lazy var provisioningCoordinator: ProvisioningCoordinator = {
|
||||
return ProvisioningCoordinatorImpl(
|
||||
chatConnectionManager: DependenciesBridge.shared.chatConnectionManager,
|
||||
@ -60,22 +83,8 @@ class ProvisioningController: NSObject {
|
||||
|
||||
private init(appReadiness: AppReadinessSetter) {
|
||||
self.appReadiness = appReadiness
|
||||
provisioningCipher = ProvisioningCipher.generate()
|
||||
|
||||
(self.deviceIdPromise, self.deviceIdFuture) = Promise.pending()
|
||||
(self.provisionEnvelopePromise, self.provisionEnvelopeFuture) = Promise.pending()
|
||||
|
||||
provisioningSocket = ProvisioningSocket()
|
||||
|
||||
super.init()
|
||||
|
||||
provisioningSocket.delegate = self
|
||||
}
|
||||
|
||||
func resetPromises() {
|
||||
_awaitProvisionMessage = nil
|
||||
(self.deviceIdPromise, self.deviceIdFuture) = Promise.pending()
|
||||
(self.provisionEnvelopePromise, self.provisionEnvelopeFuture) = Promise.pending()
|
||||
}
|
||||
|
||||
static func presentProvisioningFlow(appReadiness: AppReadinessSetter) {
|
||||
@ -96,9 +105,14 @@ class ProvisioningController: NSObject {
|
||||
|
||||
let vc = ProvisioningQRCodeViewController(provisioningController: provisioningController)
|
||||
navController.setViewControllers([vc], animated: false)
|
||||
|
||||
provisioningController.awaitProvisioning(from: vc, navigationController: navController)
|
||||
CurrentAppContext().mainWindow?.rootViewController = navController
|
||||
|
||||
Task {
|
||||
await provisioningController.awaitProvisioning(
|
||||
from: vc,
|
||||
navigationController: navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func setUpDebugLogsGesture(
|
||||
@ -229,67 +243,101 @@ class ProvisioningController: NSObject {
|
||||
let qrCodeViewController = ProvisioningQRCodeViewController(provisioningController: self)
|
||||
navigationController.pushViewController(qrCodeViewController, animated: true)
|
||||
|
||||
awaitProvisioning(from: qrCodeViewController, navigationController: navigationController)
|
||||
Task {
|
||||
await awaitProvisioning(
|
||||
from: qrCodeViewController,
|
||||
navigationController: navigationController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitProvisioning(from viewController: ProvisioningQRCodeViewController,
|
||||
navigationController: UINavigationController) {
|
||||
private func awaitProvisioning(
|
||||
from viewController: ProvisioningQRCodeViewController,
|
||||
navigationController: UINavigationController
|
||||
) async {
|
||||
let decryptableProvisionEnvelope: DecryptableProvisionEnvelope? = await withCheckedContinuation { newContinuation in
|
||||
awaitProvisionEnvelopeContinuation.update { existingContinuation in
|
||||
guard existingContinuation == nil else {
|
||||
newContinuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
awaitProvisionMessage.done { [weak self, weak navigationController] message in
|
||||
guard let self = self else { throw PromiseError.cancelled }
|
||||
guard let navigationController = navigationController else { throw PromiseError.cancelled }
|
||||
existingContinuation = newContinuation
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the primary device is new enough to link us. Right now this is a simple check
|
||||
// of >= the latest version, but when we bump the version we may need to be more specific
|
||||
// if we have some backwards compatible support and allow a limited linking with an old
|
||||
// version of the app.
|
||||
guard let provisioningVersion = message.provisioningVersion,
|
||||
provisioningVersion >= OWSDeviceProvisionerConstant.provisioningVersion else {
|
||||
OWSActionSheets.showActionSheet(
|
||||
title: OWSLocalizedString("SECONDARY_LINKING_ERROR_OLD_VERSION_TITLE",
|
||||
comment: "alert title for outdated linking device"),
|
||||
message: OWSLocalizedString("SECONDARY_LINKING_ERROR_OLD_VERSION_MESSAGE",
|
||||
comment: "alert message for outdated linking device")
|
||||
) { _ in
|
||||
self.resetPromises()
|
||||
guard let decryptableProvisionEnvelope else {
|
||||
owsFailDebug("Attempted to await provisioning multiple times!")
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let provisionMessage: ProvisionMessage
|
||||
do {
|
||||
provisionMessage = try decryptableProvisionEnvelope.decrypt()
|
||||
} catch let error {
|
||||
Logger.error("Failed to decrypt provision envelope: \(error)")
|
||||
|
||||
let alert = ActionSheetController(
|
||||
title: OWSLocalizedString(
|
||||
"SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN",
|
||||
comment: "alert title"
|
||||
),
|
||||
message: error.userErrorDescription
|
||||
)
|
||||
alert.addAction(ActionSheetAction(
|
||||
title: CommonStrings.cancelButton,
|
||||
style: .cancel,
|
||||
handler: { _ in
|
||||
navigationController.popViewController(animated: true)
|
||||
}
|
||||
))
|
||||
|
||||
navigationController.presentActionSheet(alert)
|
||||
return
|
||||
}
|
||||
|
||||
if FeatureFlags.linkAndSync, message.ephemeralBackupKey != nil {
|
||||
/// Ensure the primary is new enough to link us.
|
||||
guard
|
||||
let provisioningVersion = provisionMessage.provisioningVersion,
|
||||
provisioningVersion >= OWSDeviceProvisionerConstant.provisioningVersion
|
||||
else {
|
||||
OWSActionSheets.showActionSheet(
|
||||
title: OWSLocalizedString(
|
||||
"SECONDARY_LINKING_ERROR_OLD_VERSION_TITLE",
|
||||
comment: "alert title for outdated linking device"
|
||||
),
|
||||
message: OWSLocalizedString(
|
||||
"SECONDARY_LINKING_ERROR_OLD_VERSION_MESSAGE",
|
||||
comment: "alert message for outdated linking device"
|
||||
)
|
||||
) { _ in
|
||||
navigationController.popViewController(animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if FeatureFlags.linkAndSync, provisionMessage.ephemeralBackupKey != nil {
|
||||
// Don't confirm the name in link'n'sync, just keep going.
|
||||
self.didSetDeviceName(
|
||||
didSetDeviceName(
|
||||
UIDevice.current.name,
|
||||
provisionMessage: provisionMessage,
|
||||
from: viewController,
|
||||
willLinkAndSync: true
|
||||
)
|
||||
} else {
|
||||
let confirmVC = ProvisioningSetDeviceNameViewController(provisioningController: self)
|
||||
let confirmVC = ProvisioningSetDeviceNameViewController(
|
||||
provisionMessage: provisionMessage,
|
||||
provisioningController: self
|
||||
)
|
||||
navigationController.pushViewController(confirmVC, animated: true)
|
||||
}
|
||||
}.catch { error in
|
||||
switch error {
|
||||
case PromiseError.cancelled:
|
||||
Logger.info("cancelled")
|
||||
default:
|
||||
Logger.warn("error: \(error)")
|
||||
let alert = ActionSheetController(title: OWSLocalizedString("SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title"),
|
||||
message: error.userErrorDescription)
|
||||
alert.addAction(ActionSheetAction(title: CommonStrings.retryButton,
|
||||
accessibilityIdentifier: "alert.retry",
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
self.resetPromises()
|
||||
navigationController.popViewController(animated: true)
|
||||
}))
|
||||
navigationController.presentActionSheet(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didSetDeviceName(
|
||||
_ deviceName: String,
|
||||
provisionMessage: ProvisionMessage,
|
||||
from viewController: UIViewController,
|
||||
willLinkAndSync: Bool
|
||||
) {
|
||||
@ -357,7 +405,12 @@ class ProvisioningController: NSObject {
|
||||
accessibilityIdentifier: "alert.retry",
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
self.didSetDeviceName(deviceName, from: viewController, willLinkAndSync: willLinkAndSync)
|
||||
self.didSetDeviceName(
|
||||
deviceName,
|
||||
provisionMessage: provisionMessage,
|
||||
from: viewController,
|
||||
willLinkAndSync: willLinkAndSync
|
||||
)
|
||||
}
|
||||
))
|
||||
}
|
||||
@ -372,9 +425,10 @@ class ProvisioningController: NSObject {
|
||||
viewController.present(progressViewController, animated: false)
|
||||
let result = await self.completeLinking(
|
||||
deviceName: deviceName,
|
||||
provisionMessage: provisionMessage,
|
||||
progressViewModel: progressViewModel,
|
||||
viewController: progressViewController
|
||||
).awaitable()
|
||||
)
|
||||
let errorActionSheet = resultHandler(result)
|
||||
if let errorActionSheet {
|
||||
progressViewController.dismiss(animated: true) {
|
||||
@ -389,24 +443,24 @@ class ProvisioningController: NSObject {
|
||||
} else {
|
||||
ModalActivityIndicatorViewController.present(
|
||||
fromViewController: viewController,
|
||||
canCancel: false,
|
||||
backgroundBlock: { modal in
|
||||
self.completeLinking(
|
||||
deviceName: deviceName,
|
||||
progressViewModel: progressViewModel,
|
||||
viewController: modal
|
||||
).done { result in
|
||||
let errorActionSheet = resultHandler(result)
|
||||
modal.dismiss {
|
||||
if let errorActionSheet {
|
||||
viewController.presentActionSheet(errorActionSheet)
|
||||
} else {
|
||||
self.provisioningDidComplete(from: viewController)
|
||||
}
|
||||
}
|
||||
canCancel: false
|
||||
) { modal async -> Void in
|
||||
let result = await self.completeLinking(
|
||||
deviceName: deviceName,
|
||||
provisionMessage: provisionMessage,
|
||||
progressViewModel: progressViewModel,
|
||||
viewController: modal
|
||||
)
|
||||
|
||||
let errorActionSheet = resultHandler(result)
|
||||
modal.dismiss {
|
||||
if let errorActionSheet {
|
||||
viewController.presentActionSheet(errorActionSheet)
|
||||
} else {
|
||||
self.provisioningDidComplete(from: viewController)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -421,47 +475,38 @@ class ProvisioningController: NSObject {
|
||||
SignalApp.resetAppDataWithUI()
|
||||
}
|
||||
|
||||
func getProvisioningURL() -> Promise<URL> {
|
||||
return getDeviceId().map { [weak self] deviceId in
|
||||
guard let self = self else { throw PromiseError.cancelled }
|
||||
func getProvisioningURL() async throws -> URL {
|
||||
let provisioningUrlParams: ProvisioningUrlParams = try await withCheckedThrowingContinuation { paramsContinuation in
|
||||
let newAttempt = ProvisioningUrlCommunicationAttempt(
|
||||
socket: ProvisioningSocket(),
|
||||
cipher: .generate(),
|
||||
fetchProvisioningUrlParamsContinuation: paramsContinuation
|
||||
)
|
||||
|
||||
return try self.buildProvisioningUrl(deviceId: deviceId)
|
||||
}
|
||||
}
|
||||
urlCommunicationAttempts.update { $0.append(newAttempt) }
|
||||
|
||||
private var _awaitProvisionMessage: Promise<ProvisionMessage>?
|
||||
private var awaitProvisionMessage: Promise<ProvisionMessage> {
|
||||
if _awaitProvisionMessage == nil {
|
||||
_awaitProvisionMessage = provisionEnvelopePromise.map { [weak self] envelope in
|
||||
guard let self = self else { throw PromiseError.cancelled }
|
||||
return try self.provisioningCipher.decrypt(envelope: envelope)
|
||||
}
|
||||
newAttempt.socket.delegate = self
|
||||
newAttempt.socket.connect()
|
||||
}
|
||||
return _awaitProvisionMessage!
|
||||
|
||||
return try Self.buildProvisioningUrl(params: provisioningUrlParams)
|
||||
}
|
||||
|
||||
private func completeLinking(
|
||||
deviceName: String,
|
||||
provisionMessage: ProvisionMessage,
|
||||
progressViewModel: LinkAndSyncProgressViewModel,
|
||||
viewController: UIViewController
|
||||
) -> Guarantee<CompleteProvisioningResult> {
|
||||
return awaitProvisionMessage.then { [weak self] provisionMessage -> Promise<CompleteProvisioningResult> in
|
||||
guard let self = self else { throw PromiseError.cancelled }
|
||||
|
||||
return Promise.wrapAsync {
|
||||
await self.provisioningCoordinator.completeProvisioning(
|
||||
provisionMessage: provisionMessage,
|
||||
deviceName: deviceName,
|
||||
progressViewModel: progressViewModel,
|
||||
shouldRetry: { [weak viewController] error in
|
||||
guard let viewController else { return false }
|
||||
return await self.showError(error: error, viewController: viewController)
|
||||
}
|
||||
)
|
||||
) async -> CompleteProvisioningResult {
|
||||
await self.provisioningCoordinator.completeProvisioning(
|
||||
provisionMessage: provisionMessage,
|
||||
deviceName: deviceName,
|
||||
progressViewModel: progressViewModel,
|
||||
shouldRetry: { [weak viewController] error in
|
||||
guard let viewController else { return false }
|
||||
return await self.showError(error: error, viewController: viewController)
|
||||
}
|
||||
}.recover {
|
||||
return .value(.genericError($0))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -514,9 +559,9 @@ class ProvisioningController: NSObject {
|
||||
|
||||
// MARK: -
|
||||
|
||||
private func buildProvisioningUrl(deviceId: String) throws -> URL {
|
||||
private static func buildProvisioningUrl(params: ProvisioningUrlParams) throws -> URL {
|
||||
let base64PubKey: String = Data(
|
||||
provisioningCipher.secondaryDevicePublicKey.serialize()
|
||||
params.cipher.secondaryDevicePublicKey.serialize()
|
||||
).base64EncodedString()
|
||||
guard let encodedPubKey = base64PubKey.encodeURIComponent else {
|
||||
throw OWSAssertionError("Failed to url encode query params")
|
||||
@ -554,7 +599,7 @@ class ProvisioningController: NSObject {
|
||||
var urlString = UrlOpener.Constants.sgnlPrefix
|
||||
urlString.append("://")
|
||||
urlString.append(DeviceProvisioningURL.Constants.linkDeviceHost)
|
||||
urlString.append("?\(DeviceProvisioningURL.ephemeralDeviceIdParamName)=\(deviceId)")
|
||||
urlString.append("?\(DeviceProvisioningURL.uuidParamName)=\(params.uuid)")
|
||||
urlString.append("&\(DeviceProvisioningURL.publicKeyParamName)=\(encodedPubKey)")
|
||||
urlString.append("&\(DeviceProvisioningURL.capabilitiesParamName)=\(capabilities.joined(separator: ","))")
|
||||
guard let url = URL(string: urlString) else {
|
||||
@ -563,36 +608,96 @@ class ProvisioningController: NSObject {
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
private func getDeviceId() -> Promise<String> {
|
||||
assert(provisioningSocket.state != .open)
|
||||
// TODO send Keep-Alive or ping frames at regular intervals
|
||||
// iOS uses ping frames elsewhere, but moxie seemed surprised we weren't
|
||||
// using the keepalive endpoint. Waiting to here back from him before proceeding.
|
||||
// (If it's sufficient, my preference would be to do like we do elsewhere and
|
||||
// use the ping frames)
|
||||
provisioningSocket.connect()
|
||||
return deviceIdPromise
|
||||
}
|
||||
}
|
||||
|
||||
extension ProvisioningController: ProvisioningSocketDelegate {
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveDeviceId deviceId: String) {
|
||||
owsAssertDebug(!deviceIdPromise.isSealed)
|
||||
deviceIdFuture.resolve(deviceId)
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveProvisioningUuid provisioningUuid: String) {
|
||||
urlCommunicationAttempts.update { attempts in
|
||||
let matchingAttemptIndex = attempts.firstIndex {
|
||||
$0.socket.id == provisioningSocket.id
|
||||
}
|
||||
|
||||
guard
|
||||
let matchingAttemptIndex,
|
||||
let fetchParamsContinuation = attempts[matchingAttemptIndex].fetchProvisioningUrlParamsContinuation
|
||||
else {
|
||||
owsFailDebug("Got provisioning UUID for unknown socket!")
|
||||
return
|
||||
}
|
||||
|
||||
attempts[matchingAttemptIndex].fetchProvisioningUrlParamsContinuation = nil
|
||||
|
||||
fetchParamsContinuation.resume(
|
||||
returning: ProvisioningUrlParams(
|
||||
uuid: provisioningUuid,
|
||||
cipher: attempts[matchingAttemptIndex].cipher
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveEnvelope envelope: ProvisioningProtoProvisionEnvelope) {
|
||||
// After receiving the provisioning message, there's nothing else to retrieve from the provisioning socket
|
||||
provisioningSocket.disconnect(code: .normalClosure)
|
||||
var cipherForSocket: ProvisioningCipher?
|
||||
|
||||
owsAssertDebug(!provisionEnvelopePromise.isSealed)
|
||||
return provisionEnvelopeFuture.resolve(envelope)
|
||||
/// We've gotten a provisioning message, from one of our attempts'
|
||||
/// sockets. (We don't care which one – it's whichever one the primary
|
||||
/// scanned and sent an envelope through!)
|
||||
for attempt in urlCommunicationAttempts.get() {
|
||||
/// After we get a provisioning message, we don't expect anything
|
||||
/// from this or any other socket.
|
||||
attempt.socket.disconnect(code: .normalClosure)
|
||||
|
||||
if provisioningSocket.id == attempt.socket.id {
|
||||
owsAssertDebug(
|
||||
cipherForSocket == nil,
|
||||
"Extracting cipher, but unexpectedly already set from previous match!"
|
||||
)
|
||||
|
||||
cipherForSocket = attempt.cipher
|
||||
}
|
||||
}
|
||||
|
||||
guard let cipherForSocket else {
|
||||
owsFailDebug("Missing cipher for socket that received envelope!")
|
||||
return
|
||||
}
|
||||
|
||||
awaitProvisionEnvelopeContinuation.update { continuation in
|
||||
guard continuation != nil else {
|
||||
owsFailDebug("Got provision envelope, but missing continuation or cipher!")
|
||||
return
|
||||
}
|
||||
|
||||
continuation!.resume(returning: DecryptableProvisionEnvelope(
|
||||
cipher: cipherForSocket,
|
||||
envelope: envelope
|
||||
))
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didError error: Error) {
|
||||
Logger.warn("\(error)")
|
||||
deviceIdFuture.reject(error)
|
||||
provisionEnvelopeFuture.reject(error)
|
||||
if
|
||||
let webSocketError = error as? WebSocketError,
|
||||
case .closeError = webSocketError
|
||||
{
|
||||
Logger.info("Provisioning socket closed...")
|
||||
} else {
|
||||
Logger.error("\(error)")
|
||||
}
|
||||
|
||||
urlCommunicationAttempts.update { attempts in
|
||||
let matchingAttemptIndex = attempts.firstIndex {
|
||||
$0.socket.id == provisioningSocket.id
|
||||
}
|
||||
|
||||
guard let matchingAttemptIndex else {
|
||||
owsFailDebug("Got provisioning UUID for unknown socket!")
|
||||
return
|
||||
}
|
||||
|
||||
attempts[matchingAttemptIndex].fetchProvisioningUrlParamsContinuation?.resume(throwing: error)
|
||||
attempts[matchingAttemptIndex].fetchProvisioningUrlParamsContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,98 +8,245 @@ import SignalServiceKit
|
||||
import SignalUI
|
||||
|
||||
class ProvisioningQRCodeViewController: ProvisioningBaseViewController {
|
||||
private enum QRCodeDisplayMode {
|
||||
case qrCodeImage(UIImage?)
|
||||
case refreshButton
|
||||
case loadingSpinner
|
||||
}
|
||||
|
||||
let qrCodeView = QRCodeView()
|
||||
private var qrCodeImageBackgroundView: UIView!
|
||||
private var qrCodeImageView: UIImageView!
|
||||
private var qrCodeRefreshButton: OWSButton!
|
||||
private var qrCodeLoadingSpinner: UIActivityIndicatorView!
|
||||
|
||||
private var rotateQRCodeTask: Task<Void, Never>?
|
||||
|
||||
private func setDisplayMode(_ displayMode: QRCodeDisplayMode) {
|
||||
switch displayMode {
|
||||
case .qrCodeImage(let image):
|
||||
qrCodeLoadingSpinner.isHidden = true
|
||||
qrCodeImageBackgroundView.isHidden = false
|
||||
qrCodeRefreshButton.isHidden = true
|
||||
|
||||
qrCodeImageView.setTemplateImage(image, tintColor: .qrCodeBlue)
|
||||
case .refreshButton:
|
||||
qrCodeLoadingSpinner.isHidden = true
|
||||
qrCodeImageBackgroundView.isHidden = true
|
||||
qrCodeRefreshButton.isHidden = false
|
||||
case .loadingSpinner:
|
||||
qrCodeLoadingSpinner.isHidden = false
|
||||
qrCodeImageBackgroundView.isHidden = true
|
||||
qrCodeRefreshButton.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
// MARK: Views
|
||||
|
||||
view = UIView()
|
||||
view.addSubview(primaryView)
|
||||
primaryView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
view.backgroundColor = Theme.backgroundColor
|
||||
|
||||
let titleLabel = self.createTitleLabel(text: OWSLocalizedString("SECONDARY_ONBOARDING_SCAN_CODE_TITLE", comment: "header text while displaying a QR code which, when scanned, will link this device."))
|
||||
primaryView.addSubview(titleLabel)
|
||||
titleLabel.accessibilityIdentifier = "onboarding.linking.titleLabel"
|
||||
titleLabel.setContentHuggingHigh()
|
||||
let titleLabel = self.createTitleLabel(text: OWSLocalizedString(
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_TITLE",
|
||||
comment: "header text while displaying a QR code which, when scanned, will link this device."
|
||||
))
|
||||
|
||||
let bodyLabel = self.createTitleLabel(text: OWSLocalizedString("SECONDARY_ONBOARDING_SCAN_CODE_BODY", comment: "body text while displaying a QR code which, when scanned, will link this device."))
|
||||
let bodyLabel = self.createTitleLabel(text: OWSLocalizedString(
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_BODY",
|
||||
comment: "body text while displaying a QR code which, when scanned, will link this device."
|
||||
))
|
||||
bodyLabel.font = UIFont.dynamicTypeBody
|
||||
bodyLabel.numberOfLines = 0
|
||||
|
||||
let qrCodeWrapperView = UIView()
|
||||
qrCodeWrapperView.backgroundColor = .ows_gray02
|
||||
qrCodeWrapperView.layoutMargins = UIEdgeInsets(margin: 48)
|
||||
qrCodeWrapperView.layer.cornerRadius = 24
|
||||
|
||||
qrCodeImageBackgroundView = UIView()
|
||||
qrCodeImageBackgroundView.backgroundColor = .white
|
||||
qrCodeImageBackgroundView.layoutMargins = UIEdgeInsets(margin: 20)
|
||||
qrCodeImageBackgroundView.layer.cornerRadius = 12
|
||||
|
||||
qrCodeImageView = UIImageView()
|
||||
qrCodeImageView.backgroundColor = .white
|
||||
|
||||
qrCodeRefreshButton = OWSRoundedButton()
|
||||
qrCodeRefreshButton.setAttributedTitle(
|
||||
{
|
||||
let icon = NSAttributedString.with(
|
||||
image: UIImage(named: "refresh")!,
|
||||
font: .dynamicTypeBody,
|
||||
centerVerticallyRelativeTo: .dynamicTypeBody
|
||||
)
|
||||
|
||||
let text = OWSLocalizedString(
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_REFRESH_CODE_BUTTON",
|
||||
comment: "Text for a button offering to refresh the QR code to link an iPad."
|
||||
)
|
||||
|
||||
let string = NSMutableAttributedString()
|
||||
string.append(icon)
|
||||
string.append(" ")
|
||||
string.append(text)
|
||||
return string
|
||||
}(),
|
||||
for: .normal
|
||||
)
|
||||
qrCodeRefreshButton.setTitleColor(.black, for: .normal)
|
||||
qrCodeRefreshButton.titleLabel!.font = .dynamicTypeBody.bold()
|
||||
qrCodeRefreshButton.backgroundColor = .white
|
||||
qrCodeRefreshButton.ows_contentEdgeInsets = UIEdgeInsets(hMargin: 24, vMargin: 0)
|
||||
qrCodeRefreshButton.autoSetDimension(.height, toSize: 40)
|
||||
qrCodeRefreshButton.block = { [weak self] in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
setDisplayMode(.loadingSpinner)
|
||||
|
||||
do {
|
||||
let qrCodeImage = try await fetchNewProvisioningQRCode()
|
||||
setDisplayMode(.qrCodeImage(qrCodeImage))
|
||||
} catch {
|
||||
setDisplayMode(.refreshButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qrCodeLoadingSpinner = UIActivityIndicatorView(style: .large)
|
||||
qrCodeLoadingSpinner.color = Theme.lightThemePrimaryColor
|
||||
qrCodeLoadingSpinner.autoSetDimensions(to: .init(width: 40, height: 40))
|
||||
qrCodeLoadingSpinner.startAnimating()
|
||||
|
||||
let getHelpLabel = UILabel()
|
||||
getHelpLabel.text = OWSLocalizedString(
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_HELP_TEXT",
|
||||
comment: "Link text for page with troubleshooting info shown on the QR scanning screen"
|
||||
)
|
||||
getHelpLabel.textColor = Theme.accentBlueColor
|
||||
getHelpLabel.font = UIFont.dynamicTypeSubheadlineClamped
|
||||
getHelpLabel.numberOfLines = 0
|
||||
getHelpLabel.textAlignment = .center
|
||||
getHelpLabel.lineBreakMode = .byWordWrapping
|
||||
getHelpLabel.isUserInteractionEnabled = true
|
||||
getHelpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapExplanationLabel)))
|
||||
getHelpLabel.setContentHuggingHigh()
|
||||
|
||||
// MARK: Layout
|
||||
|
||||
qrCodeImageBackgroundView.addSubview(qrCodeImageView)
|
||||
qrCodeImageView.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
qrCodeWrapperView.addSubview(qrCodeLoadingSpinner)
|
||||
qrCodeLoadingSpinner.autoCenterInSuperviewMargins()
|
||||
|
||||
qrCodeWrapperView.addSubview(qrCodeRefreshButton)
|
||||
qrCodeRefreshButton.autoCenterInSuperviewMargins()
|
||||
|
||||
qrCodeWrapperView.addSubview(qrCodeImageBackgroundView)
|
||||
qrCodeImageBackgroundView.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
primaryView.addSubview(titleLabel)
|
||||
primaryView.addSubview(bodyLabel)
|
||||
bodyLabel.accessibilityIdentifier = "onboarding.linking.bodyLabel"
|
||||
primaryView.addSubview(qrCodeWrapperView)
|
||||
primaryView.addSubview(getHelpLabel)
|
||||
|
||||
titleLabel.setContentHuggingHigh()
|
||||
titleLabel.autoPinTopToSuperviewMargin()
|
||||
titleLabel.autoPinWidthToSuperviewMargins()
|
||||
titleLabel.autoPinEdge(.bottom, to: .top, of: bodyLabel, withOffset: -12)
|
||||
|
||||
bodyLabel.setContentHuggingHigh()
|
||||
bodyLabel.autoPinWidthToSuperviewMargins()
|
||||
bodyLabel.autoPinEdge(.bottom, to: .top, of: qrCodeWrapperView, withOffset: -64)
|
||||
|
||||
qrCodeView.setContentHuggingVerticalLow()
|
||||
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.text = OWSLocalizedString("SECONDARY_ONBOARDING_SCAN_CODE_HELP_TEXT",
|
||||
comment: "Link text for page with troubleshooting info shown on the QR scanning screen")
|
||||
explanationLabel.textColor = Theme.accentBlueColor
|
||||
explanationLabel.font = UIFont.dynamicTypeSubheadlineClamped
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.isUserInteractionEnabled = true
|
||||
explanationLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapExplanationLabel)))
|
||||
explanationLabel.accessibilityIdentifier = "onboarding.linking.helpLink"
|
||||
explanationLabel.setContentHuggingHigh()
|
||||
qrCodeWrapperView.autoHCenterInSuperview()
|
||||
qrCodeWrapperView.autoSetDimensions(to: .square(352))
|
||||
|
||||
#if TESTABLE_BUILD
|
||||
let shareURLButton = UIButton(type: .system)
|
||||
shareURLButton.setTitle(LocalizationNotNeeded("Debug only: Share URL"), for: .normal)
|
||||
shareURLButton.addTarget(self, action: #selector(didTapShareURL), for: .touchUpInside)
|
||||
#endif
|
||||
primaryView.addSubview(shareURLButton)
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [
|
||||
titleLabel,
|
||||
bodyLabel,
|
||||
qrCodeView,
|
||||
explanationLabel
|
||||
])
|
||||
#if TESTABLE_BUILD
|
||||
stackView.addArrangedSubview(shareURLButton)
|
||||
getHelpLabel.autoPinWidthToSuperviewMargins()
|
||||
getHelpLabel.autoPinEdge(.bottom, to: .top, of: shareURLButton, withOffset: -12)
|
||||
|
||||
shareURLButton.autoPinWidthToSuperviewMargins()
|
||||
shareURLButton.autoPinBottomToSuperviewMargin()
|
||||
#else
|
||||
getHelpLabel.autoPinWidthToSuperviewMargins()
|
||||
getHelpLabel.autoPinBottomToSuperviewMargin()
|
||||
#endif
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .fill
|
||||
stackView.spacing = 12
|
||||
primaryView.addSubview(stackView)
|
||||
stackView.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
fetchAndSetQRCode()
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setDisplayMode(.loadingSpinner)
|
||||
|
||||
rotateQRCodeTask = Task {
|
||||
/// Refresh the QR code every 45s, five times. If we fail, or once
|
||||
/// we've exhausted the five refreshes, fall back to showing an
|
||||
/// error state with a manual retry.
|
||||
do {
|
||||
for _ in 0..<5 {
|
||||
let qrCodeImage = try await fetchNewProvisioningQRCode()
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
setDisplayMode(.qrCodeImage(qrCodeImage))
|
||||
|
||||
try await Task.sleep(nanoseconds: 45 * NSEC_PER_SEC)
|
||||
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Bail!
|
||||
return
|
||||
} catch {
|
||||
// Fall through as if we'd exhausted our rotations.
|
||||
}
|
||||
|
||||
setDisplayMode(.refreshButton)
|
||||
}
|
||||
}
|
||||
|
||||
override public func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
rotateQRCodeTask?.cancel()
|
||||
rotateQRCodeTask = nil
|
||||
setDisplayMode(.refreshButton)
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
override func shouldShowBackButton() -> Bool {
|
||||
// Never show the back button here
|
||||
// TODO: Linked phones, clean up state to allow backing out
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
func didTapExplanationLabel(sender: UIGestureRecognizer) {
|
||||
guard sender.state == .recognized else {
|
||||
owsFailDebug("unexpected state: \(sender.state)")
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(URL(string: "https://support.signal.org/hc/articles/360007320451")!)
|
||||
}
|
||||
|
||||
#if TESTABLE_BUILD
|
||||
private let currentProvisioningUrl: AtomicValue<URL?> = AtomicValue(nil, lock: .init())
|
||||
|
||||
@IBAction func didTapShareURL(_ sender: UIButton) {
|
||||
if let qrCodeURL = self.qrCodeURL {
|
||||
UIPasteboard.general.url = qrCodeURL
|
||||
if let provisioningUrl = currentProvisioningUrl.get() {
|
||||
UIPasteboard.general.url = provisioningUrl
|
||||
// If we share the plain url and airdrop it to a mac, it will just open the url,
|
||||
// and fail because signal desktop can't open it.
|
||||
// Share some text instead so we can open it on mac and copy paste into
|
||||
// a primary device simulator.
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: ["Provisioning URL: " + qrCodeURL.absoluteString],
|
||||
activityItems: ["Provisioning URL: " + provisioningUrl.absoluteString],
|
||||
applicationActivities: nil
|
||||
)
|
||||
activityVC.popoverPresentationController?.sourceView = sender
|
||||
@ -112,27 +259,22 @@ class ProvisioningQRCodeViewController: ProvisioningBaseViewController {
|
||||
|
||||
// MARK: -
|
||||
|
||||
private var hasFetchedAndSetQRCode = false
|
||||
private var qrCodeURL: URL?
|
||||
func fetchAndSetQRCode() {
|
||||
guard !hasFetchedAndSetQRCode else { return }
|
||||
hasFetchedAndSetQRCode = true
|
||||
private nonisolated func fetchNewProvisioningQRCode() async throws -> UIImage? {
|
||||
let provisioningUrl = try await provisioningController.getProvisioningURL()
|
||||
|
||||
provisioningController.getProvisioningURL().done { url in
|
||||
self.qrCodeURL = url
|
||||
self.qrCodeView.setQR(url: url)
|
||||
}.catch { error in
|
||||
let title = OWSLocalizedString("SECONDARY_DEVICE_ERROR_FETCHING_LINKING_CODE", comment: "alert title")
|
||||
let alert = ActionSheetController(title: title, message: error.userErrorDescription)
|
||||
#if TESTABLE_BUILD
|
||||
currentProvisioningUrl.set(provisioningUrl)
|
||||
#endif
|
||||
|
||||
let retryAction = ActionSheetAction(title: CommonStrings.retryButton,
|
||||
accessibilityIdentifier: "alert.retry",
|
||||
style: .default) { _ in
|
||||
self.provisioningController.resetPromises()
|
||||
self.fetchAndSetQRCode()
|
||||
}
|
||||
alert.addAction(retryAction)
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
return SignalBrandedQRCodeGenerator(
|
||||
foregroundColor: .qrCodeBlue,
|
||||
backgroundColor: .clear
|
||||
).generateQRCode(url: provisioningUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
static var qrCodeBlue: UIColor {
|
||||
return SignalBrandedQRCodes.QRCodeColor.blue.foreground
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,16 @@ import SignalUI
|
||||
|
||||
class ProvisioningSetDeviceNameViewController: ProvisioningBaseViewController {
|
||||
|
||||
private let provisionMessage: ProvisionMessage
|
||||
|
||||
init(
|
||||
provisionMessage: ProvisionMessage,
|
||||
provisioningController: ProvisioningController
|
||||
) {
|
||||
self.provisionMessage = provisionMessage
|
||||
super.init(provisioningController: provisioningController)
|
||||
}
|
||||
|
||||
// MARK: UIViewController overrides
|
||||
|
||||
override public func loadView() {
|
||||
@ -157,9 +167,9 @@ class ProvisioningSetDeviceNameViewController: ProvisioningBaseViewController {
|
||||
|
||||
provisioningController.didSetDeviceName(
|
||||
String(deviceName),
|
||||
provisionMessage: provisionMessage,
|
||||
from: self,
|
||||
willLinkAndSync: false
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -6382,9 +6382,6 @@
|
||||
/* section header for search results that match a message in a conversation */
|
||||
"SEARCH_SECTION_MESSAGES" = "Messages";
|
||||
|
||||
/* alert title */
|
||||
"SECONDARY_DEVICE_ERROR_FETCHING_LINKING_CODE" = "Unable to Fetch Linking Code";
|
||||
|
||||
/* Message for error alert indicating that re-linking failed because the account did not match. */
|
||||
"SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_MESSAGE" = "To link this device to a different account, you must first reset this device. Are you sure you want to delete all history (messages, attachments, calls, etc.)? This action cannot be reverted.";
|
||||
|
||||
@ -6448,6 +6445,9 @@
|
||||
/* Link text for page with troubleshooting info shown on the QR scanning screen */
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_HELP_TEXT" = "Get help linking your iPad here";
|
||||
|
||||
/* Text for a button offering to refresh the QR code to link an iPad. */
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_REFRESH_CODE_BUTTON" = "Refresh code";
|
||||
|
||||
/* header text while displaying a QR code which, when scanned, will link this device. */
|
||||
"SECONDARY_ONBOARDING_SCAN_CODE_TITLE" = "Scan the QR Code with your phone";
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
import Foundation
|
||||
|
||||
public protocol ProvisioningSocketDelegate: AnyObject {
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveDeviceId deviceID: String)
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveProvisioningUuid provisioningUuid: String)
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveEnvelope envelope: ProvisioningProtoProvisionEnvelope)
|
||||
func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didError error: Error)
|
||||
}
|
||||
@ -14,9 +14,11 @@ public protocol ProvisioningSocketDelegate: AnyObject {
|
||||
// MARK: -
|
||||
|
||||
public class ProvisioningSocket {
|
||||
let socket: SSKWebSocket
|
||||
public let id = UUID()
|
||||
public weak var delegate: ProvisioningSocketDelegate?
|
||||
|
||||
let socket: SSKWebSocket
|
||||
|
||||
public init(webSocketFactory: WebSocketFactory) {
|
||||
// TODO: Should we (sometimes?) use the unidentified service?
|
||||
let request = WebSocketRequest(
|
||||
@ -95,7 +97,7 @@ extension ProvisioningSocket: SSKWebSocketDelegate {
|
||||
throw OWSAssertionError("body was unexpectedly nil")
|
||||
}
|
||||
let uuidProto = try ProvisioningProtoProvisioningUuid(serializedData: body)
|
||||
delegate?.provisioningSocket(self, didReceiveDeviceId: uuidProto.uuid)
|
||||
delegate?.provisioningSocket(self, didReceiveProvisioningUuid: uuidProto.uuid)
|
||||
case ("PUT", "/v1/message"):
|
||||
guard let body = request.body else {
|
||||
throw OWSAssertionError("body was unexpectedly nil")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user