diff --git a/Signal/Provisioning/DeviceProvisioningURL.swift b/Signal/Provisioning/DeviceProvisioningURL.swift index 9240fb372a..9bb0936d45 100644 --- a/Signal/Provisioning/DeviceProvisioningURL.swift +++ b/Signal/Provisioning/DeviceProvisioningURL.swift @@ -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) diff --git a/Signal/Provisioning/UserInterface/ProvisioningController.swift b/Signal/Provisioning/UserInterface/ProvisioningController.swift index 9fc0bb3c91..771e279a6c 100644 --- a/Signal/Provisioning/UserInterface/ProvisioningController.swift +++ b/Signal/Provisioning/UserInterface/ProvisioningController.swift @@ -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? + } + + private var urlCommunicationAttempts: AtomicValue<[ProvisioningUrlCommunicationAttempt]> = AtomicValue([], lock: .init()) + private var awaitProvisionEnvelopeContinuation: AtomicValue?> = AtomicValue(nil, lock: .init()) + private let appReadiness: AppReadinessSetter - private let provisioningCipher: ProvisioningCipher - private let provisioningSocket: ProvisioningSocket - - private var deviceIdPromise: Promise - private var deviceIdFuture: Future - - private var provisionEnvelopePromise: Promise - private var provisionEnvelopeFuture: Future - 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 { - 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? - private var awaitProvisionMessage: Promise { - 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 { - return awaitProvisionMessage.then { [weak self] provisionMessage -> Promise 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 { - 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 + } } } diff --git a/Signal/Provisioning/UserInterface/ProvisioningQRCodeViewController.swift b/Signal/Provisioning/UserInterface/ProvisioningQRCodeViewController.swift index 813a06dfcb..8cab629b78 100644 --- a/Signal/Provisioning/UserInterface/ProvisioningQRCodeViewController.swift +++ b/Signal/Provisioning/UserInterface/ProvisioningQRCodeViewController.swift @@ -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? + + 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 = 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 } } diff --git a/Signal/Provisioning/UserInterface/ProvisioningSetDeviceNameViewController.swift b/Signal/Provisioning/UserInterface/ProvisioningSetDeviceNameViewController.swift index 0c5fb95c9c..d792e53dd0 100644 --- a/Signal/Provisioning/UserInterface/ProvisioningSetDeviceNameViewController.swift +++ b/Signal/Provisioning/UserInterface/ProvisioningSetDeviceNameViewController.swift @@ -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 ) } - } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 4329d3f6d5..71a96dd729 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -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"; diff --git a/SignalServiceKit/Devices/ProvisioningSocket.swift b/SignalServiceKit/Devices/ProvisioningSocket.swift index fcae085b82..26118fcc08 100644 --- a/SignalServiceKit/Devices/ProvisioningSocket.swift +++ b/SignalServiceKit/Devices/ProvisioningSocket.swift @@ -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")