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

508 lines
18 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import MultipeerConnectivity
import SignalServiceKit
public import SignalUI
public class RegistrationTransferQRCodeViewController: OWSViewController, OWSNavigationChildController {
public var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
public var navbarBackgroundColorOverride: UIColor? { .clear }
public var prefersNavigationBarHidden: Bool { true }
public override var preferredStatusBarStyle: UIStatusBarStyle {
return isQRCodeExpanded ? .lightContent : super.preferredStatusBarStyle
}
private lazy var qrCodeView = QRCodeView(contentInset: 8)
private lazy var expansionButton: ExpansionButton = {
let button = ExpansionButton()
button.addTarget(self, action: #selector(toggleQRCodeExpansion), for: .touchUpInside)
return button
}()
private lazy var qrCodeSizeButtonSpacer = SpacerView()
private lazy var compactQRCodeContainer: UIView = {
let view = UIView()
view.layer.cornerRadius = 12
view.backgroundColor = Theme.secondaryBackgroundColor
view.addSubview(qrCodeView)
view.addSubview(expansionButton)
qrCodeView.autoPinEdge(toSuperviewEdge: .top, withInset: 16)
expansionButton.autoPinEdge(.top, to: .bottom, of: qrCodeView, withOffset: 12)
expansionButton.autoPinEdge(toSuperviewEdge: .bottom, withInset: 16)
qrCodeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 16)
qrCodeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 16)
expansionButton.autoHCenterInSuperview()
return view
}()
private lazy var explanationLabel2: UILabel = {
let explanationLabel2 = UILabel.explanationLabelForRegistration(
text: OWSLocalizedString(
"DEVICE_TRANSFER_QRCODE_EXPLANATION2",
comment: "The second explanation for the device transfer qr code view"
)
)
explanationLabel2.setContentHuggingHigh()
return explanationLabel2
}()
private lazy var closeButton: UIButton = {
let closeButton = OWSButton()
closeButton.setImage(
Theme.iconImage(.buttonX).withTintColor(.ows_white, renderingMode: .alwaysOriginal),
for: .normal
)
closeButton.contentMode = .center
closeButton.addTarget(self, action: #selector(compactQRCode), for: .touchUpInside)
return closeButton
}()
private let titleLabel = UILabel.titleLabelForRegistration(
text: OWSLocalizedString(
"DEVICE_TRANSFER_QRCODE_TITLE",
comment: "The title for the device transfer qr code view"
)
)
private let explanationLabel = UILabel.explanationLabelForRegistration(
text: OWSLocalizedString(
"DEVICE_TRANSFER_QRCODE_EXPLANATION",
comment: "The explanation for the device transfer qr code view"
)
)
private lazy var helpButton = OWSFlatButton.linkButtonForRegistration(
title: OWSLocalizedString(
"DEVICE_TRANSFER_QRCODE_NOT_SEEING",
comment: "A prompt to provide further explanation if the user is not seeing the transfer on both devices."
),
target: self,
selector: #selector(didTapHelp)
)
private lazy var cancelButton = OWSFlatButton.linkButtonForRegistration(
title: CommonStrings.cancelButton,
target: self,
selector: #selector(didTapCancel)
)
override public func loadView() {
view = UIView()
view.backgroundColor = Theme.backgroundColor
view.addSubview(titleLabel)
titleLabel.accessibilityIdentifier = "onboarding.transferQRCode.titleLabel"
titleLabel.setContentHuggingHigh()
explanationLabel.textColor = Theme.primaryTextColor
explanationLabel.accessibilityIdentifier = "onboarding.transferQRCode.bodyLabel"
explanationLabel.setContentHuggingHigh()
qrCodeView.setContentHuggingVerticalLow()
helpButton.button.titleLabel?.textAlignment = .center
helpButton.button.titleLabel?.numberOfLines = 0
helpButton.button.titleLabel?.lineBreakMode = .byWordWrapping
let stackView = UIStackView(arrangedSubviews: [
titleLabel,
explanationLabel,
compactQRCodeContainer,
explanationLabel2,
UIView.vStretchingSpacer(),
helpButton,
cancelButton
])
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 20
view.addSubview(stackView)
stackView.autoPinEdge(toSuperviewMargin: .top, withInset: 20)
stackView.autoPinLeadingToSuperviewMargin()
stackView.autoPinTrailingToSuperviewMargin()
stackView.autoPinBottomToSuperviewMargin()
qrCodeView.autoPinToSquareAspectRatio()
qrCodeView.autoMatch(
.width,
to: .width,
of: view,
withMultiplier: Constants.compactQRCodeWidthMultiple
).priority = .defaultHigh
qrCodeView.autoSetDimension(
.width,
toSize: Constants.compactQRCodeMinSize,
relation: .greaterThanOrEqual
).priority = .required
for view in [titleLabel, explanationLabel, explanationLabel2] {
view.setCompressionResistanceHigh()
view.autoPinEdge(toSuperviewMargin: .leading)
view.autoPinEdge(toSuperviewMargin: .trailing)
}
view.addSubview(closeButton)
closeButton.autoPinLeadingToSuperviewMargin()
closeButton.autoPinTopToSuperviewMargin(withInset: 8)
updateExpansionState(animated: false)
view.addGestureRecognizer(UITapGestureRecognizer(
target: self,
action: #selector(compactQRCode)
))
}
override public func viewDidLoad() {
super.viewDidLoad()
navigationItem.setHidesBackButton(true, animated: false)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
AppEnvironment.shared.deviceTransferServiceRef.addObserver(self)
Task { @MainActor in
do {
let url = try AppEnvironment.shared.deviceTransferServiceRef.startAcceptingTransfersFromOldDevices(
mode: .primary
)
qrCodeView.setQRCode(
url: url,
stylingMode: .brandedWithoutLogo
)
} catch {
owsFailDebug("error \(error)")
}
}
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
AppEnvironment.shared.deviceTransferServiceRef.removeObserver(self)
AppEnvironment.shared.deviceTransferServiceRef.stopAcceptingTransfersFromOldDevices()
}
// MARK: - QR Code expansion
private var isQRCodeExpanded = false
private func updateExpansionState(animated: Bool) {
view.isUserInteractionEnabled = false
let otherViews = [
titleLabel,
explanationLabel,
helpButton,
cancelButton
]
let animations: () -> Void = {
if self.isQRCodeExpanded {
let desiredSize = UIScreen.main.bounds.width - (Constants.expandedQRCodeMargin * 2)
let qrExpandScale = desiredSize / self.qrCodeView.frame.width
let currentQRCenterY = self.view.convert(
self.qrCodeView.center,
from: self.qrCodeView.superview
).y
let desiredQRCenterY = self.view.frame.height * 0.4
let qrExpandYOffset = desiredQRCenterY - currentQRCenterY
self.qrCodeView.layer.anchorPoint = .init(x: 0.5, y: 0.5)
self.qrCodeView.transform = .scale(qrExpandScale)
.concatenating(.translate(CGPoint(x: 0, y: qrExpandYOffset)))
let expandedQRCodeBottom = desiredQRCenterY + (desiredSize / 2)
let desiredButtonTop = expandedQRCodeBottom + 16
let currentButtonTop = self.view.convert(
self.expansionButton.frame.origin,
from: self.expansionButton.superview
).y
let buttonYOffset = max(0, desiredButtonTop - currentButtonTop)
self.expansionButton.transform = .translate(CGPoint(x: 0, y: buttonYOffset))
let desiredLabelTop = desiredButtonTop + self.expansionButton.frame.height + 16
let currentLabelTop = self.view.convert(
self.explanationLabel2.frame.origin,
from: self.explanationLabel2.superview
).y
let labelYOffset = max(0, desiredLabelTop - currentLabelTop)
self.explanationLabel2.transform = .translate(CGPoint(x: 0, y: labelYOffset))
self.explanationLabel2.textColor = Theme.darkThemePrimaryColor
self.closeButton.alpha = 1
otherViews.forEach { $0.alpha = 0 }
self.compactQRCodeContainer.backgroundColor = .clear
self.view.backgroundColor = .ows_black
self.qrCodeView.backgroundColor = .ows_white
self.expansionButton.mode = .contract
} else {
self.qrCodeView.transform = .identity
self.expansionButton.transform = .identity
self.explanationLabel2.transform = .identity
self.explanationLabel2.textColor = Theme.primaryTextColor
self.closeButton.alpha = 0
otherViews.forEach { $0.alpha = 1 }
self.compactQRCodeContainer.backgroundColor = Theme.secondaryBackgroundColor
self.view.backgroundColor = Theme.backgroundColor
self.qrCodeView.backgroundColor = .ows_white
self.expansionButton.mode = .expand
}
}
guard animated else {
animations()
view.isUserInteractionEnabled = true
return
}
UIView.animate(withDuration: 0.2, animations: animations) { _ in
self.view.isUserInteractionEnabled = true
}
}
@objc
private func toggleQRCodeExpansion() {
isQRCodeExpanded = !isQRCodeExpanded
updateExpansionState(animated: true)
setNeedsStatusBarAppearanceUpdate()
}
@objc
private func compactQRCode() {
guard isQRCodeExpanded else { return }
toggleQRCodeExpansion()
}
// MARK: - Events
weak var permissionActionSheetController: ActionSheetController?
@objc
func didTapHelp() {
guard !isQRCodeExpanded else { return }
let turnOnView = TurnOnPermissionView(
title: OWSLocalizedString(
"LOCAL_NETWORK_PERMISSION_ACTION_SHEET_TITLE",
comment: "Title for local network permission action sheet"
),
message: OWSLocalizedString(
"LOCAL_NETWORK_PERMISSION_ACTION_SHEET_BODY",
comment: "Body for local network permission action sheet"
),
steps: [
.init(
icon: #imageLiteral(resourceName: "settings-app-icon-32"),
text: OWSLocalizedString(
"LOCAL_NETWORK_PERMISSION_ACTION_SHEET_STEP_ONE",
comment: "First step for local network permission action sheet"
)
),
.init(
icon: UIImage(resource: UIApplication.shared.currentAppIcon.previewImageResource),
text: OWSLocalizedString(
"LOCAL_NETWORK_PERMISSION_ACTION_SHEET_STEP_TWO",
comment: "Second step for local network permission action sheet"
)
),
.init(
icon: UIImage(imageLiteralResourceName: "toggle-32"),
text: OWSLocalizedString(
"LOCAL_NETWORK_PERMISSION_ACTION_SHEET_STEP_THREE",
comment: "Third step for local network permission action sheet"
)
)
],
button: OWSFlatButton.primaryButtonForRegistration(
title: OWSLocalizedString(
"LOCAL_NETWORK_PERMISSION_ACTION_SHEET_NEED_HELP",
comment: "A button asking the user if they need further help getting their transfer working."
),
target: self,
selector: #selector(didTapContactSupport)
)
)
let actionSheetController = ActionSheetController()
permissionActionSheetController = actionSheetController
actionSheetController.customHeader = turnOnView
actionSheetController.isCancelable = true
presentActionSheet(actionSheetController)
}
@objc
func didTapCancel() {
guard !isQRCodeExpanded else { return }
Logger.info("")
guard let navigationController = navigationController else {
return owsFailBeta("unexpectedly missing nav controller")
}
navigationController.popViewController(animated: true)
}
@objc
func didTapContactSupport() {
guard !isQRCodeExpanded else { return }
Logger.info("")
permissionActionSheetController?.dismiss(animated: true)
permissionActionSheetController = nil
ContactSupportAlert.presentStep2(
emailSupportFilter: "Signal iOS Transfer",
fromViewController: self
)
}
// MARK: - Expand/Contract Button
private class ExpansionButton: UIButton {
enum Mode {
case expand
case contract
var text: String {
switch self {
case .expand:
return OWSLocalizedString(
"DEVICE_TRANSFER_EXPAND_QR_CODE_BUTTON",
comment: "Button shown to expand a QR code and view it fullscreen."
)
case .contract:
return OWSLocalizedString(
"DEVICE_TRANSFER_CONTRACT_QR_CODE_BUTTON",
comment: "Button shown to contract a QR code and exit the fullscreen view."
)
}
}
var image: UIImage {
switch self {
case .expand:
return Theme.iconImage(.maximize)
case .contract:
return Theme.iconImage(.minimize)
}
}
var backgroundColor: UIColor {
switch self {
case .expand: return .clear
case .contract: return Theme.darkThemeSecondaryBackgroundColor
}
}
var textColor: UIColor {
switch self {
case .expand: return Theme.primaryTextColor
case .contract: return Theme.darkThemePrimaryColor
}
}
}
var mode: Mode = .expand {
didSet {
updateForMode()
}
}
private lazy var _imageView = UIImageView()
private lazy var _label = UILabel()
init() {
super.init(frame: .zero)
addSubview(_imageView)
addSubview(_label)
_imageView.autoPinLeadingToSuperviewMargin()
_imageView.autoPinEdge(toSuperviewEdge: .leading, withInset: 12)
_label.autoPinEdge(.leading, to: .trailing, of: _imageView, withOffset: 4)
_imageView.autoPinHeight(toHeightOf: _label, offset: -4)
_imageView.autoPinEdge(
.top,
to: .top,
of: _label,
withOffset: 2
)
_label.autoPinEdge(toSuperviewEdge: .top, withInset: 6, relation: .greaterThanOrEqual)
_label.autoPinEdge(toSuperviewEdge: .bottom, withInset: 8, relation: .lessThanOrEqual)
_label.autoPinEdge(toSuperviewEdge: .trailing, withInset: 16)
_imageView.autoPinToSquareAspectRatio()
_label.setContentHuggingHigh()
_imageView.setContentHuggingHigh()
updateForMode()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateForMode() {
_label.text = mode.text
_imageView.image = mode.image
_imageView.tintColor = mode.textColor
_label.textColor = mode.textColor
backgroundColor = mode.backgroundColor
layer.cornerRadius = bounds.height / 2
}
override var bounds: CGRect {
didSet {
layer.cornerRadius = bounds.height / 2
}
}
}
// MARK: - Constants
private enum Constants {
static let compactQRCodeWidthMultiple: CGFloat = 0.6
static let compactQRCodeMinSize: CGFloat = 182
static let expandedQRCodeMargin: CGFloat = 8
}
}
extension RegistrationTransferQRCodeViewController: DeviceTransferServiceObserver {
func deviceTransferServiceDiscoveredNewDevice(peerId: MCPeerID, discoveryInfo: [String: String]?) {}
func deviceTransferServiceDidStartTransfer(progress: Progress) {
let view = RegistrationTransferProgressViewController(progress: progress)
navigationController?.pushViewController(view, animated: true)
}
func deviceTransferServiceDidEndTransfer(error: DeviceTransferService.Error?) {
if let error = error {
owsFailDebug("unexpected error while rendering QR code \(error)")
}
}
func deviceTransferServiceDidRequestAppRelaunch() {
if CurrentAppContext().frontmostViewController() == self {
owsFail("Relaunch not supported from QR screen")
}
}
}