From 75ff46695f7dd0614db01e8a1fb93fc1f019d687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:36:16 +0200 Subject: [PATCH 1/3] Scan barcodes/qrcodes only inside the highlighted area --- .../Camera/BarcodeScannerCamera.swift | 26 +++++++ .../Camera/BarcodeScannerCameraView.swift | 9 ++- .../Camera/BarcodeScannerView.swift | 73 ++++++++++++------- .../WebViewExternalMessageHandler.swift | 27 ++++++- 4 files changed, 102 insertions(+), 33 deletions(-) diff --git a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift index f2cb4d69a2..1009f1630c 100644 --- a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift +++ b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift @@ -27,6 +27,32 @@ final class BarcodeScannerCamera: NSObject { ).devices } + public var screenSize: CGSize? { + didSet { + // Prevent unecessary updates + guard let screenSize, + metadataOutput.rectOfInterest.width != BarcodeScannerView.cameraSquareSize else { return } + // Calculate normalized rectOfInterest for AVFoundation + let cameraSquareSize = BarcodeScannerView.cameraSquareSize + let x = (screenSize.width - cameraSquareSize) / 2.0 + let y = (screenSize.height - cameraSquareSize) / 2.0 + // AVFoundation expects (x, y, width, height) in normalized coordinates (0-1), + // and (0,0) is top-left of the video in portrait orientation + let normalizedX = y / screenSize.height + let normalizedY = x / screenSize.width + let normalizedWidth = cameraSquareSize / screenSize.height + let normalizedHeight = cameraSquareSize / screenSize.width + captureSession.beginConfiguration() + metadataOutput.rectOfInterest = CGRect( + x: normalizedX, + y: normalizedY, + width: normalizedWidth, + height: normalizedHeight + ) + captureSession.commitConfiguration() + } + } + private var availableCaptureDevices: [AVCaptureDevice] { allBackCaptureDevices .filter(\.isConnected) diff --git a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCameraView.swift b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCameraView.swift index 5410739b08..16188116ea 100644 --- a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCameraView.swift +++ b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCameraView.swift @@ -3,10 +3,17 @@ import SwiftUI struct BarcodeScannerCameraView: View { @StateObject private var model: BarcodeScannerDataModel private let shouldStartCamera: Bool + private let screenSize: CGSize - init(model: BarcodeScannerDataModel, shouldStartCamera: Bool = true) { + init(screenSize: CGSize, model: BarcodeScannerDataModel, shouldStartCamera: Bool = true) { self._model = .init(wrappedValue: model) self.shouldStartCamera = shouldStartCamera + self.screenSize = screenSize + + // If camera shouldn't be started, no need to forward screen size for rect of interest + if shouldStartCamera { + model.camera.screenSize = screenSize + } } var body: some View { diff --git a/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift b/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift index 4a6e7b4a18..73460b1f5f 100644 --- a/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift +++ b/Sources/App/BarcodeScanner/Camera/BarcodeScannerView.swift @@ -2,11 +2,12 @@ import Shared import SwiftUI struct BarcodeScannerView: View { + static let cameraSquareSize: CGFloat = 320 + @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 + @StateObject private var cameraDataModel = BarcodeScannerDataModel() private let flashlightIcon = MaterialDesignIcons.flashlightIcon.image( ofSize: .init(width: 24, height: 24), color: .white @@ -50,22 +51,20 @@ struct BarcodeScannerView: View { 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)) + HStack { + Spacer() + CloseButton(tint: .white, size: .medium) { + viewModel.aborted(.canceled) + dismiss() + } + .accessibilityHint(.init(L10n.closeLabel)) + } Group { Text(title) .padding(.top) - .font(.title2) + .font(DesignSystem.Font.title2.bold()) Text(description) - .font(.subheadline) + .font(DesignSystem.Font.subheadline) } .foregroundColor(.white) @@ -85,29 +84,42 @@ struct BarcodeScannerView: View { } private var cameraBackground: some View { - BarcodeScannerCameraView(model: cameraDataModel) - .ignoresSafeArea() - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - .overlay { - Color.black.opacity(0.8) - } + GeometryReader { proxy in + BarcodeScannerCameraView(screenSize: proxy.size, 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) + // No size needs to be provided here since it wont forward to rectOfInterest + BarcodeScannerCameraView(screenSize: .zero, model: cameraDataModel, shouldStartCamera: false) .ignoresSafeArea() .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .mask { RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) - .frame(width: cameraSquareSize, height: cameraSquareSize) + .frame( + width: BarcodeScannerView.cameraSquareSize, + height: BarcodeScannerView.cameraSquareSize + ) + } + .overlay { + RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + .stroke(Color.clear, lineWidth: 1) + .frame( + width: BarcodeScannerView.cameraSquareSize, + height: BarcodeScannerView.cameraSquareSize + ) } + .shadow(color: .haPrimary.opacity(0.8), radius: 10, x: 0, y: 0) .overlay { - ZStack(alignment: .bottomTrailing) { - RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) - .stroke(Color.blue, lineWidth: 1) - .frame(width: cameraSquareSize, height: cameraSquareSize) + VStack { + Spacer() Button(action: { cameraDataModel.toggleFlashlight() }, label: { @@ -115,9 +127,14 @@ struct BarcodeScannerView: View { .padding() .background(Color(uiColor: .init(hex: "#384956"))) .mask(Circle()) - .offset(x: -22, y: -22) + .padding([.trailing, .bottom], DesignSystem.Spaces.two) }) + .frame(maxWidth: .infinity, alignment: .trailing) } + .frame( + width: BarcodeScannerView.cameraSquareSize, + height: BarcodeScannerView.cameraSquareSize + ) } } } diff --git a/Sources/App/WebView/WebViewExternalMessageHandler.swift b/Sources/App/WebView/WebViewExternalMessageHandler.swift index cfc40cd748..a1574ff51f 100644 --- a/Sources/App/WebView/WebViewExternalMessageHandler.swift +++ b/Sources/App/WebView/WebViewExternalMessageHandler.swift @@ -18,7 +18,7 @@ final class WebViewExternalMessageHandler { self.improvManager = improvManager } - func handleExternalMessage(_ dictionary: [String: Any]) { + @MainActor func handleExternalMessage(_ dictionary: [String: Any]) { guard let webViewController else { Current.Log.error("WebViewExternalMessageHandler has nil webViewController") return @@ -116,9 +116,7 @@ final class WebViewExternalMessageHandler { } 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)) - webViewController.presentAlertController(controller: alert, animated: true) + presentBarcodeScannerMessage(message: message) case .threadStoreCredentialInAppleKeychain: guard let macExtendedAddress = incomingMessage.Payload?["mac_extended_address"] as? String, let activeOperationalDataset = incomingMessage.Payload?["active_operational_dataset"] as? String else { return } @@ -324,6 +322,27 @@ final class WebViewExternalMessageHandler { } } + @MainActor + private func presentBarcodeScannerMessage(message: String) { + var config = SwiftMessages.Config() + config.dimMode = .none + config.presentationStyle = .bottom + config.duration = .seconds(seconds: 3) + let view = MessageView.viewFromNib(layout: .cardView) + view.configureContent( + title: nil, + body: message, + iconImage: nil, + iconText: nil, + buttonImage: nil, + buttonTitle: nil, + buttonTapHandler: { _ in + SwiftMessages.hide() + } + ) + SwiftMessages.show(config: config, view: view) + } + private func cleanPreferredThreadCredentials() { Current.settingsStore.matterLastPreferredNetWorkMacExtendedAddress = nil Current.settingsStore.matterLastPreferredNetWorkActiveOperationalDataset = nil From 6537edeba9b32cf0b1469282b7222eeff14773aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:43:45 +0200 Subject: [PATCH 2/3] Remove wrong check --- Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift index 1009f1630c..5f4b44a654 100644 --- a/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift +++ b/Sources/App/BarcodeScanner/Camera/BarcodeScannerCamera.swift @@ -30,8 +30,7 @@ final class BarcodeScannerCamera: NSObject { public var screenSize: CGSize? { didSet { // Prevent unecessary updates - guard let screenSize, - metadataOutput.rectOfInterest.width != BarcodeScannerView.cameraSquareSize else { return } + guard let screenSize else { return } // Calculate normalized rectOfInterest for AVFoundation let cameraSquareSize = BarcodeScannerView.cameraSquareSize let x = (screenSize.width - cameraSquareSize) / 2.0 From b6fe786860c765a692fd203e98761a3dba203404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:20:32 +0200 Subject: [PATCH 3/3] Update tests --- .../WebViewExternalMessageHandler.swift | 1 + .../WebViewExternalMessageHandlerTests.swift | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/App/WebView/WebViewExternalMessageHandler.swift b/Sources/App/WebView/WebViewExternalMessageHandler.swift index a1574ff51f..2283a8c23f 100644 --- a/Sources/App/WebView/WebViewExternalMessageHandler.swift +++ b/Sources/App/WebView/WebViewExternalMessageHandler.swift @@ -340,6 +340,7 @@ final class WebViewExternalMessageHandler { SwiftMessages.hide() } ) + view.id = "BarcodeScannerMessage" SwiftMessages.show(config: config, view: view) } diff --git a/Tests/App/WebView/WebViewExternalMessageHandlerTests.swift b/Tests/App/WebView/WebViewExternalMessageHandlerTests.swift index eb368ae8fd..42e4fb8642 100644 --- a/Tests/App/WebView/WebViewExternalMessageHandlerTests.swift +++ b/Tests/App/WebView/WebViewExternalMessageHandlerTests.swift @@ -1,5 +1,6 @@ @testable import HomeAssistant import Improv_iOS +import SwiftMessages import SwiftUI import XCTest @@ -15,7 +16,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { sut.webViewController = mockWebViewController } - func testHandleExternalMessageConfigScreenShowShowSettings() { + @MainActor func testHandleExternalMessageConfigScreenShowShowSettings() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -31,7 +32,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { ) } - func testHandleExternalMessageThemeUpdateNotifyThemeColors() { + @MainActor func testHandleExternalMessageThemeUpdateNotifyThemeColors() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -43,7 +44,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { XCTAssertEqual(mockWebViewController.lastEvaluatedJavaScriptScript, "notifyThemeColors()") } - func testHandleExternalMessageBarCodeScanPresentsScanner() { + @MainActor func testHandleExternalMessageBarCodeScanPresentsScanner() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -59,7 +60,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { XCTAssertTrue(mockWebViewController.overlayedController is BarcodeScannerHostingController) } - func testHandleExternalMessageBarCodeCloseClosesScanner() { + @MainActor func testHandleExternalMessageBarCodeCloseClosesScanner() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -86,7 +87,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { XCTAssertTrue(mockWebViewController.dismissControllerAboveOverlayControllerCalled) } - func testHandleExternalMessageBarCodeNotifyNotifies() { + @MainActor func testHandleExternalMessageBarCodeNotifyNotifies() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -111,11 +112,11 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { ] sut.handleExternalMessage(dictionary2) - XCTAssertTrue(mockWebViewController.presentOverlayControllerCalled) - XCTAssertTrue(mockWebViewController.presentAlertControllerCalled) + let swiftMessage = SwiftMessages.current(id: "BarcodeScannerMessage") + XCTAssertNotNil(swiftMessage) } - func testHandleExternalMessageStoreInPlatformKeychainOpenTransferFlow() { + @MainActor func testHandleExternalMessageStoreInPlatformKeychainOpenTransferFlow() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -140,7 +141,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { XCTAssertEqual(mockWebViewController.overlayedController?.view.backgroundColor, .clear) } - func testHandleExternalMessageImportThreadCredentialsStartImportFlow() { + @MainActor func testHandleExternalMessageImportThreadCredentialsStartImportFlow() { let dictionary: [String: Any] = [ "id": 1, "message": "", @@ -161,7 +162,7 @@ final class WebViewExternalMessageHandlerTests: XCTestCase { XCTAssertEqual(mockWebViewController.overlayedController?.view.backgroundColor, .clear) } - func testHandleExternalMessageShowAssistShowsAssist() { + @MainActor func testHandleExternalMessageShowAssistShowsAssist() { let dictionary: [String: Any] = [ "id": 1, "message": "",