From b0d5d57339c9f511de733e770c0108fb2fbf1a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 8 Feb 2024 14:35:32 +0100 Subject: [PATCH 01/10] Add QR code scanner --- HomeAssistant.xcodeproj/project.pbxproj | 56 ++++ Sources/App/AppDelegate.swift | 9 + .../Camera/QRScannerCamera.swift | 292 ++++++++++++++++++ .../Camera/QRScannerCameraView.swift | 27 ++ .../Camera/QRScannerDataModel.swift | 37 +++ Sources/App/QRCodeScanner/QRScannerView.swift | 146 +++++++++ Sources/App/WebView/WebViewController.swift | 65 ++++ .../WebView/WebViewExternalBusMessage.swift | 1 + 8 files changed, 633 insertions(+) create mode 100644 Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift create mode 100644 Sources/App/QRCodeScanner/Camera/QRScannerCameraView.swift create mode 100644 Sources/App/QRCodeScanner/Camera/QRScannerDataModel.swift create mode 100644 Sources/App/QRCodeScanner/QRScannerView.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 900213c7c..d8fe2cde5 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 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B102B740E4C00E94A71 /* QRScannerView.swift */; }; + 42266B142B740F0700E94A71 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 42266B132B740F0700E94A71 /* CodeScanner */; }; + 42266B1D2B741FB400E94A71 /* QRScannerCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B172B741FB300E94A71 /* QRScannerCameraView.swift */; }; + 42266B1F2B741FB400E94A71 /* QRScannerDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B192B741FB300E94A71 /* QRScannerDataModel.swift */; }; + 42266B202B741FB400E94A71 /* QRScannerCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B1A2B741FB400E94A71 /* QRScannerCamera.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,10 @@ 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 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; + 42266B172B741FB300E94A71 /* QRScannerCameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScannerCameraView.swift; sourceTree = ""; }; + 42266B192B741FB300E94A71 /* QRScannerDataModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScannerDataModel.swift; sourceTree = ""; }; + 42266B1A2B741FB400E94A71 /* QRScannerCamera.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScannerCamera.swift; sourceTree = ""; }; 422894BF2B03B76200C1DAFB /* ThreadNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ThreadNetwork.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.2.sdk/System/Library/Frameworks/ThreadNetwork.framework; sourceTree = DEVELOPER_DIR; }; 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 = ""; }; @@ -2204,6 +2213,7 @@ 11D826F124E39F2E005B8A86 /* CoreNFC.framework in Frameworks */, D0C88460211ED11A00CCB501 /* SafariServices.framework in Frameworks */, B6393F881CB2561100503916 /* MapKit.framework in Frameworks */, + 42266B142B740F0700E94A71 /* CodeScanner in Frameworks */, B9820AF29664869FD0B25CDF /* Pods_iOS_App.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3054,6 +3064,25 @@ path = Entities; sourceTree = ""; }; + 42266B0F2B740E3600E94A71 /* QRCodeScanner */ = { + isa = PBXGroup; + children = ( + 42266B152B741F9F00E94A71 /* Camera */, + 42266B102B740E4C00E94A71 /* QRScannerView.swift */, + ); + path = QRCodeScanner; + sourceTree = ""; + }; + 42266B152B741F9F00E94A71 /* Camera */ = { + isa = PBXGroup; + children = ( + 42266B1A2B741FB400E94A71 /* QRScannerCamera.swift */, + 42266B172B741FB300E94A71 /* QRScannerCameraView.swift */, + 42266B192B741FB300E94A71 /* QRScannerDataModel.swift */, + ); + path = Camera; + sourceTree = ""; + }; 425573C52B55729E00145217 /* Servers */ = { isa = PBXGroup; children = ( @@ -3495,6 +3524,7 @@ B657A8E81CA646EB00121384 /* App */ = { isa = PBXGroup; children = ( + 42266B0F2B740E3600E94A71 /* QRCodeScanner */, B657A8E91CA646EB00121384 /* AppDelegate.swift */, D03D893720E0AF1B00D4F28D /* ClientEvents */, 11A183B22511BCF300CA326A /* LifecycleManager.swift */, @@ -4223,6 +4253,9 @@ 11B6B5812948F8E100B8B552 /* PBXTargetDependency */, ); name = App; + packageProductDependencies = ( + 42266B132B740F0700E94A71 /* CodeScanner */, + ); productName = HomeAssistant; productReference = B657A8E61CA646EB00121384 /* Home Assistant Δ.app */; productType = "com.apple.product-type.application"; @@ -4664,6 +4697,9 @@ bg, ); mainGroup = B657A8DD1CA646EB00121384; + packageReferences = ( + 42266B122B740F0700E94A71 /* XCRemoteSwiftPackageReference "CodeScanner" */, + ); productRefGroup = B657A8E71CA646EB00121384 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -5635,6 +5671,7 @@ B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, + 42266B202B741FB400E94A71 /* QRScannerCamera.swift in Sources */, 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, @@ -5674,6 +5711,7 @@ 42F1DA612B4D4F31002729BC /* CarPlayNoServerAlert.swift in Sources */, 11C590ED24A832CA0066085D /* YamlSection.swift in Sources */, 42F1DA5B2B4BF7DF002729BC /* WindowSizeObserver.swift in Sources */, + 42266B1D2B741FB400E94A71 /* QRScannerCameraView.swift in Sources */, 11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */, 1185DF94271FBA6100ED7D9A /* OnboardingAuthDetails.swift in Sources */, 420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */, @@ -5714,6 +5752,7 @@ 1185DFB3271FF53800ED7D9A /* OnboardingAuthStepSensors.swift in Sources */, 11108D632634C8FE009DAB0F /* LearnMoreButtonRow.swift in Sources */, 425573D12B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift in Sources */, + 42266B112B740E4C00E94A71 /* QRScannerView.swift in Sources */, 11B62DBE24F2EDD800E5CB55 /* EurekaCondition+Additions.swift in Sources */, B661FB74226C110A00E541DD /* OnboardingWelcomeViewController.swift in Sources */, 1164DA2125FBEE8600515E8A /* TemplateEditViewController.swift in Sources */, @@ -5737,6 +5776,7 @@ 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */, 11EFCDD324F5F39100314D85 /* WebViewWindowController.swift in Sources */, 11EFCDE024F60E5900314D85 /* BasicSceneDelegate.swift in Sources */, + 42266B1F2B741FB400E94A71 /* QRScannerDataModel.swift in Sources */, 425573C92B5572DB00145217 /* CarPlayServerListViewModel.swift in Sources */, 11A71C6F24A4644A00D9565F /* ZoneManagerIgnoreReason.swift in Sources */, 1101568324D770B2009424C9 /* iOSTagManager.swift in Sources */, @@ -7929,6 +7969,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 42266B122B740F0700E94A71 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twostraws/CodeScanner"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.3.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 116E2AAC27765D0100B330F2 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; @@ -7938,6 +7989,11 @@ isa = XCSwiftPackageProductDependency; productName = SharedPush; }; + 42266B132B740F0700E94A71 /* CodeScanner */ = { + isa = XCSwiftPackageProductDependency; + package = 42266B122B740F0700E94A71 /* XCRemoteSwiftPackageReference "CodeScanner" */; + productName = CodeScanner; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B657A8DE1CA646EB00121384 /* Project object */; diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 9a91c39d0..28d0d5f95 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -59,6 +59,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + static var orientationLock = UIInterfaceOrientationMask.all + func application( _ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -260,6 +262,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { IntentHandlerFactory.handler(for: intent) } + func application( + _ application: UIApplication, + supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { + AppDelegate.orientationLock + } + // MARK: - Private helpers @objc func checkForUpdate(_ sender: AnyObject? = nil) { diff --git a/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift b/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift new file mode 100644 index 000000000..ac94ac5f3 --- /dev/null +++ b/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift @@ -0,0 +1,292 @@ +import AVFoundation +import CoreImage +import Shared +import UIKit + +class QRScannerCamera: 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 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: ((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() + initialize() + } + + private func initialize() { + sessionQueue = DispatchQueue(label: "session queue") + captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video) + } + + 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) + if #available(iOS 15.4, *) { + metadataOutput.metadataObjectTypes = [.qr, .microQR] + } else { + metadataOutput.metadataObjectTypes = [.qr] + } + + 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 QRScannerCamera: 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 QRScannerCamera: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + if let metadataObject = metadataObjects.first { + guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } + guard let stringValue = readableObject.stringValue else { return } + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + qrFound?(stringValue) + } + } +} + +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 + } + } +} diff --git a/Sources/App/QRCodeScanner/Camera/QRScannerCameraView.swift b/Sources/App/QRCodeScanner/Camera/QRScannerCameraView.swift new file mode 100644 index 000000000..63b69ad6f --- /dev/null +++ b/Sources/App/QRCodeScanner/Camera/QRScannerCameraView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct QRScannerCameraView: View { + @StateObject private var model: QRScannerDataModel + private let shouldStartCamera: Bool + + init(model: QRScannerDataModel, 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/QRScannerDataModel.swift b/Sources/App/QRCodeScanner/Camera/QRScannerDataModel.swift new file mode 100644 index 000000000..0edbc161b --- /dev/null +++ b/Sources/App/QRCodeScanner/Camera/QRScannerDataModel.swift @@ -0,0 +1,37 @@ +import AVFoundation +import os.log +import SwiftUI + +final class QRScannerDataModel: ObservableObject { + let camera = QRScannerCamera() + + @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/QRCodeScanner/QRScannerView.swift b/Sources/App/QRCodeScanner/QRScannerView.swift new file mode 100644 index 000000000..636bdb546 --- /dev/null +++ b/Sources/App/QRCodeScanner/QRScannerView.swift @@ -0,0 +1,146 @@ +import CodeScanner +import Shared +import SwiftUI + +enum QRScannerResult { + case cancelled + case alternativeOption + case success(String) +} + +struct QRScannerView: View { + @Environment(\.dismiss) private var dismiss + // Use single data model so both camera previews use same camera stream + @State private var cameraDataModel = QRScannerDataModel() + 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? + private let completion: (QRScannerResult) -> Void + + init( + title: String, + description: String, + alternativeOptionLabel: String? = nil, + completion: @escaping (QRScannerResult) -> Void + ) { + self.title = title + self.description = description + self.alternativeOptionLabel = alternativeOptionLabel + self.completion = completion + } + + var body: some View { + ZStack(alignment: .top) { + ZStack { + cameraBackground + cameraSquare + } + .ignoresSafeArea() + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + + topInformation + } + .onAppear { + AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait + UINavigationController.attemptRotationToDeviceOrientation() + + cameraDataModel.camera.qrFound = { code in + self.completion(.success(code)) + self.dismiss() + } + } + .onDisappear { + DispatchQueue.main.async { + AppDelegate.orientationLock = UIInterfaceOrientationMask.all + UINavigationController.attemptRotationToDeviceOrientation() + } + } + } + + private var topInformation: some View { + VStack(spacing: 8) { + Button(action: { + completion(.cancelled) + dismiss() + }, label: { + Image(systemName: "xmark") + .font(.title2) + .foregroundColor(.white.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + }) + Group { + Text(title) + .padding(.top) + .font(.title2) + Text(description) + .font(.subheadline) + } + .foregroundColor(.white) + + if let alternativeOptionLabel { + Button { + completion(.alternativeOption) + dismiss() + } label: { + Text(alternativeOptionLabel) + .font(.subheadline) + .foregroundColor(.accentColor) + } + .padding(.top) + } + } + .padding() + } + + private var cameraBackground: some View { + QRScannerCameraView(model: cameraDataModel) + .ignoresSafeArea() + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .overlay { + Color.black.opacity(0.8) + } + } + + private var cameraSquare: some View { + QRScannerCameraView(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 { + QRScannerView(title: "Scan QR-code", description: "Find the code on your device", completion: { _ in }) +} diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index f3b354356..87012096d 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -1001,6 +1001,7 @@ extension WebViewController: WKScriptMessageHandler { "canWriteTag": Current.tags.isNFCAvailable, "canCommissionMatter": Current.matter.isAvailable, "canImportThreadCredentials": Current.matter.threadCredentialsSharingEnabled, + "hasQRScanner": true, ] )) } @@ -1061,6 +1062,19 @@ extension WebViewController: WKScriptMessageHandler { } case .threadImportCredentials: threadCredentialsRequested() + case .qrCodeScanner: + response = Guarantee { seal in + 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, + seal: seal + ) + } } } else { Current.Log.error("unknown: \(incomingMessage.MessageType)") @@ -1106,6 +1120,57 @@ extension WebViewController: WKScriptMessageHandler { present(threadManagementView, animated: true) } } + + private func qrCodeScannerRequested( + title: String, + description: String, + alternativeOptionLabel: String?, + incomingMessageId: Int, + seal: @escaping (WebSocketMessage) -> Void + ) { + let qrCodeScannerView = UIHostingController(rootView: QRScannerView( + title: title, + description: description, + alternativeOptionLabel: alternativeOptionLabel, + completion: { result in + switch result { + case .cancelled: + seal( + WebSocketMessage( + id: incomingMessageId, + type: "result", + result: [ + "action": "canceled", + ] + ) + ) + case .alternativeOption: + seal( + WebSocketMessage( + id: incomingMessageId, + type: "result", + result: [ + "action": "alternative_options", + ] + ) + ) + case let .success(resultString): + seal( + WebSocketMessage( + id: incomingMessageId, + type: "result", + result: [ + "action": "scan_result", + "result": resultString, + ] + ) + ) + } + } + )) + qrCodeScannerView.modalPresentationStyle = .fullScreen + present(qrCodeScannerView, animated: true) + } } extension WebViewController: UIScrollViewDelegate { diff --git a/Sources/App/WebView/WebViewExternalBusMessage.swift b/Sources/App/WebView/WebViewExternalBusMessage.swift index 5d28a6cc7..bd4be9757 100644 --- a/Sources/App/WebView/WebViewExternalBusMessage.swift +++ b/Sources/App/WebView/WebViewExternalBusMessage.swift @@ -10,4 +10,5 @@ enum WebViewExternalBusMessage: String, CaseIterable { case themeUpdate = "theme-update" case matterCommission = "matter/commission" case threadImportCredentials = "thread/import_credentials" + case qrCodeScanner = "qr_code/scan" } From 7699fffff433e5ec768b7bf9b1a8e04705bb4be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 8 Feb 2024 14:37:48 +0100 Subject: [PATCH 02/10] Lint --- Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift b/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift index ac94ac5f3..a701bf007 100644 --- a/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift +++ b/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift @@ -17,7 +17,7 @@ class QRScannerCamera: NSObject { .builtInDualCamera, .builtInDualWideCamera, .builtInWideAngleCamera, - .builtInDualWideCamera + .builtInDualWideCamera, ], mediaType: .video, position: .back @@ -264,7 +264,11 @@ extension QRScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate { } extension QRScannerCamera: AVCaptureMetadataOutputObjectsDelegate { - func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { if let metadataObject = metadataObjects.first { guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } guard let stringValue = readableObject.stringValue else { return } From 4a3fab97dc0e901669e0a3ff591f75f59dd33c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 8 Feb 2024 16:39:57 +0100 Subject: [PATCH 03/10] Update test --- Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift index 5bc86651f..952d0e6a3 100644 --- a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift +++ b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift @@ -11,7 +11,8 @@ 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.qrCodeScanner.rawValue, "qr_code/scan") - XCTAssertEqual(WebViewExternalBusMessage.allCases.count, 9) + XCTAssertEqual(WebViewExternalBusMessage.allCases.count, 10) } } From dd07b65a73bdc9fbb36b848a6b3d4f50560d935e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 8 Feb 2024 17:41:16 +0100 Subject: [PATCH 04/10] Update to accept more barcode types and return format to frontend --- HomeAssistant.xcodeproj/project.pbxproj | 33 ++++++----- ...nerView.swift => BarcodeScannerView.swift} | 16 +++--- ...amera.swift => BarcodeScannerCamera.swift} | 55 ++++++++++++++++--- ...w.swift => BarcodeScannerCameraView.swift} | 6 +- ...el.swift => BarcodeScannerDataModel.swift} | 4 +- .../WebViewExternalBusMessageTests.swift | 2 +- Sources/App/WebView/WebViewController.swift | 7 ++- .../WebView/WebViewExternalBusMessage.swift | 2 +- 8 files changed, 82 insertions(+), 43 deletions(-) rename Sources/App/QRCodeScanner/{QRScannerView.swift => BarcodeScannerView.swift} (89%) rename Sources/App/QRCodeScanner/Camera/{QRScannerCamera.swift => BarcodeScannerCamera.swift} (86%) rename Sources/App/QRCodeScanner/Camera/{QRScannerCameraView.swift => BarcodeScannerCameraView.swift} (77%) rename Sources/App/QRCodeScanner/Camera/{QRScannerDataModel.swift => BarcodeScannerDataModel.swift} (88%) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index d77a29f5d..7dc4187fc 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -504,11 +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 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B102B740E4C00E94A71 /* QRScannerView.swift */; }; + 42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */; }; 42266B142B740F0700E94A71 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 42266B132B740F0700E94A71 /* CodeScanner */; }; - 42266B1D2B741FB400E94A71 /* QRScannerCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B172B741FB300E94A71 /* QRScannerCameraView.swift */; }; - 42266B1F2B741FB400E94A71 /* QRScannerDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B192B741FB300E94A71 /* QRScannerDataModel.swift */; }; - 42266B202B741FB400E94A71 /* QRScannerCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B1A2B741FB400E94A71 /* QRScannerCamera.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 */; }; 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 */; }; @@ -1574,11 +1574,10 @@ 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 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; - 42266B172B741FB300E94A71 /* QRScannerCameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScannerCameraView.swift; sourceTree = ""; }; - 42266B192B741FB300E94A71 /* QRScannerDataModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScannerDataModel.swift; sourceTree = ""; }; - 42266B1A2B741FB400E94A71 /* QRScannerCamera.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScannerCamera.swift; sourceTree = ""; }; - 422894BF2B03B76200C1DAFB /* ThreadNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ThreadNetwork.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.2.sdk/System/Library/Frameworks/ThreadNetwork.framework; sourceTree = DEVELOPER_DIR; }; + 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 = ""; }; 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 = ""; }; @@ -3067,7 +3066,7 @@ isa = PBXGroup; children = ( 42266B152B741F9F00E94A71 /* Camera */, - 42266B102B740E4C00E94A71 /* QRScannerView.swift */, + 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */, ); path = QRCodeScanner; sourceTree = ""; @@ -3075,9 +3074,9 @@ 42266B152B741F9F00E94A71 /* Camera */ = { isa = PBXGroup; children = ( - 42266B1A2B741FB400E94A71 /* QRScannerCamera.swift */, - 42266B172B741FB300E94A71 /* QRScannerCameraView.swift */, - 42266B192B741FB300E94A71 /* QRScannerDataModel.swift */, + 42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */, + 42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */, + 42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */, ); path = Camera; sourceTree = ""; @@ -5670,7 +5669,7 @@ B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, - 42266B202B741FB400E94A71 /* QRScannerCamera.swift in Sources */, + 42266B202B741FB400E94A71 /* BarcodeScannerCamera.swift in Sources */, 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, @@ -5710,7 +5709,7 @@ 42F1DA612B4D4F31002729BC /* CarPlayNoServerAlert.swift in Sources */, 11C590ED24A832CA0066085D /* YamlSection.swift in Sources */, 42F1DA5B2B4BF7DF002729BC /* WindowSizeObserver.swift in Sources */, - 42266B1D2B741FB400E94A71 /* QRScannerCameraView.swift in Sources */, + 42266B1D2B741FB400E94A71 /* BarcodeScannerCameraView.swift in Sources */, 11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */, 1185DF94271FBA6100ED7D9A /* OnboardingAuthDetails.swift in Sources */, 420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */, @@ -5751,7 +5750,7 @@ 1185DFB3271FF53800ED7D9A /* OnboardingAuthStepSensors.swift in Sources */, 11108D632634C8FE009DAB0F /* LearnMoreButtonRow.swift in Sources */, 425573D12B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift in Sources */, - 42266B112B740E4C00E94A71 /* QRScannerView.swift in Sources */, + 42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */, 11B62DBE24F2EDD800E5CB55 /* EurekaCondition+Additions.swift in Sources */, B661FB74226C110A00E541DD /* OnboardingWelcomeViewController.swift in Sources */, 1164DA2125FBEE8600515E8A /* TemplateEditViewController.swift in Sources */, @@ -5775,7 +5774,7 @@ 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */, 11EFCDD324F5F39100314D85 /* WebViewWindowController.swift in Sources */, 11EFCDE024F60E5900314D85 /* BasicSceneDelegate.swift in Sources */, - 42266B1F2B741FB400E94A71 /* QRScannerDataModel.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/QRScannerView.swift b/Sources/App/QRCodeScanner/BarcodeScannerView.swift similarity index 89% rename from Sources/App/QRCodeScanner/QRScannerView.swift rename to Sources/App/QRCodeScanner/BarcodeScannerView.swift index 636bdb546..fd068be83 100644 --- a/Sources/App/QRCodeScanner/QRScannerView.swift +++ b/Sources/App/QRCodeScanner/BarcodeScannerView.swift @@ -5,13 +5,13 @@ import SwiftUI enum QRScannerResult { case cancelled case alternativeOption - case success(String) + case success(_ code: String, _ format: String) } -struct QRScannerView: View { +struct BarcodeScannerView: View { @Environment(\.dismiss) private var dismiss // Use single data model so both camera previews use same camera stream - @State private var cameraDataModel = QRScannerDataModel() + @State private var cameraDataModel = BarcodeScannerDataModel() private let cameraSquareSize: CGFloat = 320 private let flashlightIcon = MaterialDesignIcons.flashlightIcon.image( ofSize: .init(width: 24, height: 24), @@ -51,8 +51,8 @@ struct QRScannerView: View { AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait UINavigationController.attemptRotationToDeviceOrientation() - cameraDataModel.camera.qrFound = { code in - self.completion(.success(code)) + cameraDataModel.camera.qrFound = { code, format in + self.completion(.success(code, format)) self.dismiss() } } @@ -100,7 +100,7 @@ struct QRScannerView: View { } private var cameraBackground: some View { - QRScannerCameraView(model: cameraDataModel) + BarcodeScannerCameraView(model: cameraDataModel) .ignoresSafeArea() .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) @@ -110,7 +110,7 @@ struct QRScannerView: View { } private var cameraSquare: some View { - QRScannerCameraView(model: cameraDataModel, shouldStartCamera: false) + BarcodeScannerCameraView(model: cameraDataModel, shouldStartCamera: false) .ignoresSafeArea() .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) @@ -142,5 +142,5 @@ struct QRScannerView: View { } #Preview { - QRScannerView(title: "Scan QR-code", description: "Find the code on your device", completion: { _ in }) + BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", completion: { _ in }) } diff --git a/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift similarity index 86% rename from Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift rename to Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift index a701bf007..cb03996f6 100644 --- a/Sources/App/QRCodeScanner/Camera/QRScannerCamera.swift +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift @@ -3,7 +3,7 @@ import CoreImage import Shared import UIKit -class QRScannerCamera: NSObject { +class BarcodeScannerCamera: NSObject { private let captureSession = AVCaptureSession() private var isCaptureSessionConfigured = false private var deviceInput: AVCaptureDeviceInput? @@ -40,7 +40,7 @@ class QRScannerCamera: NSObject { } } - var qrFound: ((String) -> Void)? + var qrFound: ((_ code: String, _ format: String) -> Void)? var isRunning: Bool { captureSession.isRunning } @@ -108,12 +108,27 @@ class QRScannerCamera: NSObject { 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, *) { - metadataOutput.metadataObjectTypes = [.qr, .microQR] - } else { - metadataOutput.metadataObjectTypes = [.qr] + metadataObjectTypes.append(.codabar) } + metadataOutput.metadataObjectTypes = metadataObjectTypes + self.deviceInput = deviceInput self.videoOutput = videoOutput @@ -246,7 +261,7 @@ class QRScannerCamera: NSObject { } } -extension QRScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate { +extension BarcodeScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate { func captureOutput( _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, @@ -263,17 +278,18 @@ extension QRScannerCamera: AVCaptureVideoDataOutputSampleBufferDelegate { } } -extension QRScannerCamera: AVCaptureMetadataOutputObjectsDelegate { +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 } AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) - qrFound?(stringValue) + qrFound?(stringValue, format) } } } @@ -294,3 +310,26 @@ private extension UIScreen { } } } + +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/QRScannerCameraView.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift similarity index 77% rename from Sources/App/QRCodeScanner/Camera/QRScannerCameraView.swift rename to Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift index 63b69ad6f..9e1308d83 100644 --- a/Sources/App/QRCodeScanner/Camera/QRScannerCameraView.swift +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCameraView.swift @@ -1,10 +1,10 @@ import SwiftUI -struct QRScannerCameraView: View { - @StateObject private var model: QRScannerDataModel +struct BarcodeScannerCameraView: View { + @StateObject private var model: BarcodeScannerDataModel private let shouldStartCamera: Bool - init(model: QRScannerDataModel, shouldStartCamera: Bool = true) { + init(model: BarcodeScannerDataModel, shouldStartCamera: Bool = true) { self._model = .init(wrappedValue: model) self.shouldStartCamera = shouldStartCamera } diff --git a/Sources/App/QRCodeScanner/Camera/QRScannerDataModel.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift similarity index 88% rename from Sources/App/QRCodeScanner/Camera/QRScannerDataModel.swift rename to Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift index 0edbc161b..771a542d2 100644 --- a/Sources/App/QRCodeScanner/Camera/QRScannerDataModel.swift +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerDataModel.swift @@ -2,8 +2,8 @@ import AVFoundation import os.log import SwiftUI -final class QRScannerDataModel: ObservableObject { - let camera = QRScannerCamera() +final class BarcodeScannerDataModel: ObservableObject { + let camera = BarcodeScannerCamera() @Published var viewfinderImage: Image? init() { diff --git a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift index 952d0e6a3..d630df67c 100644 --- a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift +++ b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift @@ -11,7 +11,7 @@ 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.qrCodeScanner.rawValue, "qr_code/scan") + XCTAssertEqual(WebViewExternalBusMessage.qrCodeScanner.rawValue, "barcode/scan") XCTAssertEqual(WebViewExternalBusMessage.allCases.count, 10) } diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 87012096d..392245ba2 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -1128,7 +1128,7 @@ extension WebViewController: WKScriptMessageHandler { incomingMessageId: Int, seal: @escaping (WebSocketMessage) -> Void ) { - let qrCodeScannerView = UIHostingController(rootView: QRScannerView( + let qrCodeScannerView = UIHostingController(rootView: BarcodeScannerView( title: title, description: description, alternativeOptionLabel: alternativeOptionLabel, @@ -1154,14 +1154,15 @@ extension WebViewController: WKScriptMessageHandler { ] ) ) - case let .success(resultString): + case let .success(code, format): seal( WebSocketMessage( id: incomingMessageId, type: "result", result: [ "action": "scan_result", - "result": resultString, + "result": code, + "format": format, ] ) ) diff --git a/Sources/App/WebView/WebViewExternalBusMessage.swift b/Sources/App/WebView/WebViewExternalBusMessage.swift index bd4be9757..86ff56d33 100644 --- a/Sources/App/WebView/WebViewExternalBusMessage.swift +++ b/Sources/App/WebView/WebViewExternalBusMessage.swift @@ -10,5 +10,5 @@ enum WebViewExternalBusMessage: String, CaseIterable { case themeUpdate = "theme-update" case matterCommission = "matter/commission" case threadImportCredentials = "thread/import_credentials" - case qrCodeScanner = "qr_code/scan" + case qrCodeScanner = "barcode/scan" } From f10276ce0587d15e67aa8519f9f1633e36991a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Mon, 12 Feb 2024 10:35:54 +0100 Subject: [PATCH 05/10] Remove uneeded app delegate orientation lock --- Sources/App/AppDelegate.swift | 9 --------- .../App/QRCodeScanner/BarcodeScannerView.swift | 15 ++++++--------- Sources/App/WebView/WebViewController.swift | 2 +- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 28d0d5f95..9a91c39d0 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -59,8 +59,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - static var orientationLock = UIInterfaceOrientationMask.all - func application( _ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -262,13 +260,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { IntentHandlerFactory.handler(for: intent) } - func application( - _ application: UIApplication, - supportedInterfaceOrientationsFor window: UIWindow? - ) -> UIInterfaceOrientationMask { - AppDelegate.orientationLock - } - // MARK: - Private helpers @objc func checkForUpdate(_ sender: AnyObject? = nil) { diff --git a/Sources/App/QRCodeScanner/BarcodeScannerView.swift b/Sources/App/QRCodeScanner/BarcodeScannerView.swift index fd068be83..62a000bda 100644 --- a/Sources/App/QRCodeScanner/BarcodeScannerView.swift +++ b/Sources/App/QRCodeScanner/BarcodeScannerView.swift @@ -48,20 +48,11 @@ struct BarcodeScannerView: View { topInformation } .onAppear { - AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait - UINavigationController.attemptRotationToDeviceOrientation() - cameraDataModel.camera.qrFound = { code, format in self.completion(.success(code, format)) self.dismiss() } } - .onDisappear { - DispatchQueue.main.async { - AppDelegate.orientationLock = UIInterfaceOrientationMask.all - UINavigationController.attemptRotationToDeviceOrientation() - } - } } private var topInformation: some View { @@ -144,3 +135,9 @@ struct BarcodeScannerView: View { #Preview { BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", completion: { _ in }) } + +class BarcodeScannerHostingController: UIHostingController { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + [.portrait] + } +} diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 392245ba2..b59b6bc76 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -1128,7 +1128,7 @@ extension WebViewController: WKScriptMessageHandler { incomingMessageId: Int, seal: @escaping (WebSocketMessage) -> Void ) { - let qrCodeScannerView = UIHostingController(rootView: BarcodeScannerView( + let qrCodeScannerView = BarcodeScannerHostingController(rootView: BarcodeScannerView( title: title, description: description, alternativeOptionLabel: alternativeOptionLabel, From bd0f584ff1b2d8a2cb21c74af221ccb6c5226db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Mon, 12 Feb 2024 10:43:11 +0100 Subject: [PATCH 06/10] Use UINotificationFeedbackGenerator success haptics --- .../QRCodeScanner/Camera/BarcodeScannerCamera.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift index cb03996f6..b5e2b62f9 100644 --- a/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift +++ b/Sources/App/QRCodeScanner/Camera/BarcodeScannerCamera.swift @@ -10,6 +10,7 @@ class BarcodeScannerCamera: NSObject { private var videoOutput: AVCaptureVideoDataOutput? private let metadataOutput = AVCaptureMetadataOutput() private var sessionQueue: DispatchQueue! + private let feedbackGenerator = UINotificationFeedbackGenerator() private var allBackCaptureDevices: [AVCaptureDevice] { AVCaptureDevice.DiscoverySession( deviceTypes: [ @@ -61,12 +62,10 @@ class BarcodeScannerCamera: NSObject { override init() { super.init() - initialize() - } + self.sessionQueue = DispatchQueue(label: "session queue") + self.captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video) - private func initialize() { - sessionQueue = DispatchQueue(label: "session queue") - captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video) + feedbackGenerator.prepare() } private func configureCaptureSession(completionHandler: (_ success: Bool) -> Void) { @@ -288,7 +287,7 @@ extension BarcodeScannerCamera: AVCaptureMetadataOutputObjectsDelegate { let format = metadataObject.type.haString guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } guard let stringValue = readableObject.stringValue else { return } - AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + feedbackGenerator.notificationOccurred(.success) qrFound?(stringValue, format) } } From c9516a648707f6251a5b83730127432f87347f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Mon, 12 Feb 2024 10:52:47 +0100 Subject: [PATCH 07/10] Add accessibility to close button --- Sources/App/QRCodeScanner/BarcodeScannerView.swift | 1 + Sources/App/Resources/en.lproj/Localizable.strings | 3 ++- Sources/Shared/Resources/Swiftgen/Strings.swift | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/App/QRCodeScanner/BarcodeScannerView.swift b/Sources/App/QRCodeScanner/BarcodeScannerView.swift index 62a000bda..f17fe23f7 100644 --- a/Sources/App/QRCodeScanner/BarcodeScannerView.swift +++ b/Sources/App/QRCodeScanner/BarcodeScannerView.swift @@ -66,6 +66,7 @@ struct BarcodeScannerView: View { .foregroundColor(.white.opacity(0.8)) .frame(maxWidth: .infinity, alignment: .leading) }) + .accessibilityHint(.init(L10n.closeLabel)) Group { Text(title) .padding(.top) 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/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 From cc40070fdd4db4bec57216ac1a3da1c2ed08946b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Mon, 12 Feb 2024 10:56:42 +0100 Subject: [PATCH 08/10] Remove SPM --- HomeAssistant.xcodeproj/project.pbxproj | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 7dc4187fc..a5e79a75e 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -505,7 +505,6 @@ 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 */; }; - 42266B142B740F0700E94A71 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 42266B132B740F0700E94A71 /* CodeScanner */; }; 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 */; }; @@ -2212,7 +2211,6 @@ 11D826F124E39F2E005B8A86 /* CoreNFC.framework in Frameworks */, D0C88460211ED11A00CCB501 /* SafariServices.framework in Frameworks */, B6393F881CB2561100503916 /* MapKit.framework in Frameworks */, - 42266B142B740F0700E94A71 /* CodeScanner in Frameworks */, B9820AF29664869FD0B25CDF /* Pods_iOS_App.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4252,7 +4250,6 @@ ); name = App; packageProductDependencies = ( - 42266B132B740F0700E94A71 /* CodeScanner */, ); productName = HomeAssistant; productReference = B657A8E61CA646EB00121384 /* Home Assistant Δ.app */; @@ -4696,7 +4693,6 @@ ); mainGroup = B657A8DD1CA646EB00121384; packageReferences = ( - 42266B122B740F0700E94A71 /* XCRemoteSwiftPackageReference "CodeScanner" */, ); productRefGroup = B657A8E71CA646EB00121384 /* Products */; projectDirPath = ""; @@ -7967,17 +7963,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 42266B122B740F0700E94A71 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/twostraws/CodeScanner"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.3.3; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ 116E2AAC27765D0100B330F2 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; @@ -7987,11 +7972,6 @@ isa = XCSwiftPackageProductDependency; productName = SharedPush; }; - 42266B132B740F0700E94A71 /* CodeScanner */ = { - isa = XCSwiftPackageProductDependency; - package = 42266B122B740F0700E94A71 /* XCRemoteSwiftPackageReference "CodeScanner" */; - productName = CodeScanner; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B657A8DE1CA646EB00121384 /* Project object */; From 8a7169b81d7d84ba13c2e87ad9dcf7252b425acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Mon, 12 Feb 2024 14:25:16 +0100 Subject: [PATCH 09/10] Update implementation to align with frontend --- HomeAssistant.xcodeproj/project.pbxproj | 4 + .../QRCodeScanner/BarcodeScannerView.swift | 24 ++-- .../BarcodeScannerViewModel.swift | 45 ++++++++ .../WebViewExternalBusMessageTests.swift | 14 ++- Sources/App/WebView/WebViewController.swift | 104 +++++++----------- .../WebView/WebViewExternalBusMessage.swift | 10 +- .../API/WebSocket/WebSocketMessage.swift | 4 +- 7 files changed, 119 insertions(+), 86 deletions(-) create mode 100644 Sources/App/QRCodeScanner/BarcodeScannerViewModel.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index a5e79a75e..92f9e01e2 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -508,6 +508,7 @@ 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 */; }; @@ -1577,6 +1578,7 @@ 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 = ""; }; @@ -3065,6 +3067,7 @@ children = ( 42266B152B741F9F00E94A71 /* Camera */, 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */, + 42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */, ); path = QRCodeScanner; sourceTree = ""; @@ -5642,6 +5645,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 */, diff --git a/Sources/App/QRCodeScanner/BarcodeScannerView.swift b/Sources/App/QRCodeScanner/BarcodeScannerView.swift index f17fe23f7..ce7770a00 100644 --- a/Sources/App/QRCodeScanner/BarcodeScannerView.swift +++ b/Sources/App/QRCodeScanner/BarcodeScannerView.swift @@ -2,14 +2,9 @@ import CodeScanner import Shared import SwiftUI -enum QRScannerResult { - case cancelled - case alternativeOption - case success(_ code: String, _ format: String) -} - 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 @@ -21,18 +16,17 @@ struct BarcodeScannerView: View { private let title: String private let description: String private let alternativeOptionLabel: String? - private let completion: (QRScannerResult) -> Void init( title: String, description: String, alternativeOptionLabel: String? = nil, - completion: @escaping (QRScannerResult) -> Void + incomingMessageId: Int ) { self.title = title self.description = description self.alternativeOptionLabel = alternativeOptionLabel - self.completion = completion + self._viewModel = .init(wrappedValue: .init(incomingMessageId: incomingMessageId)) } var body: some View { @@ -49,8 +43,7 @@ struct BarcodeScannerView: View { } .onAppear { cameraDataModel.camera.qrFound = { code, format in - self.completion(.success(code, format)) - self.dismiss() + viewModel.scannedCode(code, format: format) } } } @@ -58,7 +51,7 @@ struct BarcodeScannerView: View { private var topInformation: some View { VStack(spacing: 8) { Button(action: { - completion(.cancelled) + viewModel.aborted(.canceled) dismiss() }, label: { Image(systemName: "xmark") @@ -78,8 +71,7 @@ struct BarcodeScannerView: View { if let alternativeOptionLabel { Button { - completion(.alternativeOption) - dismiss() + viewModel.aborted(.alternativeOptions) } label: { Text(alternativeOptionLabel) .font(.subheadline) @@ -134,10 +126,10 @@ struct BarcodeScannerView: View { } #Preview { - BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", completion: { _ in }) + BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", incomingMessageId: 1) } -class BarcodeScannerHostingController: UIHostingController { +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/WebView/Tests/WebViewExternalBusMessageTests.swift b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift index d630df67c..5204aa381 100644 --- a/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift +++ b/Sources/App/WebView/Tests/WebViewExternalBusMessageTests.swift @@ -11,8 +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.qrCodeScanner.rawValue, "barcode/scan") + 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, 10) + 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 b59b6bc76..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() { @@ -1062,19 +1066,24 @@ extension WebViewController: WKScriptMessageHandler { } case .threadImportCredentials: threadCredentialsRequested() - case .qrCodeScanner: - response = Guarantee { seal in - 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, - seal: seal - ) - } + 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)") @@ -1086,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 { @@ -1125,52 +1134,17 @@ extension WebViewController: WKScriptMessageHandler { title: String, description: String, alternativeOptionLabel: String?, - incomingMessageId: Int, - seal: @escaping (WebSocketMessage) -> Void + incomingMessageId: Int ) { - let qrCodeScannerView = BarcodeScannerHostingController(rootView: BarcodeScannerView( + barCodeScannerController = BarcodeScannerHostingController(rootView: BarcodeScannerView( title: title, description: description, alternativeOptionLabel: alternativeOptionLabel, - completion: { result in - switch result { - case .cancelled: - seal( - WebSocketMessage( - id: incomingMessageId, - type: "result", - result: [ - "action": "canceled", - ] - ) - ) - case .alternativeOption: - seal( - WebSocketMessage( - id: incomingMessageId, - type: "result", - result: [ - "action": "alternative_options", - ] - ) - ) - case let .success(code, format): - seal( - WebSocketMessage( - id: incomingMessageId, - type: "result", - result: [ - "action": "scan_result", - "result": code, - "format": format, - ] - ) - ) - } - } + incomingMessageId: incomingMessageId )) - qrCodeScannerView.modalPresentationStyle = .fullScreen - present(qrCodeScannerView, animated: true) + barCodeScannerController?.modalPresentationStyle = .fullScreen + guard let barCodeScannerController else { return } + present(barCodeScannerController, animated: true) } } diff --git a/Sources/App/WebView/WebViewExternalBusMessage.swift b/Sources/App/WebView/WebViewExternalBusMessage.swift index 86ff56d33..fb64987cc 100644 --- a/Sources/App/WebView/WebViewExternalBusMessage.swift +++ b/Sources/App/WebView/WebViewExternalBusMessage.swift @@ -10,5 +10,13 @@ enum WebViewExternalBusMessage: String, CaseIterable { case themeUpdate = "theme-update" case matterCommission = "matter/commission" case threadImportCredentials = "thread/import_credentials" - case qrCodeScanner = "barcode/scan" + 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 From 2279a488f3e6eebf3212d020d641765a466d8696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Mon, 12 Feb 2024 18:10:51 +0100 Subject: [PATCH 10/10] Remove lib reference --- HomeAssistant.xcodeproj/project.pbxproj | 4 ---- Sources/App/QRCodeScanner/BarcodeScannerView.swift | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 92f9e01e2..9e7cb68b1 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -4252,8 +4252,6 @@ 11B6B5812948F8E100B8B552 /* PBXTargetDependency */, ); name = App; - packageProductDependencies = ( - ); productName = HomeAssistant; productReference = B657A8E61CA646EB00121384 /* Home Assistant Δ.app */; productType = "com.apple.product-type.application"; @@ -4695,8 +4693,6 @@ bg, ); mainGroup = B657A8DD1CA646EB00121384; - packageReferences = ( - ); productRefGroup = B657A8E71CA646EB00121384 /* Products */; projectDirPath = ""; projectRoot = ""; diff --git a/Sources/App/QRCodeScanner/BarcodeScannerView.swift b/Sources/App/QRCodeScanner/BarcodeScannerView.swift index ce7770a00..023dad9b1 100644 --- a/Sources/App/QRCodeScanner/BarcodeScannerView.swift +++ b/Sources/App/QRCodeScanner/BarcodeScannerView.swift @@ -1,4 +1,3 @@ -import CodeScanner import Shared import SwiftUI @@ -72,6 +71,7 @@ struct BarcodeScannerView: View { if let alternativeOptionLabel { Button { viewModel.aborted(.alternativeOptions) + dismiss() } label: { Text(alternativeOptionLabel) .font(.subheadline)