Rotate the provisioning QR code every 45s

This commit is contained in:
Sasha Weiss 2024-12-10 14:18:09 -08:00 committed by GitHub
parent ede169a28d
commit f94673f9f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 472 additions and 213 deletions

View File

@ -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)

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
)
}
}

View File

@ -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";

View File

@ -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")