From 02a8457410c807054de7774ed7ef7da6b3dad435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= Date: Mon, 19 Feb 2024 10:53:59 +0100 Subject: [PATCH] Add QR code scanner (#2576) ## Summary Add QR code scanner that can be triggered from HA and receive result response ## Screenshots https://github.com/home-assistant/iOS/assets/5808343/0e4d4f18-7507-44f2-8844-e701448943dd ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes --- HomeAssistant.xcodeproj/project.pbxproj | 36 ++ .../QRCodeScanner/BarcodeScannerView.swift | 136 +++++++ .../BarcodeScannerViewModel.swift | 45 +++ .../Camera/BarcodeScannerCamera.swift | 334 ++++++++++++++++++ .../Camera/BarcodeScannerCameraView.swift | 27 ++ .../Camera/BarcodeScannerDataModel.swift | 37 ++ .../Resources/en.lproj/Localizable.strings | 3 +- .../WebViewExternalBusMessageTests.swift | 13 +- Sources/App/WebView/WebViewController.swift | 62 +++- .../WebView/WebViewExternalBusMessage.swift | 9 + .../API/WebSocket/WebSocketMessage.swift | 4 +- .../Shared/Resources/Swiftgen/Strings.swift | 2 + 12 files changed, 693 insertions(+), 15 deletions(-) create mode 100644 Sources/App/QRCodeScanner/BarcodeScannerView.swift create mode 100644 Sources/App/QRCodeScanner/BarcodeScannerViewModel.swift create mode 100644 Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift create mode 100644 Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift create mode 100644 Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 01a62deb9..9e7cb68b1 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -504,6 +504,11 @@ 420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420FE84A2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift */; }; 420FE84E2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420FE84D2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift */; }; 420FE8502B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420FE84F2B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift */; }; + 42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */; }; + 42266B1D2B741FB400E94A71 /* BarcodeScannerCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */; }; + 42266B1F2B741FB400E94A71 /* BarcodeScannerDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */; }; + 42266B202B741FB400E94A71 /* BarcodeScannerCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */; }; + 42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */; }; 424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F452B188946008C8DF3 /* WidgetBackground.swift */; }; 424A7F482B188BF3008C8DF3 /* WidgetContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */; }; 424DD05A2B3509170057E456 /* CarPlayActionsTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424DD0592B3509170057E456 /* CarPlayActionsTemplate.swift */; }; @@ -1569,6 +1574,11 @@ 420FE84A2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlayActionsTemplate+Build.swift"; sourceTree = ""; }; 420FE84D2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayEntitiesListViewModel.swift; sourceTree = ""; }; 420FE84F2B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlayEntitiesListTemplate+Build.swift"; sourceTree = ""; }; + 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerView.swift; sourceTree = ""; }; + 42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BarcodeScannerCameraView.swift; sourceTree = ""; }; + 42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BarcodeScannerDataModel.swift; sourceTree = ""; }; + 42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BarcodeScannerCamera.swift; sourceTree = ""; }; + 42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerViewModel.swift; sourceTree = ""; }; 4242A2B12B2B5C8000E9F001 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; 4242A2B22B2B5C8100E9F001 /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "ca-ES"; path = "ca-ES.lproj/AppIntentVocabulary.plist"; sourceTree = ""; }; 4242A2B32B2B5C8100E9F001 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "zh-Hans"; path = "zh-Hans.lproj/AppIntentVocabulary.plist"; sourceTree = ""; }; @@ -3052,6 +3062,26 @@ path = Entities; sourceTree = ""; }; + 42266B0F2B740E3600E94A71 /* QRCodeScanner */ = { + isa = PBXGroup; + children = ( + 42266B152B741F9F00E94A71 /* Camera */, + 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */, + 42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */, + ); + path = QRCodeScanner; + sourceTree = ""; + }; + 42266B152B741F9F00E94A71 /* Camera */ = { + isa = PBXGroup; + children = ( + 42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */, + 42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */, + 42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */, + ); + path = Camera; + sourceTree = ""; + }; 425573C52B55729E00145217 /* Servers */ = { isa = PBXGroup; children = ( @@ -3493,6 +3523,7 @@ B657A8E81CA646EB00121384 /* App */ = { isa = PBXGroup; children = ( + 42266B0F2B740E3600E94A71 /* QRCodeScanner */, B657A8E91CA646EB00121384 /* AppDelegate.swift */, D03D893720E0AF1B00D4F28D /* ClientEvents */, 11A183B22511BCF300CA326A /* LifecycleManager.swift */, @@ -5610,6 +5641,7 @@ 425573CC2B5574AD00145217 /* CarPlayAreasZonesTemplate+Build.swift in Sources */, B626AAF11D8F972800A0D225 /* SettingsDetailViewController.swift in Sources */, 1127381C2622B6F300F5E312 /* DebugSettingsViewController.swift in Sources */, + 42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */, 11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */, 11DA6B4F2713912F008ADFAF /* OnboardingPermissionViewController.swift in Sources */, 42CA28BB2B1028330093B31A /* SimulatorThreadClientService.swift in Sources */, @@ -5633,6 +5665,7 @@ B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, + 42266B202B741FB400E94A71 /* BarcodeScannerCamera.swift in Sources */, 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, @@ -5672,6 +5705,7 @@ 42F1DA612B4D4F31002729BC /* CarPlayNoServerAlert.swift in Sources */, 11C590ED24A832CA0066085D /* YamlSection.swift in Sources */, 42F1DA5B2B4BF7DF002729BC /* WindowSizeObserver.swift in Sources */, + 42266B1D2B741FB400E94A71 /* BarcodeScannerCameraView.swift in Sources */, 11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */, 1185DF94271FBA6100ED7D9A /* OnboardingAuthDetails.swift in Sources */, 420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */, @@ -5712,6 +5746,7 @@ 1185DFB3271FF53800ED7D9A /* OnboardingAuthStepSensors.swift in Sources */, 11108D632634C8FE009DAB0F /* LearnMoreButtonRow.swift in Sources */, 425573D12B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift in Sources */, + 42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */, 11B62DBE24F2EDD800E5CB55 /* EurekaCondition+Additions.swift in Sources */, B661FB74226C110A00E541DD /* OnboardingWelcomeViewController.swift in Sources */, 1164DA2125FBEE8600515E8A /* TemplateEditViewController.swift in Sources */, @@ -5735,6 +5770,7 @@ 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */, 11EFCDD324F5F39100314D85 /* WebViewWindowController.swift in Sources */, 11EFCDE024F60E5900314D85 /* BasicSceneDelegate.swift in Sources */, + 42266B1F2B741FB400E94A71 /* BarcodeScannerDataModel.swift in Sources */, 425573C92B5572DB00145217 /* CarPlayServerListViewModel.swift in Sources */, 11A71C6F24A4644A00D9565F /* ZoneManagerIgnoreReason.swift in Sources */, 1101568324D770B2009424C9 /* iOSTagManager.swift in Sources */, diff --git a/Sources/App/QRCodeScanner/BarcodeScannerView.swift b/Sources/App/QRCodeScanner/BarcodeScannerView.swift new file mode 100644 index 000000000..023dad9b1 --- /dev/null +++ b/Sources/App/QRCodeScanner/BarcodeScannerView.swift @@ -0,0 +1,136 @@ +import Shared +import SwiftUI + +struct BarcodeScannerView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: BarcodeScannerViewModel + // Use single data model so both camera previews use same camera stream + @State private var cameraDataModel = BarcodeScannerDataModel() + private let cameraSquareSize: CGFloat = 320 + private let flashlightIcon = MaterialDesignIcons.flashlightIcon.image( + ofSize: .init(width: 24, height: 24), + color: .white + ) + + private let title: String + private let description: String + private let alternativeOptionLabel: String? + + init( + title: String, + description: String, + alternativeOptionLabel: String? = nil, + incomingMessageId: Int + ) { + self.title = title + self.description = description + self.alternativeOptionLabel = alternativeOptionLabel + self._viewModel = .init(wrappedValue: .init(incomingMessageId: incomingMessageId)) + } + + var body: some View { + ZStack(alignment: .top) { + ZStack { + cameraBackground + cameraSquare + } + .ignoresSafeArea() + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + + topInformation + } + .onAppear { + cameraDataModel.camera.qrFound = { code, format in + viewModel.scannedCode(code, format: format) + } + } + } + + private var topInformation: some View { + VStack(spacing: 8) { + Button(action: { + viewModel.aborted(.canceled) + dismiss() + }, label: { + Image(systemName: "xmark") + .font(.title2) + .foregroundColor(.white.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + }) + .accessibilityHint(.init(L10n.closeLabel)) + Group { + Text(title) + .padding(.top) + .font(.title2) + Text(description) + .font(.subheadline) + } + .foregroundColor(.white) + + if let alternativeOptionLabel { + Button { + viewModel.aborted(.alternativeOptions) + dismiss() + } label: { + Text(alternativeOptionLabel) + .font(.subheadline) + .foregroundColor(.accentColor) + } + .padding(.top) + } + } + .padding() + } + + private var cameraBackground: some View { + BarcodeScannerCameraView(model: cameraDataModel) + .ignoresSafeArea() + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .overlay { + Color.black.opacity(0.8) + } + } + + private var cameraSquare: some View { + BarcodeScannerCameraView(model: cameraDataModel, shouldStartCamera: false) + .ignoresSafeArea() + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .mask { + RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + .frame(width: cameraSquareSize, height: cameraSquareSize) + } + .overlay { + ZStack(alignment: .bottomTrailing) { + RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + .stroke(Color.blue, lineWidth: 1) + .frame(width: cameraSquareSize, height: cameraSquareSize) + Button(action: { + toggleFlashlight() + }, label: { + Image(uiImage: flashlightIcon) + .padding() + .background(Color(uiColor: .init(hex: "#384956"))) + .mask(Circle()) + .offset(x: -22, y: -22) + }) + } + } + } + + private func toggleFlashlight() { + cameraDataModel.toggleFlashlight() + } +} + +#Preview { + BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", incomingMessageId: 1) +} + +final class BarcodeScannerHostingController: UIHostingController { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + [.portrait] + } +} diff --git a/Sources/App/QRCodeScanner/BarcodeScannerViewModel.swift b/Sources/App/QRCodeScanner/BarcodeScannerViewModel.swift new file mode 100644 index 000000000..461da4a06 --- /dev/null +++ b/Sources/App/QRCodeScanner/BarcodeScannerViewModel.swift @@ -0,0 +1,45 @@ +import Foundation +import Shared + +final class BarcodeScannerViewModel: ObservableObject { + enum AbortReason: String { + case canceled + case alternativeOptions = "alternative_options" + } + + private let incomingMessageId: Int + + init(incomingMessageId: Int) { + self.incomingMessageId = incomingMessageId + } + + func scannedCode(_ code: String, format: String) { + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { [weak self] controller in + guard let incomingMessageId = self?.incomingMessageId else { return } + controller + .sendExternalBus(message: .init( + id: incomingMessageId, + command: WebViewExternalBusOutgoingMessage.barCodeScanResult.rawValue, + payload: [ + "rawValue": code, + "format": format, + ] + )) + } + } + + func aborted(_ reason: AbortReason) { + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { [weak self] controller in + guard let incomingMessageId = self?.incomingMessageId else { return } + controller.sendExternalBus(message: .init( + id: incomingMessageId, + command: WebViewExternalBusOutgoingMessage.barCodeScanAborted.rawValue, + payload: [ + "reason": reason.rawValue, + ] + )) + } + } +} diff --git a/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift new file mode 100644 index 000000000..b5e2b62f9 --- /dev/null +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift @@ -0,0 +1,334 @@ +import AVFoundation +import CoreImage +import Shared +import UIKit + +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: [ + .builtInTrueDepthCamera, + .builtInDualCamera, + .builtInDualWideCamera, + .builtInWideAngleCamera, + .builtInDualWideCamera, + ], + mediaType: .video, + position: .back + ).devices + } + + private var availableCaptureDevices: [AVCaptureDevice] { + allBackCaptureDevices + .filter(\.isConnected) + .filter({ !$0.isSuspended }) + } + + private var captureDevice: AVCaptureDevice? { + didSet { + guard let captureDevice = captureDevice else { return } + Current.Log.info("Using capture device: \(captureDevice.localizedName)") + sessionQueue.async { + self.updateSessionForCaptureDevice(captureDevice) + } + } + } + + var qrFound: ((_ code: String, _ format: String) -> Void)? + var isRunning: Bool { + captureSession.isRunning + } + + private var addToPhotoStream: ((AVCapturePhoto) -> Void)? + + private var addToPreviewStream: ((CIImage) -> Void)? + + var isPreviewPaused = false + + lazy var previewStream: AsyncStream = AsyncStream { continuation in + addToPreviewStream = { ciImage in + if !self.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 = 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) + } + } + } + + 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 + self.captureSession.startRunning() + } + } + return + } + + sessionQueue.async { [self] in + self.configureCaptureSession { success in + guard success else { return } + self.captureSession.startRunning() + } + } + } + + func stop() { + guard isCaptureSessionConfigured else { return } + + if captureSession.isRunning { + sessionQueue.async { + self.captureSession.stopRunning() + } + } + } + + 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)") + } + } + + 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 + ) { + 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) + qrFound?(stringValue, format) + } + } +} + +private extension UIScreen { + var orientation: UIDeviceOrientation { + let point = coordinateSpace.convert(CGPoint.zero, to: fixedCoordinateSpace) + if point == CGPoint.zero { + return .portrait + } else if point.x != 0, point.y != 0 { + return .portraitUpsideDown + } else if point.x == 0, point.y != 0 { + return .landscapeRight + } else if point.x != 0, point.y == 0 { + return .landscapeLeft + } else { + return .unknown + } + } +} + +private extension AVMetadataObject.ObjectType { + var haString: String { + if #available(iOS 15.4, *), self == .codabar { + return "codabar" + } + + switch self { + case .qr: return "qr_code" + case .aztec: return "aztec" + case .code128: return "code_128" + case .code39: return "code_39" + case .code93: return "code_93" + case .dataMatrix: return "data_matrix" + case .ean13: return "ean_13" + case .ean8: return "ean_8" + case .itf14: return "itf" + case .pdf417: return "pdf417" + case .upce: return "upc_e" + default: return "unknown" + } + } +} diff --git a/Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift new file mode 100644 index 000000000..9e1308d83 --- /dev/null +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct BarcodeScannerCameraView: View { + @StateObject private var model: BarcodeScannerDataModel + private let shouldStartCamera: Bool + + init(model: BarcodeScannerDataModel, shouldStartCamera: Bool = true) { + self._model = .init(wrappedValue: model) + self.shouldStartCamera = shouldStartCamera + } + + var body: some View { + GeometryReader { geometry in + if let image = $model.viewfinderImage.wrappedValue { + image + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + .task { + if shouldStartCamera { + await model.camera.start() + } + } + } +} diff --git a/Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift new file mode 100644 index 000000000..771a542d2 --- /dev/null +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift @@ -0,0 +1,37 @@ +import AVFoundation +import os.log +import SwiftUI + +final class BarcodeScannerDataModel: ObservableObject { + let camera = BarcodeScannerCamera() + + @Published var viewfinderImage: Image? + init() { + Task { + await handleCameraPreviews() + } + } + + func handleCameraPreviews() async { + let imageStream = camera.previewStream + .map(\.image) + + for await image in imageStream { + Task { @MainActor in + viewfinderImage = image + } + } + } + + func toggleFlashlight() { + camera.toggleFlashlight() + } +} + +private extension CIImage { + var image: Image? { + let ciContext = CIContext() + guard let cgImage = ciContext.createCGImage(self, from: extent) else { return nil } + return Image(decorative: cgImage, scale: 1, orientation: .up) + } +} diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index c33e981de..a9a361aec 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -116,6 +116,7 @@ "debug_section_label" = "Debug"; "delete" = "Delete"; "done_label" = "Done"; +"close_label" = "Close"; "error_label" = "Error"; "extensions.map.location.new" = "New Location"; "extensions.map.location.original" = "Original Location"; @@ -822,4 +823,4 @@ Home Assistant is free and open source home automation software with a focus on "widgets.open_page.description" = "Open a frontend page in Home Assistant."; "widgets.open_page.not_configured" = "No Pages Available"; "widgets.open_page.title" = "Open Page"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; diff --git a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift index 5bc86651f..5204aa381 100644 --- a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift +++ b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift @@ -11,7 +11,18 @@ final class WebViewExternalBusMessageTests: XCTestCase { XCTAssertEqual(WebViewExternalBusMessage.themeUpdate.rawValue, "theme-update") XCTAssertEqual(WebViewExternalBusMessage.matterCommission.rawValue, "matter/commission") XCTAssertEqual(WebViewExternalBusMessage.threadImportCredentials.rawValue, "thread/import_credentials") + XCTAssertEqual(WebViewExternalBusMessage.barCodeScanner.rawValue, "bar_code/scan") + XCTAssertEqual(WebViewExternalBusMessage.barCodeScannerClose.rawValue, "bar_code/close") + XCTAssertEqual(WebViewExternalBusMessage.barCodeScannerNotify.rawValue, "bar_code/notify") - XCTAssertEqual(WebViewExternalBusMessage.allCases.count, 9) + XCTAssertEqual(WebViewExternalBusMessage.allCases.count, 12) + } + + func test_externalBus_outgoing_messageKeys() { + XCTAssertEqual(WebViewExternalBusOutgoingMessage.showAutomationEditor.rawValue, "automation/editor/show") + XCTAssertEqual(WebViewExternalBusOutgoingMessage.barCodeScanResult.rawValue, "bar_code/scan_result") + XCTAssertEqual(WebViewExternalBusOutgoingMessage.barCodeScanAborted.rawValue, "bar_code/aborted") + + XCTAssertEqual(WebViewExternalBusOutgoingMessage.allCases.count, 3) } } diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index f3b354356..82ae6036a 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -25,6 +25,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg private var keepAliveTimer: Timer? private var initialURL: URL? + private var barCodeScannerController: UIViewController? private let settingsButton: UIButton! = { let button = UIButton() @@ -839,19 +840,22 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg showActionAutomationEditorNotAvailable() return } - sendExternalBus(message: .init(command: "automation/editor/show", payload: [ - "config": [ - "trigger": [ - [ - "platform": "event", - "event_type": "ios.action_fired", - "event_data": [ - "actionID": actionId, + sendExternalBus(message: .init( + command: WebViewExternalBusOutgoingMessage.showAutomationEditor.rawValue, + payload: [ + "config": [ + "trigger": [ + [ + "platform": "event", + "event_type": "ios.action_fired", + "event_data": [ + "actionID": actionId, + ], ], ], ], - ], - ])) + ] + )) } private func showActionAutomationEditorNotAvailable() { @@ -1001,6 +1005,7 @@ extension WebViewController: WKScriptMessageHandler { "canWriteTag": Current.tags.isNFCAvailable, "canCommissionMatter": Current.matter.isAvailable, "canImportThreadCredentials": Current.matter.threadCredentialsSharingEnabled, + "hasQRScanner": true, ] )) } @@ -1061,6 +1066,24 @@ extension WebViewController: WKScriptMessageHandler { } case .threadImportCredentials: threadCredentialsRequested() + case .barCodeScanner: + guard let title = incomingMessage.Payload?["title"] as? String, + let description = incomingMessage.Payload?["description"] as? String, + let incomingMessageId = incomingMessage.ID else { return } + qrCodeScannerRequested( + title: title, + description: description, + alternativeOptionLabel: incomingMessage.Payload?["alternative_option_label"] as? String, + incomingMessageId: incomingMessageId + ) + case .barCodeScannerClose: + barCodeScannerController?.dismiss(animated: true) + case .barCodeScannerNotify: + guard let message = incomingMessage.Payload?["message"] as? String else { return } + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(.init(title: L10n.okLabel, style: .default)) + let controller = barCodeScannerController ?? self + controller.present(alert, animated: false, completion: nil) } } else { Current.Log.error("unknown: \(incomingMessage.MessageType)") @@ -1072,7 +1095,7 @@ extension WebViewController: WKScriptMessageHandler { } @discardableResult - private func sendExternalBus(message: WebSocketMessage) -> Promise { + public func sendExternalBus(message: WebSocketMessage) -> Promise { Promise { seal in DispatchQueue.main.async { [self] in do { @@ -1106,6 +1129,23 @@ extension WebViewController: WKScriptMessageHandler { present(threadManagementView, animated: true) } } + + private func qrCodeScannerRequested( + title: String, + description: String, + alternativeOptionLabel: String?, + incomingMessageId: Int + ) { + barCodeScannerController = BarcodeScannerHostingController(rootView: BarcodeScannerView( + title: title, + description: description, + alternativeOptionLabel: alternativeOptionLabel, + incomingMessageId: incomingMessageId + )) + barCodeScannerController?.modalPresentationStyle = .fullScreen + guard let barCodeScannerController else { return } + present(barCodeScannerController, animated: true) + } } extension WebViewController: UIScrollViewDelegate { diff --git a/Sources/App/WebView/WebViewExternalBusMessage.swift b/Sources/App/WebView/WebViewExternalBusMessage.swift index 5d28a6cc7..fb64987cc 100644 --- a/Sources/App/WebView/WebViewExternalBusMessage.swift +++ b/Sources/App/WebView/WebViewExternalBusMessage.swift @@ -10,4 +10,13 @@ enum WebViewExternalBusMessage: String, CaseIterable { case themeUpdate = "theme-update" case matterCommission = "matter/commission" case threadImportCredentials = "thread/import_credentials" + case barCodeScanner = "bar_code/scan" + case barCodeScannerClose = "bar_code/close" + case barCodeScannerNotify = "bar_code/notify" +} + +enum WebViewExternalBusOutgoingMessage: String, CaseIterable { + case showAutomationEditor = "automation/editor/show" + case barCodeScanResult = "bar_code/scan_result" + case barCodeScanAborted = "bar_code/aborted" } diff --git a/Sources/Shared/API/WebSocket/WebSocketMessage.swift b/Sources/Shared/API/WebSocket/WebSocketMessage.swift index bd14bbf32..6531c4dbd 100644 --- a/Sources/Shared/API/WebSocket/WebSocketMessage.swift +++ b/Sources/Shared/API/WebSocket/WebSocketMessage.swift @@ -61,8 +61,8 @@ public class WebSocketMessage: Codable { self.command = nil } - public init(command: String, payload: [String: Any]? = nil) { - self.ID = -1 + public init(id: Int = -1, command: String, payload: [String: Any]? = nil) { + self.ID = id self.MessageType = "command" self.command = command self.Payload = payload diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 58388db1e..40488915c 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -16,6 +16,8 @@ public enum L10n { public static var alwaysOpenLabel: String { return L10n.tr("Localizable", "always_open_label") } /// Cancel public static var cancelLabel: String { return L10n.tr("Localizable", "cancel_label") } + /// Close + public static var closeLabel: String { return L10n.tr("Localizable", "close_label") } /// Continue public static var continueLabel: String { return L10n.tr("Localizable", "continue_label") } /// Copy