Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkDeviceViewController.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import SignalServiceKit
import SignalUI

protocol LinkDeviceViewControllerDelegate: AnyObject {
    typealias LinkNSyncData = (ephemeralBackupKey: MessageRootBackupKey, tokenId: DeviceProvisioningTokenId)
    @MainActor
    func didFinishLinking(_ linkNSyncData: LinkNSyncData?, from linkDeviceViewController: LinkDeviceViewController)
}

class LinkDeviceViewController: OWSViewController {

    weak var delegate: LinkDeviceViewControllerDelegate?
    private var context = ViewControllerContext.shared

    private var hasShownEducationSheet: Bool
    private weak var educationSheet: HeroSheetViewController?

    private lazy var qrCodeScanViewController = QRCodeScanViewController(appearance: .framed)

    init(skipEducationSheet: Bool) {
        self.hasShownEducationSheet = skipEducationSheet
        super.init()
    }

    // MARK: -

    override func viewDidLoad() {
        super.viewDidLoad()

        title = CommonStrings.scanQRCodeTitle

#if TESTABLE_BUILD
        navigationItem.rightBarButtonItem = .init(
            title: LocalizationNotNeeded("ENTER"),
            style: .plain,
            target: self,
            action: #selector(manuallyEnterLinkURL),
        )
#endif

        qrCodeScanViewController.delegate = self

        addChild(qrCodeScanViewController)
        view.addSubview(qrCodeScanViewController.view)

        qrCodeScanViewController.view.autoPinEdgesToSuperviewEdges()
        qrCodeScanViewController.didMove(toParent: self)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        if !UIDevice.current.isIPad {
            UIDevice.current.ows_setOrientation(.portrait)
        }

        if !hasShownEducationSheet {
            let animationName = if traitCollection.userInterfaceStyle == .dark {
                "linking-device-dark"
            } else {
                "linking-device-light"
            }

            let sheet = HeroSheetViewController(
                hero: .animation(named: animationName, height: 192),
                title: OWSLocalizedString(
                    "LINK_DEVICE_SCANNING_INSTRUCTIONS_SHEET_TITLE",
                    comment: "Title for QR Scanning screen instructions sheet",
                ),
                body: OWSLocalizedString(
                    "LINK_DEVICE_SCANNING_INSTRUCTIONS_SHEET_BODY",
                    comment: "Title for QR Scanning screen instructions sheet",
                ),
                primaryButton: .dismissing(title: CommonStrings.okayButton),
            )

            DispatchQueue.main.async {
                self.present(sheet, animated: true)
                self.hasShownEducationSheet = true
                self.educationSheet = sheet
            }
        }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        UIDevice.current.isIPad ? .all : .portrait
    }

    private func dismissEducationSheetIfNecessary(completion: @escaping () -> Void) {
        if let educationSheet {
            educationSheet.dismiss(animated: true, completion: completion)
        } else {
            completion()
        }
    }

    private func safePresent(_ viewController: UIViewController) {
        dismissEducationSheetIfNecessary { [weak self] in
            self?.present(viewController, animated: true)
        }
    }

    // MARK: -

    private func confirmProvisioningWithUrl(_ deviceProvisioningUrl: DeviceProvisioningURL) {
        switch deviceProvisioningUrl.linkType {
        case .linkDevice:
            confirmProvisioning(with: deviceProvisioningUrl)
        case .quickRestore:
            // Ignore quick restore URLs in the link device controller
            break
        }
    }

    private func confirmProvisioning(with deviceProvisioningUrl: DeviceProvisioningURL) {
        if
            deviceProvisioningUrl.capabilities.contains(.linknsync)
        {
            let linkOrSyncSheet: LinkOrSyncPickerSheet = .load(
                didDismiss: {
                    self.popToLinkedDeviceList()
                },
                linkAndSync: {
                    self.provisionWithUrl(deviceProvisioningUrl, shouldLinkNSync: true)
                },
                linkOnly: {
                    self.provisionWithUrl(deviceProvisioningUrl, shouldLinkNSync: false)
                },
            )

            self.safePresent(linkOrSyncSheet)
        } else {
            let title = NSLocalizedString(
                "LINK_DEVICE_PERMISSION_ALERT_TITLE",
                comment: "confirm the users intent to link a new device",
            )
            let linkingDescription = NSLocalizedString(
                "LINK_DEVICE_PERMISSION_ALERT_BODY",
                comment: "confirm the users intent to link a new device",
            )

            let actionSheet = ActionSheetController(title: title, message: linkingDescription)
            actionSheet.addAction(ActionSheetAction(
                title: CommonStrings.cancelButton,
                style: .cancel,
                handler: { _ in
                    DispatchQueue.main.async {
                        self.popToLinkedDeviceList()
                    }
                },
            ))
            actionSheet.addAction(ActionSheetAction(
                title: NSLocalizedString("CONFIRM_LINK_NEW_DEVICE_ACTION", comment: "Button text"),
                style: .default,
                handler: { _ in
                    self.provisionWithUrl(deviceProvisioningUrl, shouldLinkNSync: false)
                },
            ))
            safePresent(actionSheet)
        }
    }

