TM-SGNL-iOS/Signal/Provisioning/UserInterface/ProvisioningQRCodeViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

288 lines
9.7 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SafariServices
import SignalServiceKit
import SignalUI
class ProvisioningQRCodeViewController: ProvisioningBaseViewController {
private enum ProvisioningUrlDisplayMode {
case loading
case loaded(URL)
case refreshButton
}
private var qrCodeWrapperView: UIView!
private var qrCodeWrapperViewSizeConstraint: NSLayoutConstraint!
private var qrCodeView: QRCodeView!
private var qrCodeRefreshButton: OWSButton!
private var rotateQRCodeTask: Task<Void, Never>?
private func setDisplayMode(_ displayMode: ProvisioningUrlDisplayMode) {
switch displayMode {
case .loading:
qrCodeView.isHidden = false
qrCodeRefreshButton.isHidden = true
qrCodeView.setLoading()
case .loaded(let url):
qrCodeView.isHidden = false
qrCodeRefreshButton.isHidden = true
qrCodeView.setQRCode(url: url)
case .refreshButton:
qrCodeView.isHidden = true
qrCodeRefreshButton.isHidden = false
}
}
override init(provisioningController: ProvisioningController) {
super.init(provisioningController: provisioningController)
}
// MARK: -
override func loadView() {
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."
))
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
qrCodeWrapperView = UIView()
qrCodeWrapperView.backgroundColor = .ows_gray02
qrCodeWrapperView.layer.cornerRadius = 24
qrCodeView = QRCodeView()
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
guard let self else { return }
setDisplayMode(.loading)
startQRCodeRotationTask()
}
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
qrCodeWrapperView.addSubview(qrCodeRefreshButton)
qrCodeRefreshButton.autoCenterInSuperviewMargins()
qrCodeWrapperView.addSubview(qrCodeView)
qrCodeView.autoPinEdgesToSuperviewMargins()
let qrCodeTopSpacer = UIView()
let qrCodeBottomSpacer = UIView()
let contentStack = UIStackView(arrangedSubviews: [
titleLabel,
bodyLabel,
qrCodeTopSpacer,
qrCodeWrapperView,
qrCodeBottomSpacer,
getHelpLabel,
])
#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)
contentStack.addArrangedSubview(shareURLButton)
#endif
contentStack.axis = .vertical
contentStack.alignment = .center
contentStack.spacing = 12
primaryView.addSubview(contentStack)
contentStack.autoPinEdgesToSuperviewMargins()
qrCodeTopSpacer.autoMatch(.height, to: .height, of: qrCodeBottomSpacer, withMultiplier: 0.33)
qrCodeWrapperView.autoPinToSquareAspectRatio()
qrCodeWrapperViewSizeConstraint = qrCodeWrapperView.autoSetDimension(.height, toSize: 0)
}
private func updateLayoutForViewSize(_ size: CGSize) {
if size.height > size.width {
qrCodeWrapperView.layoutMargins = UIEdgeInsets(margin: 48)
qrCodeWrapperViewSizeConstraint.constant = 352
} else {
qrCodeWrapperView.layoutMargins = UIEdgeInsets(margin: 24)
qrCodeWrapperViewSizeConstraint.constant = 220
}
}
// MARK: -
private func startQRCodeRotationTask() {
AssertIsOnMainThread()
guard rotateQRCodeTask == nil else {
return
}
rotateQRCodeTask = Task {
/// Every 45s, five times, rotate the provisioning socket for which
/// we're displaying a QR code. If we fail, or once we've exhausted
/// the five rotations, fall back to showing a manual "refresh"
/// button.
///
/// Note that the server will close provisioning sockets after 90s,
/// so hopefully rotating every 45s means no primary will ever end
/// up trying to send into a closed socket.
do {
for _ in 0..<5 {
let provisioningUrl = try await openNewProvisioningSocket()
try Task.checkCancellation()
setDisplayMode(.loaded(provisioningUrl))
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)
rotateQRCodeTask = nil
}
}
// MARK: -
override func viewDidLoad() {
super.viewDidLoad()
setDisplayMode(.loading)
startQRCodeRotationTask()
}
override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
/// Wait until this method to update the layout, because this is the
/// first point at which we will be laid out and know our size.
updateLayoutForViewSize(view.bounds.size)
}
override public func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
rotateQRCodeTask?.cancel()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
updateLayoutForViewSize(size)
}
// MARK: - Events
override func shouldShowBackButton() -> Bool {
// Never show the back button here
return false
}
@objc
func didTapExplanationLabel(sender: UIGestureRecognizer) {
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 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: " + provisioningUrl.absoluteString],
applicationActivities: nil
)
activityVC.popoverPresentationController?.sourceView = sender
self.present(activityVC, animated: true)
} else {
UIPasteboard.general.string = LocalizationNotNeeded("URL NOT READY YET")
}
}
#endif
// MARK: -
private nonisolated func openNewProvisioningSocket() async throws -> URL {
let provisioningUrl = try await provisioningController.openNewProvisioningSocket()
#if TESTABLE_BUILD
currentProvisioningUrl.set(provisioningUrl)
#endif
return provisioningUrl
}
}