Signal-iOS/Signal/Provisioning/UserInterface/ProvisioningQRCodeViewController.swift

278 lines
9.4 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import SwiftUI
class ProvisioningQRCodeViewController: ProvisioningBaseViewController {
private let provisioningQRCodeViewModel: ProvisioningQRCodeView.Model
private var rotateQRCodeTask: Task<Void, Never>?
override init(provisioningController: ProvisioningController) {
provisioningQRCodeViewModel = ProvisioningQRCodeView.Model(urlDisplayMode: .loading)
super.init(provisioningController: provisioningController)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Theme.backgroundColor
view.addSubview(primaryView)
primaryView.autoPinEdgesToSuperviewEdges()
let qrCodeViewHostingContainer = HostingContainer(wrappedView: ProvisioningQRCodeView(
model: provisioningQRCodeViewModel,
onRefreshButtonPressed: { [weak self] in
self?.startQRCodeRotationTask()
}
))
addChild(qrCodeViewHostingContainer)
primaryView.addSubview(qrCodeViewHostingContainer.view)
qrCodeViewHostingContainer.view.autoPinEdgesToSuperviewMargins()
qrCodeViewHostingContainer.didMove(toParent: self)
startQRCodeRotationTask()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
rotateQRCodeTask?.cancel()
}
// MARK: -
override func shouldShowBackButton() -> Bool {
// Never show the back button here
return false
}
// MARK: -
func reset() {
rotateQRCodeTask?.cancel()
rotateQRCodeTask = nil
provisioningQRCodeViewModel.updateURLDisplayMode(.loading)
startQRCodeRotationTask()
}
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 provisioningController.openNewProvisioningSocket()
try Task.checkCancellation()
provisioningQRCodeViewModel.updateURLDisplayMode(.loaded(provisioningUrl))
let rotationDelaySecs: UInt64
#if TESTABLE_BUILD
rotationDelaySecs = 3
#else
rotationDelaySecs = 45
#endif
try await Task.sleep(nanoseconds: rotationDelaySecs * NSEC_PER_SEC)
try Task.checkCancellation()
}
} catch is CancellationError {
// We've been canceled; bail! It's the canceler's responsibility
// to make sure the UI is updated.
return
} catch {
// Fall through as if we'd exhausted our rotations.
}
provisioningQRCodeViewModel.updateURLDisplayMode(.refreshButton)
rotateQRCodeTask = nil
}
}
}
// MARK: -
private struct ProvisioningQRCodeView: View {
class Model: ObservableObject {
enum URLDisplayMode {
case loading
case loaded(URL)
case refreshButton
}
@Published
private(set) var urlDisplayMode: URLDisplayMode
let qrCodeViewModel: QRCodeViewRepresentable.Model
init(urlDisplayMode: URLDisplayMode) {
self.urlDisplayMode = .loading
self.qrCodeViewModel = QRCodeViewRepresentable.Model(qrCodeURL: nil)
updateURLDisplayMode(urlDisplayMode)
}
func updateURLDisplayMode(_ newValue: URLDisplayMode) {
urlDisplayMode = newValue
qrCodeViewModel.qrCodeURL = switch urlDisplayMode {
case .loaded(let url): url
case .loading, .refreshButton: nil
}
}
}
@ObservedObject
private var model: Model
private let onRefreshButtonPressed: () -> Void
init(model: Model, onRefreshButtonPressed: @escaping () -> Void) {
self.model = model
self.onRefreshButtonPressed = onRefreshButtonPressed
}
var body: some View {
GeometryReader { overallGeometry in
VStack(spacing: 12) {
Text(OWSLocalizedString(
"SECONDARY_ONBOARDING_SCAN_CODE_TITLE",
comment: "header text while displaying a QR code which, when scanned, will link this device."
))
.font(.title)
.fontWeight(.semibold)
.foregroundStyle(Color.Signal.label)
Text(OWSLocalizedString(
"SECONDARY_ONBOARDING_SCAN_CODE_BODY",
comment: "body text while displaying a QR code which, when scanned, will link this device."
))
.font(.body)
.foregroundStyle(Color.Signal.label)
Spacer()
.frame(height: overallGeometry.size.height * 0.05)
GeometryReader { qrCodeGeometry in
ZStack {
Color(UIColor.ows_gray02)
.cornerRadius(24)
switch model.urlDisplayMode {
case .loading, .loaded:
QRCodeViewRepresentable(model: model.qrCodeViewModel)
.padding(qrCodeGeometry.size.height * 0.10)
case .refreshButton:
Button(action: onRefreshButtonPressed) {
HStack {
Image("refresh")
Text(OWSLocalizedString(
"SECONDARY_ONBOARDING_SCAN_CODE_REFRESH_CODE_BUTTON",
comment: "Text for a button offering to refresh the QR code to link an iPad."
))
.font(.body)
.fontWeight(.bold)
}
.foregroundStyle(Color.black)
.padding(.horizontal, 24)
.padding(.vertical, 8)
}
.background {
Capsule().fill(Color.white)
}
}
}
}
.aspectRatio(1, contentMode: .fit)
Spacer()
.frame(height: overallGeometry.size.height * (overallGeometry.size.isLandscape ? 0.05 : 0.1))
Link(
OWSLocalizedString(
"SECONDARY_ONBOARDING_SCAN_CODE_HELP_TEXT",
comment: "Link text for page with troubleshooting info shown on the QR scanning screen"
),
destination: URL(string: "https://support.signal.org/hc/articles/360007320451")!
)
.font(.subheadline)
.foregroundStyle(Color.Signal.accent)
#if TESTABLE_BUILD
if
#available(iOS 16.0, *),
let provisioningUrl = model.qrCodeViewModel.qrCodeURL
{
// If on a physical device, this prefixing with some text
// allows one to AirDrop the URL to macOS to be copied into
// a simulator, instead of having macOS automatically try
// and open the URL (which Signal Desktop will try, and
// fail, to handle).
ShareLink(item: "Provisioning URL: \(provisioningUrl)") {
Text(LocalizationNotNeeded(
"Debug only: Share URL"
))
.font(.subheadline)
.foregroundStyle(Color.Signal.accent)
}
.simultaneousGesture(TapGesture().onEnded {
// When tapped, also copy to the clipboard for easy
// extraction from a simulator.
UIPasteboard.general.url = provisioningUrl
})
}
#endif
}
.multilineTextAlignment(.center)
}
}
}
// MARK: -
private struct PreviewView: View {
let urlDisplayMode: ProvisioningQRCodeView.Model.URLDisplayMode
var body: some View {
ProvisioningQRCodeView(
model: ProvisioningQRCodeView.Model(urlDisplayMode: urlDisplayMode),
onRefreshButtonPressed: {}
)
.padding(112)
}
}
#Preview("Loaded") {
PreviewView(urlDisplayMode: .loaded(URL(string: "https://signal.org")!))
}
#Preview("Loading") {
PreviewView(urlDisplayMode: .loading)
}
#Preview("Refresh Button") {
PreviewView(urlDisplayMode: .refreshButton)
}