Signal-iOS/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift
2024-03-25 13:26:31 -05:00

343 lines
11 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Photos
import PureLayout
import SignalCoreKit
import SignalServiceKit
import SignalUI
protocol UsernameLinkScanDelegate: AnyObject {
func usernameLinkScanned(_ usernameLink: Usernames.UsernameLink)
}
class UsernameLinkScanQRCodeViewController: OWSViewController, OWSNavigationChildController {
var preferredNavigationBarStyle: OWSNavigationBarStyle { .blur }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.portrait
}
/// Represents an item selected from the image picker, intended to contain a
/// username link QR code.
///
/// The image picker returns us a ``PHAsset`` and a convenient
/// ``SignalAttachment`` promise whenever an item is selected. It's a little
/// clunky, but rather than dealing with loading the asset we can use the
/// attachment promise to get the image, so we hold onto both.
private struct SelectedPickerItem {
let phAsset: PHAsset
let attachmentPromise: Promise<SignalAttachment>
}
private var selectedPickerItem: SelectedPickerItem?
weak var scanDelegate: UsernameLinkScanDelegate?
init(scanDelegate: UsernameLinkScanDelegate) {
self.scanDelegate = scanDelegate
super.init()
}
private var context: ViewControllerContext { .shared }
// MARK: - Views
private lazy var scanViewController = {
// Because we're overlaying things onto the scan view, the centered
// viewfinder frame looks a little off. Shifting it up by 16pt looks
// cleaner and matches the designs better.
let scanFrameWindowOffset = CGPoint(x: 0, y: -16)
let scanViewController = QRCodeScanViewController(
appearance: .framed(offset: scanFrameWindowOffset)
)
scanViewController.delegate = self
return scanViewController
}()
private lazy var instructionsLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.lineBreakMode = .byWordWrapping
label.text = OWSLocalizedString(
"USERNAME_LINK_SCAN_QR_CODE_INSTRUCTIONS_LABEL",
comment: "Text providing instructions on how to use the username link QR code scanning."
)
// Always use dark theme since it sits over the scan mask.
label.textColor = .ows_white
return label
}()
private lazy var uploadPhotoButton: UIButton = {
let button = OWSRoundedButton { [weak self] in
self?.didTapUploadPhotoButton()
}
button.contentEdgeInsets = UIEdgeInsets(margin: 14)
// Always use dark theming since it sits over the scan mask.
button.setTemplateImageName(
Theme.iconName(.buttonPhotoLibrary),
tintColor: .ows_white
)
button.backgroundColor = .ows_whiteAlpha20
return button
}()
// MARK: - Lifecycle
var navbarBackgroundColorOverride: UIColor? {
return OWSTableViewController2.tableBackgroundColor(
isUsingPresentedStyle: true
)
}
override func viewDidLoad() {
super.viewDidLoad()
addChild(scanViewController)
let instructionsWrapperView: UIView = {
let wrapper = UIView()
wrapper.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 20)
wrapper.addSubview(instructionsLabel)
instructionsLabel.autoPinEdgesToSuperviewMargins()
return wrapper
}()
view.addSubview(scanViewController.view)
view.addSubview(instructionsWrapperView)
view.addSubview(uploadPhotoButton)
scanViewController.view.autoPinEdgesToSuperviewEdges()
instructionsWrapperView.autoPinEdges(toSuperviewSafeAreaExcludingEdge: .bottom)
uploadPhotoButton.autoSetDimensions(to: .square(52))
uploadPhotoButton.autoHCenterInSuperview()
uploadPhotoButton.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 16)
themeDidChange()
contentSizeCategoryDidChange()
}
override func contentSizeCategoryDidChange() {
instructionsLabel.font = .dynamicTypeBody2
}
// MARK: Actions
private func didTapUploadPhotoButton() {
let imagePickerViewController = ImagePickerGridController()
imagePickerViewController.delegate = self
imagePickerViewController.dataSource = self
let imagePickerNavController = OWSNavigationController(
rootViewController: imagePickerViewController
)
presentFormSheet(imagePickerNavController, animated: true)
}
}
// MARK: - Scan delegate
extension UsernameLinkScanQRCodeViewController: QRCodeScanDelegate {
func qrCodeScanViewScanned(
_ qrCodeScanViewController: QRCodeScanViewController,
qrCodeData: Data?,
qrCodeString: String?
) -> QRCodeScanOutcome {
guard let qrCodeString else {
UsernameLogger.shared.error("Unexpectedly missing QR code string!")
return .continueScanning
}
guard
let scannedUrl = URL(string: qrCodeString),
let scannedUsernameLink = Usernames.UsernameLink(usernameLinkUrl: scannedUrl)
else {
UsernameLogger.shared.error(
"Failed to create username link from scanned QR code!"
)
return .continueScanning
}
guard let scanDelegate else {
UsernameLogger.shared.error("Missing scan delegate!")
return .continueScanning
}
scanDelegate.usernameLinkScanned(scannedUsernameLink)
return .stopScanning
}
func qrCodeScanViewDismiss(
_ qrCodeScanViewController: QRCodeScanViewController
) {
dismiss(animated: true)
}
}
// MARK: - Image Picker
extension UsernameLinkScanQRCodeViewController: ImagePickerGridControllerDelegate {
func imagePickerDidComplete(_ imagePicker: ImagePickerGridController) {
guard let selectedPickerItem = self.selectedPickerItem else {
return
}
firstly(on: context.schedulers.sync) { () -> Promise<Usernames.UsernameLink> in
return self.parseUsernameLinkFromAttachment(
attachmentPromise: selectedPickerItem.attachmentPromise
)
}
.done(on: context.schedulers.main) { usernameLink in
guard let scanDelegate = self.scanDelegate else {
throw OWSAssertionError("Missing scan delegate!")
}
// Our delegate will handle dismissing us, since in practice we're
// not the only controller that needs dismissing.
scanDelegate.usernameLinkScanned(usernameLink)
}
.catch(on: context.schedulers.main) { error in
OWSActionSheets.showErrorAlert(
message: CommonStrings.somethingWentWrongError,
fromViewController: imagePicker
)
}
}
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) {
imagePicker.dismiss(animated: true)
}
func imagePicker(
_ imagePicker: ImagePickerGridController,
didSelectAsset asset: PHAsset,
attachmentPromise: Promise<SignalAttachment>
) {
selectedPickerItem = SelectedPickerItem(
phAsset: asset,
attachmentPromise: attachmentPromise
)
}
func imagePicker(
_ imagePicker: ImagePickerGridController,
didDeselectAsset asset: PHAsset
) {
// Because we only ever have one selected item, we don't need to check
// if the deselected asset matches the one we had previously selected.
selectedPickerItem = nil
}
func imagePickerDidTryToSelectTooMany(
_ imagePicker: ImagePickerGridController
) {
// The selection will fail, and there's no need to show an error.
}
}
extension UsernameLinkScanQRCodeViewController: ImagePickerGridControllerDataSource {
func imagePicker(
_ imagePicker: ImagePickerGridController,
isAssetSelected asset: PHAsset
) -> Bool {
return selectedPickerItem?.phAsset == asset
}
func imagePickerCanSelectMoreItems(
_ imagePicker: ImagePickerGridController
) -> Bool {
return selectedPickerItem == nil
}
var numberOfMediaItems: Int {
return selectedPickerItem == nil ? 0 : 1
}
}
private extension UsernameLinkScanQRCodeViewController {
func parseUsernameLinkFromAttachment(
attachmentPromise: Promise<SignalAttachment>
) -> Promise<Usernames.UsernameLink> {
struct AttachmentToUsernameLinkFailedError: Error {}
return firstly(on: context.schedulers.sync) { () -> Promise<SignalAttachment> in
return attachmentPromise
}
.map(on: context.schedulers.sync) { attachment throws -> Usernames.UsernameLink in
guard let imageFromAttachment = attachment.image() else {
UsernameLogger.shared.error("Unexpectedly failed to get image from attachment!")
throw AttachmentToUsernameLinkFailedError()
}
guard let usernameLink = self.parseImageForQRCode(
image: imageFromAttachment
) else {
UsernameLogger.shared.warn("Image did not contain username link QR code!")
throw AttachmentToUsernameLinkFailedError()
}
return usernameLink
}
}
private func parseImageForQRCode(image: UIImage) -> Usernames.UsernameLink? {
guard let qrCodeDetector = CIDetector(
ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
) else {
UsernameLogger.shared.error("Failed to create QR code detector!")
return nil
}
guard let ciImage = CIImage(image: image) else {
UsernameLogger.shared.warn("Failed to create CIImage from image...")
return nil
}
let detectedFeatures = qrCodeDetector.features(in: ciImage)
guard
detectedFeatures.count == 1,
let qrCodeFeature = detectedFeatures.first as? CIQRCodeFeature
else {
UsernameLogger.shared.warn("Failed to detect QR code feature... Feature count: \(detectedFeatures.count)")
return nil
}
guard
let qrCodeMessageString = qrCodeFeature.messageString,
let qrCodeMessageUrl = URL(string: qrCodeMessageString)
else {
UsernameLogger.shared.warn("Failed to get message URL from QR code...")
return nil
}
guard let usernameLink = Usernames.UsernameLink(
usernameLinkUrl: qrCodeMessageUrl
) else {
UsernameLogger.shared.warn("Failed to construct username link from URL...")
return nil
}
return usernameLink
}
}