iOS/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift
Bruno Pantaleão Gonçalves 62fcf1d60d
Scan barcodes/qrcodes only inside the highlighted area (#3697)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2025-07-04 16:43:36 +02:00

368 lines
12 KiB
Swift

import AVFoundation
import CoreImage
import Shared
import UIKit
protocol BarcodeScannerCameraDelegate: AnyObject {
func didDetectBarcode(_ code: String, format: String)
}
final class BarcodeScannerCamera: NSObject {
private let captureSession = AVCaptureSession()
private var isCaptureSessionConfigured = false
private var deviceInput: AVCaptureDeviceInput?
private var videoOutput: AVCaptureVideoDataOutput?
private let metadataOutput = AVCaptureMetadataOutput()
private var sessionQueue: DispatchQueue!
private let feedbackGenerator = UINotificationFeedbackGenerator()
private var allBackCaptureDevices: [AVCaptureDevice] {
AVCaptureDevice.DiscoverySession(
deviceTypes: [
.builtInTripleCamera,
.builtInDualCamera,
.builtInWideAngleCamera,
],
mediaType: .video,
position: .back
).devices
}
public var screenSize: CGSize? {
didSet {
// Prevent unecessary updates
guard let screenSize else { return }
// Calculate normalized rectOfInterest for AVFoundation
let cameraSquareSize = BarcodeScannerView.cameraSquareSize
let x = (screenSize.width - cameraSquareSize) / 2.0
let y = (screenSize.height - cameraSquareSize) / 2.0
// AVFoundation expects (x, y, width, height) in normalized coordinates (0-1),
// and (0,0) is top-left of the video in portrait orientation
let normalizedX = y / screenSize.height
let normalizedY = x / screenSize.width
let normalizedWidth = cameraSquareSize / screenSize.height
let normalizedHeight = cameraSquareSize / screenSize.width
captureSession.beginConfiguration()
metadataOutput.rectOfInterest = CGRect(
x: normalizedX,
y: normalizedY,
width: normalizedWidth,
height: normalizedHeight
)
captureSession.commitConfiguration()
}
}
private var availableCaptureDevices: [AVCaptureDevice] {
allBackCaptureDevices
.filter(\.isConnected)
.filter({ !$0.isSuspended })
}
private var captureDevice: AVCaptureDevice? {
didSet {
guard let captureDevice else { return }
Current.Log.info("Using capture device: \(captureDevice.localizedName)")
sessionQueue.async {
self.updateSessionForCaptureDevice(captureDevice)
}
}
}
/// Last time a barcode was detected
private var lastDetection: Date?
weak var delegate: BarcodeScannerCameraDelegate?
var isRunning: Bool {
captureSession.isRunning
}
private var addToPhotoStream: ((AVCapturePhoto) -> Void)?
private var addToPreviewStream: ((CIImage) -> Void)?
var isPreviewPaused = false
lazy var previewStream: AsyncStream<CIImage>? = AsyncStream { continuation in
addToPreviewStream = { [weak self] ciImage in
guard let self else { return }
if !isPreviewPaused {
continuation.yield(ciImage)
}
}
}
override init() {
super.init()
self.sessionQueue = DispatchQueue(label: "session queue")
self.captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video)
feedbackGenerator.prepare()
}
private func configureCaptureSession(completionHandler: (_ success: Bool) -> Void) {
var success = false
captureSession.beginConfiguration()
defer {
self.captureSession.commitConfiguration()
completionHandler(success)
}
guard
let captureDevice,
let deviceInput = try? AVCaptureDeviceInput(device: captureDevice) else {
Current.Log.error("Failed to obtain video input.")
return
}
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "VideoDataOutputQueue"))
guard captureSession.canAddInput(deviceInput) else {
Current.Log.error("Unable to add device input to capture session.")
return
}
guard captureSession.canAddOutput(videoOutput) else {
Current.Log.error("Unable to add video output to capture session.")
return
}
guard captureSession.canAddOutput(metadataOutput) else {
Current.Log.error("Unable to add metadata output to capture session.")
return
}
captureSession.addInput(deviceInput)
captureSession.addOutput(videoOutput)
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
var metadataObjectTypes: [AVMetadataObject.ObjectType] = [
.qr,
.aztec,
.code128,
.code39,
.code93,
.dataMatrix,
.ean13,
.ean8,
.itf14,
.pdf417,
.upce,
]
if #available(iOS 15.4, *) {
metadataObjectTypes.append(.codabar)
}
metadataOutput.metadataObjectTypes = metadataObjectTypes
self.deviceInput = deviceInput
self.videoOutput = videoOutput
isCaptureSessionConfigured = true
success = true
}
private func checkAuthorization() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
Current.Log.info("Camera access authorized.")
return true
case .notDetermined:
Current.Log.info("Camera access not determined.")
sessionQueue.suspend()
let status = await AVCaptureDevice.requestAccess(for: .video)
sessionQueue.resume()
return status
case .denied:
Current.Log.info("Camera access denied.")
return false
case .restricted:
Current.Log.info("Camera library access restricted.")
return false
@unknown default:
return false
}
}
private func deviceInputFor(device: AVCaptureDevice?) -> AVCaptureDeviceInput? {
guard let validDevice = device else { return nil }
do {
return try AVCaptureDeviceInput(device: validDevice)
} catch {
Current.Log.error("Error getting capture device input: \(error.localizedDescription)")
return nil
}
}
private func updateSessionForCaptureDevice(_ captureDevice: AVCaptureDevice) {
guard isCaptureSessionConfigured else { return }
captureSession.beginConfiguration()
defer { captureSession.commitConfiguration() }
for input in captureSession.inputs {
if let deviceInput = input as? AVCaptureDeviceInput {
captureSession.removeInput(deviceInput)
}
}
if let deviceInput = deviceInputFor(device: captureDevice) {
if !captureSession.inputs.contains(deviceInput), captureSession.canAddInput(deviceInput) {
captureSession.addInput(deviceInput)
configureFocus(for: deviceInput.device)
}
}
}
private func configureFocus(for device: AVCaptureDevice) {
do {
try device.lockForConfiguration()
if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}
// Set focus point to center
if device.isFocusPointOfInterestSupported {
device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5) // Center of the screen
}
device.unlockForConfiguration()
} catch {
Current.Log.error("Error setting barcode scanner camera focus: \(error)")
}
}
func start() async {
guard !captureSession.isRunning else { return }
let authorized = await checkAuthorization()
guard authorized else {
Current.Log.error("Camera access was not authorized.")
return
}
if isCaptureSessionConfigured {
if !captureSession.isRunning {
sessionQueue.async { [self] in
captureSession.startRunning()
}
}
return
}
sessionQueue.async { [self] in
configureCaptureSession { success in
guard success else { return }
self.captureSession.startRunning()
}
}
}
func stop() {
guard isCaptureSessionConfigured else { return }
if captureSession.isRunning {
sessionQueue.async {
self.captureSession.stopRunning()
}
}
previewStream = nil
addToPreviewStream = nil
}
func toggleFlashlight() {
guard let captureDevice, captureDevice.hasTorch else { return }
do {
try captureDevice.lockForConfiguration()
if captureDevice.torchMode == .off {
try captureDevice.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
} else {
captureDevice.torchMode = .off
}
captureDevice.unlockForConfiguration()
} catch {
Current.Log.info("Flashlight could not be used: \(error)")
}
}
func turnOffFlashlight() {
guard let captureDevice, captureDevice.hasTorch else { return }
do {
try captureDevice.lockForConfiguration()
captureDevice.torchMode = .off
captureDevice.unlockForConfiguration()
} catch {
Current.Log.info("Flashlight could not be turned off: \(error)")
}
}
private var deviceOrientation: UIDeviceOrientation {
var orientation = UIDevice.current.orientation
if orientation == UIDeviceOrientation.unknown {
orientation = UIScreen.main.orientation
}
return orientation
}
private func videoOrientationFor(_ deviceOrientation: UIDeviceOrientation) -> AVCaptureVideoOrientation? {
switch deviceOrientation {
case .portrait: return AVCaptureVideoOrientation.portrait
case .portraitUpsideDown: return AVCaptureVideoOrientation.portraitUpsideDown
case .landscapeLeft: return AVCaptureVideoOrientation.landscapeRight
case .landscapeRight: return AVCaptureVideoOrientation.landscapeLeft
default: return nil
}
}
}
extension BarcodeScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard let pixelBuffer = sampleBuffer.imageBuffer else { return }
if connection.isVideoOrientationSupported,
let videoOrientation = videoOrientationFor(deviceOrientation) {
connection.videoOrientation = videoOrientation
}
addToPreviewStream?(CIImage(cvPixelBuffer: pixelBuffer))
}
}
extension BarcodeScannerCamera: AVCaptureMetadataOutputObjectsDelegate {
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
// Avoid several detections if user keeps camera pointing to the same barcode
if let lastDetection {
guard Current.date().timeIntervalSince(lastDetection) > 1.5 else { return }
}
if let metadataObject = metadataObjects.first {
let format = metadataObject.type.haString
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
guard let stringValue = readableObject.stringValue else { return }
feedbackGenerator.notificationOccurred(.success)
lastDetection = Current.date()
delegate?.didDetectBarcode(stringValue, format: format)
}
}
}