From dba68b234b73f4449c02f8785c965cd1963b5449 Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Tue, 30 Jun 2026 18:43:53 -0300 Subject: [PATCH] PM-39361 Fix card scanner unresponsive --- .../AddEditCardItem/CardScannerView.swift | 61 +++++++++++++++++-- .../CardScannerViewTests.swift | 54 ++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerViewTests.swift diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerView.swift index 3457eee1ac..1ce7b39b97 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerView.swift @@ -21,7 +21,14 @@ struct CardScannerWrapperView: View { /// Drives `startScanning()`/`stopScanning()` via the SwiftUI view lifecycle. @SwiftUI.State private var isScanning = false + /// Counts how many stop-then-restart cycles have been attempted this session. + /// Capped at 2 to avoid infinite loops; resets on each `.onAppear`. + @SwiftUI.State private var scannerRetryCount = 0 + + /// Dismisses the sheet when the scanner gives up after exhausting retries. @Environment(\.dismiss) private var dismiss + /// Used to restart scanning when the app returns to the foreground after a camera interruption. + @Environment(\.scenePhase) private var scenePhase var body: some View { NavigationView { @@ -35,11 +42,15 @@ struct CardScannerWrapperView: View { scanner: scanner, onLinesUpdated: onLinesUpdated, isScanning: $isScanning, + onScannerUnavailable: restartScanning, ) .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 35) - .onAppear { isScanning = true } + .onAppear { + scannerRetryCount = 0 + isScanning = true + } .onDisappear { isScanning = false } } .navigationTitle(Localizations.scanCard) @@ -51,6 +62,28 @@ struct CardScannerWrapperView: View { } } .navigationViewStyle(.stack) + .onChange(of: scenePhase) { newPhase in + if newPhase == .active, isScanning { + restartScanning() + } + } + } + + // MARK: Private + + /// Stops scanning, waits 300 ms for the camera to fully release, then restarts. + /// After two retries the sheet is dismissed so the user is never left with a blank screen. + private func restartScanning() { + guard scannerRetryCount < 2 else { + dismiss() + return + } + scannerRetryCount += 1 + isScanning = false + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + isScanning = true + } } } @@ -75,6 +108,10 @@ struct CardScannerView: UIViewControllerRepresentable { /// When `true`, scanning is active; when `false`, scanning is stopped. @Binding var isScanning: Bool + /// Called when `startScanning()` throws or the scanner becomes unavailable at runtime + /// (e.g. camera interrupted). The wrapper uses this to schedule a stop-then-restart cycle. + var onScannerUnavailable: (() -> Void)? + // MARK: Static methods for UIViewControllerRepresentable /// Stops scanning and clears the delegate when SwiftUI removes this representable from the hierarchy, @@ -103,7 +140,7 @@ struct CardScannerView: UIViewControllerRepresentable { // MARK: UIViewControllerRepresentable func makeCoordinator() -> Coordinator { - Coordinator(onLinesUpdated: onLinesUpdated) + Coordinator(onLinesUpdated: onLinesUpdated, onScannerUnavailable: onScannerUnavailable) } func makeUIViewController(context: Context) -> DataScannerViewController { @@ -114,7 +151,11 @@ struct CardScannerView: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) { if isScanning { - try? uiViewController.startScanning() + do { + try uiViewController.startScanning() + } catch { + DispatchQueue.main.async { context.coordinator.onScannerUnavailable?() } + } } else { uiViewController.stopScanning() } @@ -139,10 +180,15 @@ extension CardScannerView { let onLinesUpdated: ([String]) -> Void + /// Forwarded from `CardScannerView.onScannerUnavailable`; called when `startScanning()` + /// fails or the camera is interrupted at runtime. + var onScannerUnavailable: (() -> Void)? + // MARK: Initialization - init(onLinesUpdated: @escaping ([String]) -> Void) { + init(onLinesUpdated: @escaping ([String]) -> Void, onScannerUnavailable: (() -> Void)?) { self.onLinesUpdated = onLinesUpdated + self.onScannerUnavailable = onScannerUnavailable } // MARK: DataScannerViewControllerDelegate @@ -173,6 +219,13 @@ extension CardScannerView { updateLines(from: allItems) } + func dataScanner( + _ dataScanner: DataScannerViewController, + becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable, + ) { + onScannerUnavailable?() + } + // MARK: Private Helpers private func updateLines(from items: [RecognizedItem]) { diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerViewTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerViewTests.swift new file mode 100644 index 0000000000..d28e6b5f3a --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardScannerViewTests.swift @@ -0,0 +1,54 @@ +import VisionKit +import XCTest + +@testable import BitwardenShared + +// MARK: - CardScannerViewTests + +/// Tests for `CardScannerView.Coordinator`. +/// +/// `CardScannerWrapperView`'s retry/scene-phase logic is driven by SwiftUI `@State` and cannot +/// be meaningfully exercised without a live view host, so it is not covered here. +/// +@available(iOS 16.0, *) +class CardScannerViewTests: BitwardenTestCase { + // MARK: Tests + + /// `dataScanner(_:becameUnavailableWithError:)` calls `onScannerUnavailable` when the scanner + /// reports an `unsupported` error. + func test_becameUnavailableWithError_callsOnScannerUnavailable_unsupported() { + var callbackInvoked = false + let subject = CardScannerView.Coordinator( + onLinesUpdated: { _ in }, + onScannerUnavailable: { callbackInvoked = true }, + ) + + subject.dataScanner(CardScannerView.makeScanner(), becameUnavailableWithError: .unsupported) + + XCTAssertTrue(callbackInvoked) + } + + /// `dataScanner(_:becameUnavailableWithError:)` calls `onScannerUnavailable` when the scanner + /// reports a `cameraRestricted` error. + func test_becameUnavailableWithError_callsOnScannerUnavailable_cameraRestricted() { + var callbackInvoked = false + let subject = CardScannerView.Coordinator( + onLinesUpdated: { _ in }, + onScannerUnavailable: { callbackInvoked = true }, + ) + + subject.dataScanner(CardScannerView.makeScanner(), becameUnavailableWithError: .cameraRestricted) + + XCTAssertTrue(callbackInvoked) + } + + /// `dataScanner(_:becameUnavailableWithError:)` does not crash when `onScannerUnavailable` is nil. + func test_becameUnavailableWithError_nilCallback_doesNotCrash() { + let subject = CardScannerView.Coordinator( + onLinesUpdated: { _ in }, + onScannerUnavailable: nil, + ) + + subject.dataScanner(CardScannerView.makeScanner(), becameUnavailableWithError: .unsupported) + } +}