Signal-iOS/SignalUI/ViewControllers/ScanQRCodeViewController.swift
2026-05-07 11:14:56 -07:00

1084 lines
34 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
}
}