    private func provisionWithUrl(
        _ deviceProvisioningUrl: DeviceProvisioningURL,
        shouldLinkNSync: Bool,
    ) {
        Task {
            do {
                let (ephemeralBackupKey, tokenId) = try await context.provisioningManager.provision(
                    with: deviceProvisioningUrl,
                    shouldLinkNSync: shouldLinkNSync,
                )
                Logger.info("Successfully provisioned device.")

                self.delegate?.didFinishLinking(
                    ephemeralBackupKey.map { ($0, tokenId) },
                    from: self,
                )
            } catch {
                Logger.error("Failed to provision device with error: \(error)")
                let actionSheet = self.retryActionSheetController(error: error, retryBlock: { [weak self] in
                    self?.provisionWithUrl(deviceProvisioningUrl, shouldLinkNSync: shouldLinkNSync)
                })
                self.safePresent(actionSheet)
            }
        }
    }

    private func retryActionSheetController(error: Error, retryBlock: @escaping () -> Void) -> ActionSheetController {
        switch error {
        case let error as DeviceLimitExceededError:
            let actionSheet = ActionSheetController(
                title: error.errorDescription,
                message: error.recoverySuggestion,
            )
            actionSheet.addAction(ActionSheetAction(
                title: CommonStrings.okButton,
                handler: { [weak self] _ in
                    self?.popToLinkedDeviceList()
                },
            ))
            return actionSheet

        default:
            let actionSheet = ActionSheetController(
                title: OWSLocalizedString("LINKING_DEVICE_FAILED_TITLE", comment: "Alert Title"),
                message: error.userErrorDescription,
            )
            actionSheet.addAction(ActionSheetAction(
                title: CommonStrings.retryButton,
                style: .default,
                handler: { action in retryBlock() },
            ))
            actionSheet.addAction(ActionSheetAction(
                title: CommonStrings.cancelButton,
                style: .cancel,
                handler: { [weak self] action in
                    DispatchQueue.main.async { self?.dismiss(animated: true) }
                },
            ))
            return actionSheet
        }
    }

    func popToLinkedDeviceList(_ completion: (() -> Void)? = nil) {
        dismissEducationSheetIfNecessary { [weak navigationController] in
            navigationController?.popViewController(animated: true)
            // The method for adding a completion handler to popViewController in
            // UIViewController+SignalUI doesn't play well with UIHostingController
            navigationController?.transitionCoordinator?.animate(alongsideTransition: nil) { _ in
                UIViewController.attemptRotationToDeviceOrientation()
                completion?()
            }
        }
    }

#if TESTABLE_BUILD
    @objc
    private func manuallyEnterLinkURL() {
        let alertController = UIAlertController(
            title: LocalizationNotNeeded("Manually enter linking code."),
            message: LocalizationNotNeeded("Copy the URL represented by the QR code into the field below."),
            preferredStyle: .alert,
        )
        alertController.addTextField()
        alertController.addAction(UIAlertAction(
            title: CommonStrings.okayButton,
            style: .default,
            handler: { _ in
                guard let qrCodeString = alertController.textFields?.first?.text else { return }
                self.qrCodeScanViewScanned(
                    qrCodeData: nil,
                    qrCodeString: qrCodeString,
                )
            },
        ))
        alertController.addAction(UIAlertAction(
            title: CommonStrings.cancelButton,
            style: .cancel,
        ))
        safePresent(alertController)
    }
#endif
}

extension LinkDeviceViewController: QRCodeScanDelegate {
    @discardableResult
    func qrCodeScanViewScanned(
        qrCodeData: Data?,
        qrCodeString: String?,
    ) -> QRCodeScanOutcome {
        AssertIsOnMainThread()

        guard let qrCodeString else {
            // Only accept QR codes with a valid string payload.
            return .continueScanning
        }

        guard let url = DeviceProvisioningURL(urlString: qrCodeString) else {
            Logger.error("Unable to parse provisioning params from QRCode: \(qrCodeString)")

            let title = NSLocalizedString("LINK_DEVICE_INVALID_CODE_TITLE", comment: "report an invalid linking code")
            let body = NSLocalizedString("LINK_DEVICE_INVALID_CODE_BODY", comment: "report an invalid linking code")

            let actionSheet = ActionSheetController(title: title, message: body)
            actionSheet.addAction(ActionSheetAction(
                title: CommonStrings.cancelButton,
                style: .cancel,
                handler: { _ in
                    DispatchQueue.main.async {
                        self.popToLinkedDeviceList()
                    }
                },
            ))
            actionSheet.addAction(ActionSheetAction(
                title: NSLocalizedString("LINK_DEVICE_RESTART", comment: "attempt another linking"),
                style: .default,
                handler: { _ in
                    self.qrCodeScanViewController.tryToStartScanning()
                },
            ))
            safePresent(actionSheet)

            return .stopScanning
        }

        confirmProvisioningWithUrl(url)

        return .stopScanning
    }

    func qrCodeScanViewDismiss(_ qrCodeScanViewController: SignalUI.QRCodeScanViewController) {
        AssertIsOnMainThread()
        popToLinkedDeviceList()
    }
}