1084 lines
34 KiB
Swift
1084 lines
34 KiB
Swift
//
|
||
// Copyright 2021 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
public import AVFoundation
|
||
import Foundation
|
||
import SignalServiceKit
|
||
import Vision
|
||
|
||
public protocol QRCodeSampleBufferScannerDelegate: AnyObject {
|
||
/// A boolean indicating if the scanner should attempt to process QR codes.
|
||
/// This property will be accessed off the main thread.
|
||
var shouldProcessQRCodes: Bool { get }
|
||
|
||
/// Informs the delegate that a QR code has been found.
|
||
@MainActor
|
||
func qrCodeSampleBufferScanner(
|
||
_ sampleBufferScanner: QRCodeSampleBufferScanner,
|
||
didFindStringValue stringValue: String?,
|
||
dataValue: Data?,
|
||
)
|
||
|
||
/// Informs the delegate that there was an error in the
|
||
/// `VNDetectBarcodesRequest`.
|
||
@MainActor
|
||
func qrCodeSampleBufferScanner(
|
||
_ sampleBufferScanner: QRCodeSampleBufferScanner,
|
||
didFailWithError error: any Error,
|
||
)
|
||
}
|
||
|
||
public class QRCodeSampleBufferScanner: NSObject {
|
||
private weak var delegate: QRCodeSampleBufferScannerDelegate?
|
||
|
||
public init(delegate: QRCodeSampleBufferScannerDelegate?) {
|
||
self.delegate = delegate
|
||
}
|
||
|
||
private lazy var detectQRCodeRequest: VNDetectBarcodesRequest = {
|
||
let request = VNDetectBarcodesRequest { [weak self] request, error in
|
||
if let error {
|
||
DispatchQueue.main.async {
|
||
guard let self else { return }
|
||
self.delegate?.qrCodeSampleBufferScanner(self, didFailWithError: error)
|
||
}
|
||
return
|
||
}
|
||
self?.processClassification(request: request)
|
||
}
|
||
|
||
request.symbologies = [.qr]
|
||
|
||
return request
|
||
}()
|
||
|
||
private func processClassification(request: VNRequest) {
|
||
guard delegate?.shouldProcessQRCodes == true else {
|
||
return
|
||
}
|
||
|
||
typealias QRCode = (string: String?, data: Data?)
|
||
let qrCode: QRCode? = (request.results ?? [])
|
||
.lazy
|
||
.compactMap { $0 as? VNBarcodeObservation }
|
||
.filter { (barcode: VNBarcodeObservation) -> Bool in
|
||
barcode.symbology == .qr
|
||
&& barcode.barcodeDescriptor is CIQRCodeDescriptor
|
||
&& barcode.confidence > 0.9
|
||
}
|
||
.sorted { $0.confidence > $1.confidence }
|
||
.compactMap { (barcode: VNBarcodeObservation) -> QRCode? in
|
||
guard let qrCodeDescriptor = barcode.barcodeDescriptor as? CIQRCodeDescriptor else {
|
||
return nil
|
||
}
|
||
let qrCodeCodewords = qrCodeDescriptor.errorCorrectedPayload
|
||
let qrCodeVersion = qrCodeDescriptor.symbolVersion
|
||
let qrCodeString: String? = barcode.payloadStringValue
|
||
let qrCodeData: Data? = QRCodePayload.parse(
|
||
codewords: qrCodeCodewords,
|
||
qrCodeVersion: qrCodeVersion,
|
||
)?.data
|
||
|
||
guard qrCodeString != nil || qrCodeData != nil else {
|
||
return nil
|
||
}
|
||
|
||
return (qrCodeString, qrCodeData)
|
||
}
|
||
.first
|
||
|
||
guard let qrCode else {
|
||
return
|
||
}
|
||
|
||
Logger.info("Scanned QR Code.")
|
||
|
||
DispatchQueue.main.async {
|
||
self.delegate?.qrCodeSampleBufferScanner(self, didFindStringValue: qrCode.string, dataValue: qrCode.data)
|
||
}
|
||
}
|
||
}
|
||
|
||
extension QRCodeSampleBufferScanner: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||
public func captureOutput(
|
||
_ output: AVCaptureOutput,
|
||
didOutput sampleBuffer: CMSampleBuffer,
|
||
from connection: AVCaptureConnection,
|
||
) {
|
||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
||
return
|
||
}
|
||
let imageRequestHandler = VNImageRequestHandler(
|
||
cvPixelBuffer: pixelBuffer,
|
||
orientation: .right,
|
||
)
|
||
do {
|
||
try imageRequestHandler.perform([detectQRCodeRequest])
|
||
} catch {
|
||
owsFailDebug("Error: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum QRCodeScanOutcome: UInt {
|
||
case stopScanning
|
||
case continueScanning
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
@MainActor
|
||
public protocol QRCodeScanDelegate: AnyObject {
|
||
// A QR code scan might yield a String payload, Data payload or both.
|
||
//
|
||
// * Traditional QR code payloads are Strings, but that's not true of
|
||
// payloads like our safety numbers/fingerprints. In that case,
|
||
// a Data payload will be present but not a String payload.
|
||
// * iOS tries to parse QR code payloads as Strings but doesn't
|
||
// expose the underlying Data payload, only the "codewords" (an
|
||
// encoded form of the Data payload).
|
||
// We use QRCodePayload to parse the Data payload from the
|
||
// "codewords". QRCodePayload only supports a narrow set of "modes"
|
||
// & "configurations". iOS supports presumably the entire QR code
|
||
// standard. If a QR code contains Kanji, for example, a String
|
||
// payload will be present (parsed by iOS) but not a Data payload.
|
||
//
|
||
// In some scenarios, we require a Data payload. If a Data payload
|
||
// cannot be parsed, qrCodeScanViewScanned should presumably exit
|
||
// and return .continueScanning to ignore the scanned QR code. Or
|
||
// an error alert might be presented.
|
||
//
|
||
// In other scenarios, we require a String payload, e.g. when we
|
||
// expect a URL. Similarly, if a String payload is required and
|
||
// not present, we probably want to ignore the scanned QR code and
|
||
// continue scanning.
|
||
@discardableResult
|
||
func qrCodeScanViewScanned(
|
||
qrCodeData: Data?,
|
||
qrCodeString: String?,
|
||
) -> QRCodeScanOutcome
|
||
|
||
// QRCodeScanViewController DRYs up asking for camera permissions, etc.
|
||
// If scanning cannot be performed (e.g. a user declined to grant camera
|
||
// permissions), the delegate will be asked to dismiss.
|
||
func qrCodeScanViewDismiss(_ qrCodeScanViewController: QRCodeScanViewController)
|
||
|
||
var shouldShowUploadPhotoButton: Bool { get }
|
||
func didTapUploadPhotoButton(_ qrCodeScanViewController: QRCodeScanViewController)
|
||
}
|
||
|
||
public extension QRCodeScanDelegate {
|
||
var shouldShowUploadPhotoButton: Bool { false }
|
||
func didTapUploadPhotoButton(_ qrCodeScanViewController: QRCodeScanViewController) {}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
public class QRCodeScanViewController: OWSViewController {
|
||
|
||
public enum Appearance {
|
||
case framed
|
||
case unadorned
|
||
|
||
fileprivate var backgroundColor: UIColor {
|
||
switch self {
|
||
case .framed:
|
||
return .ows_black
|
||
case .unadorned:
|
||
return .clear
|
||
}
|
||
}
|
||
}
|
||
|
||
private let appearance: Appearance
|
||
private let showUploadPhotoButton: Bool
|
||
|
||
public weak var delegate: QRCodeScanDelegate?
|
||
|
||
private var scanner: QRCodeScanner?
|
||
|
||
public var prefersFrontFacingCamera = false {
|
||
didSet {
|
||
scanner?.prefersFrontFacingCamera = prefersFrontFacingCamera
|
||
}
|
||
}
|
||
|
||
public init(appearance: Appearance, showUploadPhotoButton: Bool = false) {
|
||
self.appearance = appearance
|
||
self.showUploadPhotoButton = showUploadPhotoButton
|
||
super.init()
|
||
}
|
||
|
||
override public var prefersStatusBarHidden: Bool {
|
||
return !DependenciesBridge.shared.currentCallProvider.hasCurrentCall
|
||
}
|
||
|
||
override public var prefersHomeIndicatorAutoHidden: Bool {
|
||
return true
|
||
}
|
||
|
||
private lazy var uploadPhotoButton: UIButton = {
|
||
let button = OWSRoundedButton { [weak self] in
|
||
guard let self else { return }
|
||
self.delegate?.didTapUploadPhotoButton(self)
|
||
}
|
||
|
||
button.ows_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: - View Lifecycle
|
||
|
||
override public func viewDidLoad() {
|
||
AssertIsOnMainThread()
|
||
|
||
super.viewDidLoad()
|
||
|
||
view.backgroundColor = appearance.backgroundColor
|
||
|
||
addObservers()
|
||
}
|
||
|
||
override public func viewDidAppear(_ animated: Bool) {
|
||
AssertIsOnMainThread()
|
||
|
||
super.viewDidAppear(animated)
|
||
|
||
tryToStartScanning()
|
||
}
|
||
|
||
override public func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
|
||
super.viewWillTransition(to: size, with: coordinator)
|
||
|
||
if let interfaceOrientation = self.view.window?.windowScene?.interfaceOrientation {
|
||
self.scanner?.updateVideoPreviewOrientation(interfaceOrientation)
|
||
}
|
||
}
|
||
|
||
override public func viewDidDisappear(_ animated: Bool) {
|
||
AssertIsOnMainThread()
|
||
|
||
super.viewDidDisappear(animated)
|
||
|
||
stopScanning()
|
||
}
|
||
|
||
// MARK: - Notifications
|
||
|
||
private func addObservers() {
|
||
AssertIsOnMainThread()
|
||
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(didEnterBackground),
|
||
name: .OWSApplicationDidEnterBackground,
|
||
object: nil,
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(didBecomeActive),
|
||
name: .OWSApplicationDidBecomeActive,
|
||
object: nil,
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(logSessionRuntimeError),
|
||
name: .AVCaptureSessionRuntimeError,
|
||
object: nil,
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(logSessionInterruptError),
|
||
name: .AVCaptureSessionWasInterrupted,
|
||
object: nil,
|
||
)
|
||
}
|
||
|
||
@objc
|
||
private func didEnterBackground() {
|
||
AssertIsOnMainThread()
|
||
|
||
stopScanning()
|
||
}
|
||
|
||
@objc
|
||
private func didBecomeActive() {
|
||
AssertIsOnMainThread()
|
||
|
||
if self.view.window != nil {
|
||
tryToStartScanning()
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func logSessionRuntimeError(notification: Notification) {
|
||
guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? NSError else {
|
||
Logger.info("Error running AVCaptureSession: no specific error provided")
|
||
return
|
||
}
|
||
|
||
Logger.error("Error running AVCaptureSession: \(error.localizedDescription)")
|
||
}
|
||
|
||
@objc
|
||
private func logSessionInterruptError(notification: Notification) {
|
||
if let userInfo = notification.userInfo {
|
||
guard
|
||
let reasonValue = userInfo[AVCaptureSessionInterruptionReasonKey] as? NSNumber,
|
||
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue.intValue)
|
||
else {
|
||
Logger.info("session was interrupted for no apparent reason")
|
||
return
|
||
}
|
||
Logger.info("session was interrupted with reason code: \(reason.rawValue)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Scanning
|
||
|
||
private func stopScanning() {
|
||
scanner = nil
|
||
viewfinderAnimator?.stopAnimation(true)
|
||
viewfinderAnimator = nil
|
||
}
|
||
|
||
@objc
|
||
public func tryToStartScanning() {
|
||
AssertIsOnMainThread()
|
||
|
||
guard nil == scanner else {
|
||
Logger.info("Early return because scanner is not nil")
|
||
return
|
||
}
|
||
|
||
self.ows_askForCameraPermissions { [weak self] granted in
|
||
guard let self else { return }
|
||
|
||
if granted {
|
||
self.startScanning()
|
||
} else {
|
||
self.delegate?.qrCodeScanViewDismiss(self)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var viewfinderAnimator: UIViewPropertyAnimator?
|
||
|
||
/// Continuously animates the viewfinder, updating `viewfinderAnimator` with the latest animator.
|
||
///
|
||
/// - Important:
|
||
///
|
||
/// Stop the animation (stored in `viewfinderAnimator`) when the view disappears.
|
||
///
|
||
/// - Parameters:
|
||
/// - frame: The viewfinder frame to animate.
|
||
/// - isReversed: `false` if the viewfinder frame should enlarge,
|
||
/// `true` if it should shrink.
|
||
private func animateViewfinder(frame: UIView, isReversed: Bool = false) {
|
||
guard view.window != nil else { return }
|
||
let animator = UIViewPropertyAnimator(
|
||
duration: 0.35,
|
||
springDamping: 1,
|
||
springResponse: 0.35,
|
||
)
|
||
animator.addAnimations {
|
||
frame.transform = isReversed ? .identity : .scale(1.1)
|
||
}
|
||
animator.addCompletion { [weak self] _ in
|
||
self?.animateViewfinder(frame: frame, isReversed: !isReversed)
|
||
}
|
||
// Play every 1 second, so subtract the animation duration from 1 second
|
||
animator.startAnimation(afterDelay: 1 - 0.35)
|
||
viewfinderAnimator = animator
|
||
}
|
||
|
||
private func startScanning() {
|
||
AssertIsOnMainThread()
|
||
|
||
guard scanner == nil else {
|
||
owsFailDebug("already scanning")
|
||
return
|
||
}
|
||
|
||
let scanner = QRCodeScanner(
|
||
prefersFrontFacingCamera: self.prefersFrontFacingCamera,
|
||
scannerDelegate: self,
|
||
)
|
||
self.scanner = scanner
|
||
|
||
view.removeAllSubviews()
|
||
|
||
let previewView = scanner.previewView
|
||
view.addSubview(previewView)
|
||
previewView.autoPinEdgesToSuperviewEdges()
|
||
|
||
switch appearance {
|
||
case .unadorned:
|
||
break
|
||
case .framed:
|
||
let shouldAnimateScale = !UIAccessibility.isReduceMotionEnabled
|
||
|
||
let viewfinder = UIImage(named: "qr_viewfinder")
|
||
let frame = UIImageView(image: viewfinder)
|
||
self.view.addSubview(frame)
|
||
frame.autoHCenterInSuperview()
|
||
frame.centerYAnchor.constraint(
|
||
equalTo: self.view.safeAreaLayoutGuide.centerYAnchor,
|
||
constant: showUploadPhotoButton ? -16 : 0,
|
||
).isActive = true
|
||
|
||
frame.layer.opacity = 0
|
||
if shouldAnimateScale {
|
||
frame.transform = .scale(1.2)
|
||
}
|
||
let entranceAnimator = UIViewPropertyAnimator(
|
||
duration: 0.3,
|
||
springDamping: 1,
|
||
springResponse: 0.3,
|
||
)
|
||
entranceAnimator.addAnimations {
|
||
frame.layer.opacity = 1
|
||
frame.transform = .identity
|
||
}
|
||
|
||
entranceAnimator.startAnimation()
|
||
|
||
if shouldAnimateScale {
|
||
animateViewfinder(frame: frame)
|
||
}
|
||
}
|
||
|
||
if showUploadPhotoButton {
|
||
view.addSubview(uploadPhotoButton)
|
||
uploadPhotoButton.autoSetDimensions(to: .square(52))
|
||
uploadPhotoButton.autoHCenterInSuperview()
|
||
uploadPhotoButton.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 16)
|
||
}
|
||
|
||
let initialOrientation = self.view.window!.windowScene!.interfaceOrientation
|
||
firstly {
|
||
scanner.startVideoCapture(initialOrientation: initialOrientation)
|
||
}.done {
|
||
Logger.info("Ready.")
|
||
}.catch { [weak self] error in
|
||
owsFailDebug("Error: \(error)")
|
||
guard let self else { return }
|
||
self.showFailureUI(error: error)
|
||
}
|
||
}
|
||
|
||
private func showFailureUI(error: Error) {
|
||
Logger.error("error: \(error)")
|
||
|
||
OWSActionSheets.showActionSheet(
|
||
title: nil,
|
||
message: error.userErrorDescription,
|
||
buttonTitle: CommonStrings.dismissButton,
|
||
buttonAction: { [weak self] _ in
|
||
guard let self else { return }
|
||
self.delegate?.qrCodeScanViewDismiss(self)
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private enum QRCodeError: Error {
|
||
case invalidCodewords
|
||
case unknownMode
|
||
case unsupportedConfiguration
|
||
case invalidLength
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
// iOS tries to parse QR code payloads as Strings but doesn't
|
||
// expose the underlying Data payload, only the "codewords" (an
|
||
// encoded form of the Data payload).
|
||
//
|
||
// QRCodePayload can parse some Data payloads from the "codewords".
|
||
// QRCodePayload only supports a narrow set of "modes"
|
||
// & "configurations", but this is sufficient for the cases where
|
||
// we need it: safety number fingerprints.
|
||
//
|
||
// This isn't an efficient way to parse the codewords, but
|
||
// correctness matters and perf doesn't, since QR code payloads
|
||
// are inherently small. Therefore, this approach favors simplicity
|
||
// over efficiency.
|
||
public class QRCodePayload {
|
||
public let version: Int
|
||
public let mode: Mode
|
||
public let bytes: [UInt8]
|
||
|
||
public var data: Data {
|
||
Data(bytes)
|
||
}
|
||
|
||
public var asString: String? {
|
||
String(data: data, encoding: .utf8)
|
||
}
|
||
|
||
init(version: Int, mode: Mode, bytes: [UInt8]) {
|
||
self.version = version
|
||
self.mode = mode
|
||
self.bytes = bytes
|
||
}
|
||
|
||
// There are even more modes, but it'll improve logging a bit
|
||
// to identify these modes even though we don't support them.
|
||
//
|
||
// TODO: We currently only support .byte mode.
|
||
public enum Mode: UInt {
|
||
case numeric = 1
|
||
case alphaNumeric = 2
|
||
case bytes = 4
|
||
case kanji = 8
|
||
}
|
||
|
||
public static func parse(
|
||
codewords: Data,
|
||
qrCodeVersion version: Int,
|
||
) -> QRCodePayload? {
|
||
// QR Code Standard
|
||
// ISO/IEC 18004:2015
|
||
// https://www.iso.org/standard/62021.html
|
||
do {
|
||
let bitstream = QRCodeBitStream(codewords: codewords)
|
||
|
||
let modeLength: UInt = 4
|
||
let modeBits = try bitstream.readUInt8(bitCount: modeLength)
|
||
guard let mode = Mode(rawValue: UInt(modeBits)) else {
|
||
let ignoreUnknownMode = CurrentAppContext().isRunningTests
|
||
if ignoreUnknownMode {
|
||
Logger.error("Invalid mode: \(modeBits)")
|
||
return nil
|
||
} else {
|
||
owsFailDebug("Invalid mode: \(modeBits)")
|
||
}
|
||
throw QRCodeError.unknownMode
|
||
}
|
||
// TODO: We currently only support .byte mode.
|
||
guard mode == .bytes else {
|
||
Logger.warn("Unsupported mode: \(mode)")
|
||
throw QRCodeError.unsupportedConfiguration
|
||
}
|
||
|
||
let characterCountLength = try characterCountIndicatorLengthBits(
|
||
version: version,
|
||
mode: mode,
|
||
)
|
||
let characterCount = try bitstream.readUInt32(bitCount: characterCountLength)
|
||
guard characterCount > 0 else {
|
||
Logger.error("Invalid length: \(characterCount)")
|
||
throw QRCodeError.invalidLength
|
||
}
|
||
// TODO: We currently only support .byte mode.
|
||
var bytes = [UInt8]()
|
||
for _ in 0..<characterCount {
|
||
let byte = try bitstream.readUInt8(bitCount: 8)
|
||
bytes.append(byte)
|
||
}
|
||
return QRCodePayload(version: version, mode: mode, bytes: bytes)
|
||
} catch {
|
||
owsFailDebug("Error: \(error)")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static func characterCountIndicatorLengthBits(
|
||
version: Int,
|
||
mode: Mode,
|
||
) throws -> UInt {
|
||
if version >= 1, version <= 9 {
|
||
switch mode {
|
||
case .numeric:
|
||
return 10
|
||
case .alphaNumeric:
|
||
return 9
|
||
case .bytes:
|
||
return 8
|
||
case .kanji:
|
||
return 8
|
||
}
|
||
} else if version >= 10, version <= 26 {
|
||
switch mode {
|
||
case .numeric:
|
||
return 12
|
||
case .alphaNumeric:
|
||
return 11
|
||
case .bytes:
|
||
return 16
|
||
case .kanji:
|
||
return 10
|
||
}
|
||
} else if version >= 27, version <= 40 {
|
||
switch mode {
|
||
case .numeric:
|
||
return 14
|
||
case .alphaNumeric:
|
||
return 13
|
||
case .bytes:
|
||
return 16
|
||
case .kanji:
|
||
return 12
|
||
}
|
||
}
|
||
throw QRCodeError.unsupportedConfiguration
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
// This isn't an efficient way to parse the codewords, but
|
||
// correctness matters and perf doesn't, since QR code payloads
|
||
// are inherently small.
|
||
private class QRCodeBitStream {
|
||
private var bits: [UInt8]
|
||
|
||
init(codewords: Data) {
|
||
var bits = [UInt8]()
|
||
for codeword in codewords {
|
||
var codeword: UInt8 = codeword
|
||
var codewordBits = [UInt8]()
|
||
for _ in 0..<8 {
|
||
let bit = codeword & 1
|
||
codeword = codeword >> 1
|
||
codewordBits.append(UInt8(bit))
|
||
}
|
||
owsAssertDebug(codeword == 0)
|
||
// We reverse; we want to stream bits in "most significant-to-least-significant"
|
||
// order.
|
||
bits.append(contentsOf: codewordBits.reversed())
|
||
}
|
||
self.bits = bits
|
||
}
|
||
|
||
private func readBit() throws -> UInt8 {
|
||
guard !bits.isEmpty else {
|
||
throw QRCodeError.invalidCodewords
|
||
}
|
||
return bits.removeFirst()
|
||
}
|
||
|
||
fileprivate func readUInt8(bitCount: UInt) throws -> UInt8 {
|
||
owsAssertDebug(bitCount > 0)
|
||
owsAssertDebug(bitCount <= 8)
|
||
|
||
return UInt8(try readUInt32(bitCount: bitCount))
|
||
}
|
||
|
||
fileprivate func readUInt32(bitCount: UInt) throws -> UInt32 {
|
||
owsAssertDebug(bitCount > 0)
|
||
owsAssertDebug(bitCount <= 32)
|
||
|
||
var result: UInt32 = 0
|
||
for _ in 0..<bitCount {
|
||
let bit = try readBit()
|
||
result = (result << 1) | UInt32(bit)
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
extension QRCodeScanViewController: QRCodeSampleBufferScannerDelegate {
|
||
|
||
public var shouldProcessQRCodes: Bool { true }
|
||
|
||
@MainActor
|
||
public func qrCodeSampleBufferScanner(
|
||
_ sampleBufferScanner: QRCodeSampleBufferScanner,
|
||
didFindStringValue qrCodeString: String?,
|
||
dataValue qrCodeData: Data?,
|
||
) {
|
||
guard self.scanner?.owns(sampleBufferScanner: sampleBufferScanner) == true else {
|
||
Logger.warn("ignoring scan result from old scanner")
|
||
return
|
||
}
|
||
|
||
let outcome = self.delegate?.qrCodeScanViewScanned(
|
||
qrCodeData: qrCodeData,
|
||
qrCodeString: qrCodeString,
|
||
)
|
||
|
||
switch outcome {
|
||
case .stopScanning:
|
||
self.stopScanning()
|
||
ImpactHapticFeedback.impactOccurred(style: .medium)
|
||
case nil:
|
||
Logger.warn("ignoring scan result because there's no delegate")
|
||
fallthrough
|
||
case .continueScanning:
|
||
break
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
public func qrCodeSampleBufferScanner(_ sampleBufferScanner: QRCodeSampleBufferScanner, didFailWithError error: any Error) {
|
||
showFailureUI(error: error)
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
enum QRCodeScanError: Error {
|
||
case assertionError(description: String)
|
||
case initializationFailed
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private class QRCodeScanner {
|
||
|
||
private(set) lazy var previewView = QRCodeScanPreviewView(session: session)
|
||
|
||
private let sessionQueue = DispatchQueue(label: "org.signal.qrcode-scanner")
|
||
|
||
private let session = AVCaptureSession()
|
||
private let output: QRCodeScanOutput
|
||
|
||
private var _captureOrientation: AVCaptureVideoOrientation = .portrait
|
||
var captureOrientation: AVCaptureVideoOrientation {
|
||
get {
|
||
assertIsOnSessionQueue()
|
||
return _captureOrientation
|
||
}
|
||
set {
|
||
assertIsOnSessionQueue()
|
||
_captureOrientation = newValue
|
||
}
|
||
}
|
||
|
||
init(
|
||
prefersFrontFacingCamera: Bool,
|
||
scannerDelegate: any QRCodeSampleBufferScannerDelegate,
|
||
) {
|
||
self.prefersFrontFacingCamera = prefersFrontFacingCamera
|
||
self.output = QRCodeScanOutput(scannerDelegate: scannerDelegate)
|
||
|
||
if #available(iOS 16.0, *) {
|
||
if session.isMultitaskingCameraAccessSupported {
|
||
session.isMultitaskingCameraAccessEnabled = true
|
||
}
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
sessionQueue.async(.promise) { [session] in
|
||
session.stopRunning()
|
||
}.done {
|
||
Logger.info("stopCapture completed")
|
||
}.catch { error in
|
||
owsFailDebug("Error: \(error)")
|
||
}
|
||
}
|
||
|
||
func owns(sampleBufferScanner: QRCodeSampleBufferScanner) -> Bool {
|
||
return (self.output.sampleBufferScanner as QRCodeSampleBufferScanner) === sampleBufferScanner
|
||
}
|
||
|
||
// MARK: - Public
|
||
|
||
func updateVideoPreviewOrientation(_ newValue: UIInterfaceOrientation) {
|
||
sessionQueue.async {
|
||
let captureOrientation = AVCaptureVideoOrientation(interfaceOrientation: newValue) ?? .portrait
|
||
if self.captureOrientation == captureOrientation {
|
||
return
|
||
}
|
||
self.captureOrientation = captureOrientation
|
||
self._updateVideoPreviewConnectionOrientation()
|
||
}
|
||
}
|
||
|
||
private func _updateVideoPreviewConnectionOrientation() {
|
||
assertIsOnSessionQueue()
|
||
guard let videoConnection = previewView.previewLayer.connection else {
|
||
Logger.info("previewView hasn't completed setup yet.")
|
||
return
|
||
}
|
||
if videoConnection.isVideoOrientationSupported {
|
||
videoConnection.videoOrientation = self.captureOrientation
|
||
}
|
||
}
|
||
|
||
var prefersFrontFacingCamera: Bool {
|
||
didSet {
|
||
sessionQueue.async {
|
||
guard self.session.isRunning else {
|
||
// No need to update yet.
|
||
return
|
||
}
|
||
self.session.beginConfiguration()
|
||
try? self.setCurrentInput()
|
||
self.session.commitConfiguration()
|
||
}
|
||
}
|
||
}
|
||
|
||
func startVideoCapture(initialOrientation: UIInterfaceOrientation) -> Promise<Void> {
|
||
AssertIsOnMainThread()
|
||
|
||
guard !Platform.isSimulator else {
|
||
// Trying to actually set up the capture session will fail on a simulator
|
||
// since we don't have actual capture devices. But it's useful to be able
|
||
// to mostly run the capture code on the simulator to work with layout.
|
||
return Promise.value(())
|
||
}
|
||
|
||
// If the session is already running, no need to do anything.
|
||
guard !self.session.isRunning else {
|
||
Logger.info("Early return, session already running")
|
||
return Promise.value(())
|
||
}
|
||
|
||
let initialCaptureOrientation = AVCaptureVideoOrientation(interfaceOrientation: initialOrientation) ?? .portrait
|
||
|
||
return sessionQueue.async(.promise) { [weak self] in
|
||
guard let self else { return }
|
||
|
||
self.session.beginConfiguration()
|
||
defer { self.session.commitConfiguration() }
|
||
|
||
self.captureOrientation = initialCaptureOrientation
|
||
self.session.sessionPreset = .high
|
||
|
||
try self.setCurrentInput()
|
||
|
||
let videoDataOutput = self.output.videoDataOutput
|
||
guard self.session.canAddOutput(videoDataOutput) else {
|
||
owsFailDebug("!canAddOutput(videoDataOutput).")
|
||
throw QRCodeScanError.initializationFailed
|
||
}
|
||
self.session.addOutput(videoDataOutput)
|
||
guard let connection = videoDataOutput.connection(with: .video) else {
|
||
owsFailDebug("Missing videoDataOutput.connection.")
|
||
throw QRCodeScanError.initializationFailed
|
||
}
|
||
if connection.isVideoStabilizationSupported {
|
||
connection.preferredVideoStabilizationMode = .auto
|
||
}
|
||
}.done(on: sessionQueue) {
|
||
self.session.startRunning()
|
||
self._updateVideoPreviewConnectionOrientation()
|
||
}
|
||
}
|
||
|
||
func assertIsOnSessionQueue() {
|
||
assertOnQueue(sessionQueue)
|
||
}
|
||
|
||
// This method should be called on the serial queue,
|
||
// and between calls to session.beginConfiguration/commitConfiguration
|
||
func setCurrentInput() throws {
|
||
assertIsOnSessionQueue()
|
||
|
||
let device = try selectCaptureDevice()
|
||
|
||
try device.lockForConfiguration()
|
||
|
||
// Setting (focus/exposure)PointOfInterest alone does not initiate a (focus/exposure) operation.
|
||
// Call set(Focus/Exposure)Mode() to apply the new point of interest.
|
||
let focusMode: AVCaptureDevice.FocusMode = .continuousAutoFocus
|
||
if device.isFocusModeSupported(focusMode) {
|
||
device.focusMode = focusMode
|
||
}
|
||
|
||
let exposureMode: AVCaptureDevice.ExposureMode = .continuousAutoExposure
|
||
if device.isExposureModeSupported(exposureMode) {
|
||
device.exposureMode = exposureMode
|
||
}
|
||
|
||
device.unlockForConfiguration()
|
||
|
||
let newInput = try AVCaptureDeviceInput(device: device)
|
||
|
||
// Remove any existing inputs.
|
||
session.inputs.forEach {
|
||
session.removeInput($0)
|
||
}
|
||
session.addInput(newInput)
|
||
|
||
// The default videoZoomFactor is the ultrawide lens when there is one,
|
||
// so set it to the main lens and let it switch to the ultrawide for
|
||
// macro mode automatically. This only works after it's added to the
|
||
// session for some reason 🤷♀️
|
||
if let wideAngleZoomFactor = device.virtualDeviceSwitchOverVideoZoomFactors.first {
|
||
try device.lockForConfiguration()
|
||
device.videoZoomFactor = CGFloat(truncating: wideAngleZoomFactor)
|
||
device.unlockForConfiguration()
|
||
}
|
||
}
|
||
|
||
private func selectCaptureDevice() throws -> AVCaptureDevice {
|
||
assertIsOnSessionQueue()
|
||
|
||
// Camera types in descending order of preference.
|
||
var deviceTypes = [AVCaptureDevice.DeviceType]()
|
||
deviceTypes.append(.builtInTripleCamera)
|
||
deviceTypes.append(.builtInDualWideCamera)
|
||
deviceTypes.append(.builtInDualCamera)
|
||
deviceTypes.append(.builtInWideAngleCamera)
|
||
deviceTypes.append(.builtInUltraWideCamera)
|
||
deviceTypes.append(.builtInTelephotoCamera)
|
||
|
||
func selectDevice(session: AVCaptureDevice.DiscoverySession) -> AVCaptureDevice? {
|
||
var deviceMap = [AVCaptureDevice.DeviceType: AVCaptureDevice]()
|
||
for device in session.devices {
|
||
deviceMap[device.deviceType] = device
|
||
}
|
||
for deviceType in deviceTypes {
|
||
if let device = deviceMap[deviceType] {
|
||
return device
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
let preferredPosition: AVCaptureDevice.Position =
|
||
prefersFrontFacingCamera ? .front : .back
|
||
let preferredSession = AVCaptureDevice.DiscoverySession(
|
||
deviceTypes: deviceTypes,
|
||
mediaType: .video,
|
||
position: preferredPosition,
|
||
)
|
||
if let device = selectDevice(session: preferredSession) {
|
||
return device
|
||
}
|
||
// Failover to other camera.
|
||
let failoverPosition: AVCaptureDevice.Position =
|
||
prefersFrontFacingCamera ? .back : .front
|
||
let failoverSession = AVCaptureDevice.DiscoverySession(
|
||
deviceTypes: deviceTypes,
|
||
mediaType: .video,
|
||
position: failoverPosition,
|
||
)
|
||
if let device = selectDevice(session: failoverSession) {
|
||
return device
|
||
}
|
||
|
||
throw QRCodeScanError.assertionError(description: "Missing videoDevice.")
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private class QRCodeScanPreviewView: UIView {
|
||
|
||
let previewLayer: AVCaptureVideoPreviewLayer
|
||
|
||
override var bounds: CGRect {
|
||
didSet {
|
||
previewLayer.frame = bounds
|
||
}
|
||
}
|
||
|
||
override var contentMode: UIView.ContentMode {
|
||
get {
|
||
switch previewLayer.videoGravity {
|
||
case .resizeAspectFill:
|
||
return .scaleAspectFill
|
||
case .resizeAspect:
|
||
return .scaleAspectFit
|
||
case .resize:
|
||
return .scaleToFill
|
||
default:
|
||
owsFailDebug("Unexpected contentMode")
|
||
return .scaleToFill
|
||
}
|
||
}
|
||
set {
|
||
switch newValue {
|
||
case .scaleAspectFill:
|
||
previewLayer.videoGravity = .resizeAspectFill
|
||
case .scaleAspectFit:
|
||
previewLayer.videoGravity = .resizeAspect
|
||
case .scaleToFill:
|
||
previewLayer.videoGravity = .resize
|
||
default:
|
||
owsFailDebug("Unexpected contentMode")
|
||
}
|
||
}
|
||
}
|
||
|
||
init(session: AVCaptureSession) {
|
||
previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||
if Platform.isSimulator {
|
||
// helpful for debugging layout on simulator which has no real capture device
|
||
previewLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor
|
||
}
|
||
super.init(frame: .zero)
|
||
self.contentMode = .scaleAspectFill
|
||
previewLayer.frame = bounds
|
||
layer.addSublayer(previewLayer)
|
||
}
|
||
|
||
required init?(coder aDecoder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private class QRCodeScanOutput {
|
||
|
||
let videoDataOutput = AVCaptureVideoDataOutput()
|
||
let sampleBufferScanner: QRCodeSampleBufferScanner
|
||
|
||
// MARK: - Init
|
||
|
||
init(scannerDelegate: any QRCodeSampleBufferScannerDelegate) {
|
||
self.sampleBufferScanner = QRCodeSampleBufferScanner(delegate: scannerDelegate)
|
||
videoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)]
|
||
videoDataOutput.setSampleBufferDelegate(
|
||
self.sampleBufferScanner,
|
||
queue: DispatchQueue(label: "qr-code-scan-output", qos: .default),
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
public extension AVCaptureVideoOrientation {
|
||
init?(deviceOrientation: UIDeviceOrientation) {
|
||
switch deviceOrientation {
|
||
case .unknown:
|
||
return nil
|
||
case .portrait: self = .portrait
|
||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||
case .landscapeLeft: self = .landscapeRight
|
||
case .landscapeRight: self = .landscapeLeft
|
||
case .faceUp:
|
||
return nil
|
||
case .faceDown:
|
||
return nil
|
||
@unknown default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
init?(interfaceOrientation: UIInterfaceOrientation) {
|
||
switch interfaceOrientation {
|
||
case .unknown:
|
||
return nil
|
||
case .portrait: self = .portrait
|
||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||
case .landscapeLeft: self = .landscapeLeft
|
||
case .landscapeRight: self = .landscapeRight
|
||
@unknown default:
|
||
return nil
|
||
}
|
||
}
|
||
}
|