Creating a UIImageView outside a view hierarchy now crashes on iOS 18. We can avoid this offscreen rendering technique by removing UIImage.asTintedImage(), configuring the tint colour and template rendering on the UIImage directly, and rendering with UIGraphicsImageRenderer instead of lower-level Core Graphics APIs.
504 lines
18 KiB
Swift
504 lines
18 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import MultipeerConnectivity
|
|
import SignalServiceKit
|
|
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(useCircularWrapper: false)
|
|
|
|
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: .alwaysTemplate),
|
|
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)
|
|
|
|
deviceTransferService.addObserver(self)
|
|
|
|
do {
|
|
let url = try deviceTransferService.startAcceptingTransfersFromOldDevices(
|
|
mode: .primary
|
|
)
|
|
|
|
qrCodeView.setQR(url: url)
|
|
} catch {
|
|
owsFailDebug("error \(error)")
|
|
}
|
|
}
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
deviceTransferService.removeObserver(self)
|
|
deviceTransferService.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: #imageLiteral(resourceName: "AppIcon"),
|
|
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")
|
|
}
|
|
}
|
|
}
|