diff --git a/BankAPILibrary/GiniBankAPILibrary/Sources/GiniBankAPILibrary/Documents/Configuration/ClientConfiguration.swift b/BankAPILibrary/GiniBankAPILibrary/Sources/GiniBankAPILibrary/Documents/Configuration/ClientConfiguration.swift index a9647f90c2..de4cdfaf1b 100644 --- a/BankAPILibrary/GiniBankAPILibrary/Sources/GiniBankAPILibrary/Documents/Configuration/ClientConfiguration.swift +++ b/BankAPILibrary/GiniBankAPILibrary/Sources/GiniBankAPILibrary/Documents/Configuration/ClientConfiguration.swift @@ -27,6 +27,7 @@ public struct ClientConfiguration: Codable { public let savePhotosLocallyEnabled: Bool public let alreadyPaidHintEnabled: Bool public let paymentDueHintEnabled: Bool + public let unsupportedQRCodeWarningEnabled: Bool /** Creates a new `ClientConfiguration` instance. @@ -43,6 +44,7 @@ public struct ClientConfiguration: Codable { - savePhotosLocallyEnabled: A flag indicating whether saving photos locally is enabled. - alreadyPaidHintEnabled: A flag indicating whether hints for already paid invoices are enabled. - paymentDueHintEnabled: A flag indicating whether hints for upcoming payment due date is enabled. + - unsupportedQRCodeWarningEnabled: A flag indicating whether the unsupported QR code warning alert is enabled. */ public init(clientID: String, userJourneyAnalyticsEnabled: Bool, @@ -54,7 +56,8 @@ public struct ClientConfiguration: Codable { eInvoiceEnabled: Bool, savePhotosLocallyEnabled: Bool, alreadyPaidHintEnabled: Bool, - paymentDueHintEnabled: Bool) { + paymentDueHintEnabled: Bool, + unsupportedQRCodeWarningEnabled: Bool) { self.clientID = clientID self.userJourneyAnalyticsEnabled = userJourneyAnalyticsEnabled self.skontoEnabled = skontoEnabled @@ -66,5 +69,6 @@ public struct ClientConfiguration: Codable { self.savePhotosLocallyEnabled = savePhotosLocallyEnabled self.alreadyPaidHintEnabled = alreadyPaidHintEnabled self.paymentDueHintEnabled = paymentDueHintEnabled + self.unsupportedQRCodeWarningEnabled = unsupportedQRCodeWarningEnabled } } diff --git a/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/ClientConfigurationTests.swift b/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/ClientConfigurationTests.swift index 5f1de3ff46..80eab7c7e9 100644 --- a/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/ClientConfigurationTests.swift +++ b/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/ClientConfigurationTests.swift @@ -26,7 +26,8 @@ struct ClientConfigurationTests { eInvoiceEnabled: true, savePhotosLocallyEnabled: true, alreadyPaidHintEnabled: true, - paymentDueHintEnabled: true) + paymentDueHintEnabled: true, + unsupportedQRCodeWarningEnabled: true) #expect(config.clientID == testClientID, "Expected clientID to be \(testClientID)") #expect(config.userJourneyAnalyticsEnabled, "Expected userJourneyAnalyticsEnabled to be true") @@ -38,6 +39,7 @@ struct ClientConfigurationTests { #expect(config.eInvoiceEnabled, "Expected eInvoiceEnabled to be true") #expect(config.alreadyPaidHintEnabled, "Expected alreadyPaidHintEnabled to be true") #expect(config.savePhotosLocallyEnabled, "Expected savePhotosLocallyEnabled to be true") + #expect(config.unsupportedQRCodeWarningEnabled, "Expected unsupportedQRCodeWarningEnabled to be true") } @Test("Initialization with all flags disabled") @@ -52,9 +54,8 @@ struct ClientConfigurationTests { eInvoiceEnabled: false, savePhotosLocallyEnabled: false, alreadyPaidHintEnabled: false, - paymentDueHintEnabled: false) - - + paymentDueHintEnabled: false, + unsupportedQRCodeWarningEnabled: false) #expect(config.clientID == testClientID, "Expected clientID to be \(testClientID)") #expect(!config.userJourneyAnalyticsEnabled, "Expected userJourneyAnalyticsEnabled to be false") @@ -66,6 +67,7 @@ struct ClientConfigurationTests { #expect(!config.eInvoiceEnabled, "Expected eInvoiceEnabled to be false") #expect(!config.alreadyPaidHintEnabled, "Expected alreadyPaidHintEnabled to be false") #expect(!config.savePhotosLocallyEnabled, "Expected savePhotosLocallyEnabled to be false") + #expect(!config.unsupportedQRCodeWarningEnabled, "Expected unsupportedQRCodeWarningEnabled to be false") } // MARK: - JSON Decoding Tests @@ -87,6 +89,7 @@ struct ClientConfigurationTests { #expect(!config.eInvoiceEnabled, "Expected eInvoiceEnabled to be false from JSON") #expect(!config.alreadyPaidHintEnabled, "Expected alreadyPaidHintEnabled to be false from JSON") #expect(!config.savePhotosLocallyEnabled, "Expected savePhotosLocallyEnabled to be false from JSON") + #expect(!config.unsupportedQRCodeWarningEnabled, "Expected unsupportedQRCodeWarningEnabled to be false from JSON") } @Test("Decoding fails when missing required clientID field") @@ -113,7 +116,8 @@ struct ClientConfigurationTests { eInvoiceEnabled: true, savePhotosLocallyEnabled: true, alreadyPaidHintEnabled: true, - paymentDueHintEnabled: true) + paymentDueHintEnabled: true, + unsupportedQRCodeWarningEnabled: true) let encoder = JSONEncoder() @@ -140,6 +144,8 @@ struct ClientConfigurationTests { "Expected alreadyPaidHintEnabled to be preserved") #expect(decodedConfig.savePhotosLocallyEnabled == config.savePhotosLocallyEnabled, "Expected savePhotosLocallyEnabled to be preserved") + #expect(decodedConfig.unsupportedQRCodeWarningEnabled == config.unsupportedQRCodeWarningEnabled, + "Expected unsupportedQRCodeWarningEnabled to be preserved") } // MARK: - Property Combinations Tests @@ -156,7 +162,8 @@ struct ClientConfigurationTests { eInvoiceEnabled: false, savePhotosLocallyEnabled: false, alreadyPaidHintEnabled: true, - paymentDueHintEnabled: true) + paymentDueHintEnabled: true, + unsupportedQRCodeWarningEnabled: true) #expect(config.userJourneyAnalyticsEnabled, "Expected userJourneyAnalyticsEnabled to be true") #expect(!config.skontoEnabled, "Expected skontoEnabled to be false") @@ -167,5 +174,6 @@ struct ClientConfigurationTests { #expect(!config.eInvoiceEnabled, "Expected eInvoiceEnabled to be false") #expect(config.alreadyPaidHintEnabled, "Expected alreadyPaidHintEnabled to be true") #expect(!config.savePhotosLocallyEnabled, "Expected savePhotosLocallyEnabled to be false") + #expect(config.unsupportedQRCodeWarningEnabled, "Expected unsupportedQRCodeWarningEnabled to be true") } } diff --git a/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/Resources/clientConfiguration.json b/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/Resources/clientConfiguration.json index 34095c26eb..875870e826 100644 --- a/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/Resources/clientConfiguration.json +++ b/BankAPILibrary/GiniBankAPILibrary/Tests/GiniBankAPILibraryTests/Resources/clientConfiguration.json @@ -9,5 +9,6 @@ "eInvoiceEnabled": false, "savePhotosLocallyEnabled": false, "alreadyPaidHintEnabled": false, - "paymentDueHintEnabled": false + "paymentDueHintEnabled": false, + "unsupportedQRCodeWarningEnabled": false } diff --git a/BankSDK/GiniBankSDK/Sources/GiniBankSDK/Core/GiniBankNetworkingScreenApiCoordinator.swift b/BankSDK/GiniBankSDK/Sources/GiniBankSDK/Core/GiniBankNetworkingScreenApiCoordinator.swift index b2b8f252c1..b825da1f17 100644 --- a/BankSDK/GiniBankSDK/Sources/GiniBankSDK/Core/GiniBankNetworkingScreenApiCoordinator.swift +++ b/BankSDK/GiniBankSDK/Sources/GiniBankSDK/Core/GiniBankNetworkingScreenApiCoordinator.swift @@ -306,6 +306,8 @@ open class GiniBankNetworkingScreenApiCoordinator: GiniScreenAPICoordinator, Gin GiniCaptureUserDefaultsStorage.qrCodeEducationEnabled = configuration.qrCodeEducationEnabled GiniCaptureUserDefaultsStorage.eInvoiceEnabled = configuration.eInvoiceEnabled GiniCaptureUserDefaultsStorage.savePhotosLocallyEnabled = configuration.savePhotosLocallyEnabled + GiniCaptureUserDefaultsStorage.unsupportedQRCodeWarningEnabled = + configuration.unsupportedQRCodeWarningEnabled self.initializeAnalytics(with: configuration) } case .failure(let error): diff --git a/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/Helpers/CameraMock.swift b/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/Helpers/CameraMock.swift index cac1999e03..7bef926a94 100644 --- a/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/Helpers/CameraMock.swift +++ b/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/Helpers/CameraMock.swift @@ -56,7 +56,7 @@ final class CameraMock: CameraProtocol { // This method will remain empty; no implementation is needed. } - func setup(completion: ((CameraError?) -> Void)) { + func setup(completion: @escaping ((CameraError?) -> Void)) { switch state { case .authorized: completion(nil) @@ -76,4 +76,12 @@ final class CameraMock: CameraProtocol { func stop() { // This method will remain empty; no implementation is needed. } + + func pauseQRDetection() { + // This method will remain empty; no implementation is needed. + } + + func resumeQRDetection() { + // This method will remain empty; no implementation is needed. + } } diff --git a/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/NetworkingScreenApiCoordinatorTests.swift b/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/NetworkingScreenApiCoordinatorTests.swift index a10b40bd6b..4e1e3b6c4c 100644 --- a/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/NetworkingScreenApiCoordinatorTests.swift +++ b/BankSDK/GiniBankSDK/Tests/GiniBankSDKTests/NetworkingScreenApiCoordinatorTests.swift @@ -362,6 +362,7 @@ extension ClientConfiguration { eInvoiceEnabled: false, savePhotosLocallyEnabled: false, alreadyPaidHintEnabled: alreadyPaidHintEnabled, - paymentDueHintEnabled: paymentDueHintEnabled) + paymentDueHintEnabled: paymentDueHintEnabled, + unsupportedQRCodeWarningEnabled: false) } } diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift index 0b840079f2..374495da78 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift @@ -33,6 +33,8 @@ protocol CameraProtocol: AnyObject { func setupQRScanningOutput(completion: @escaping ((CameraError?) -> Void)) func start() func stop() + func pauseQRDetection() + func resumeQRDetection() // IBAN detection func startOCR() @@ -74,6 +76,9 @@ final class Camera: NSObject, CameraProtocol { fileprivate let application: UIApplication + // QR detection + private(set) var qrMetadataOutput: AVCaptureMetadataOutput? + // IBAN detection private var request: VNImageBasedRequest? private var textOrientation = CGImagePropertyOrientation.up @@ -272,8 +277,30 @@ final class Camera: NSObject, CameraProtocol { if qrOutput.availableMetadataObjectTypes.contains(.qr) { qrOutput.metadataObjectTypes = [.qr] } + qrMetadataOutput = qrOutput session.commitConfiguration() } + + func pauseQRDetection() { + sessionQueue.async { [weak self] in + self?.qrMetadataOutput?.metadataObjectTypes = [] + } + } + + func resumeQRDetection() { + sessionQueue.async { [weak self] in + guard let self = self, + let output = self.qrMetadataOutput, + output.availableMetadataObjectTypes.contains(.qr) else { return } + output.metadataObjectTypes = [.qr] + } + } + + // Intended for unit tests only — injects a metadata output without going + // through full session setup, which is unstable on CI simulators. + func setQRMetadataOutputForTesting(_ output: AVCaptureMetadataOutput?) { + qrMetadataOutput = output + } } // MARK: - Fileprivate diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift index bb4cc610e2..6014da5812 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift @@ -46,6 +46,10 @@ final class CameraViewController: UIViewController { private var isPresentedOnScreen = false private var isValidIBANDetected: Bool = false + // Snapshot of the backend flag taken on the first invalid QR scan. + // Stays fixed for the session so all repeated scans show the same feedback type. + private var sessionUnsupportedQRCodeWarningEnabled: Bool? + private var isWarningFlagSnapshotted = false // Analytics private var invalidQRCodeOverlayFirstAppearance: Bool = true private var ibanOverlayFirstAppearance: Bool = true @@ -589,24 +593,39 @@ final class CameraViewController: UIViewController { resetQRCodeTask?.cancel() detectedQRCodeDocument = document - hideQRCodeTask = DispatchWorkItem(block: { - self.resetQRCodeScanning(isValid: isValid) - - if let QRDocument = self.detectedQRCodeDocument { - if isValid { + if isValid { + let task = DispatchWorkItem(block: { + self.resetQRCodeScanning(isValid: true) + if let QRDocument = self.detectedQRCodeDocument { self.didPick(QRDocument) } - } - }) - - if isValid { + }) + hideQRCodeTask = task showValidQRCodeFeedback() + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.hideQRCodeDelay, execute: task) } else { if !isValidIBANDetected { - showInvalidQRCodeFeedback() + // Snapshot the flag once so the feedback type stays consistent across + // repeated scans in the same session. A separate boolean guards the snapshot + // because assigning nil to a Bool? still leaves it nil, making a nil-check + // unreliable as a "has snapshotted" gate. + if !isWarningFlagSnapshotted { + sessionUnsupportedQRCodeWarningEnabled = GiniCaptureUserDefaultsStorage.unsupportedQRCodeWarningEnabled + isWarningFlagSnapshotted = true + } + + if sessionUnsupportedQRCodeWarningEnabled == true { + showUnsupportedQRCodeAlert() + } else { + showInvalidQRCodeFeedback() + let task = DispatchWorkItem(block: { + self.resetQRCodeScanning(isValid: false) + }) + hideQRCodeTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.hideQRCodeDelay, execute: task) + } } } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: hideQRCodeTask!) } private func showValidQRCodeFeedback() { @@ -651,6 +670,43 @@ final class CameraViewController: UIViewController { qrCodeOverLay.configureQrCodeOverlay(withCorrectQrCode: false) } + private func showUnsupportedQRCodeAlert() { + // Skip duplicate side-effects when the alert is already on screen and more frames + // are still queued before pauseQRDetection takes effect on the session queue. + guard presentedViewController == nil else { return } + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.warning) + + cameraPreviewViewController.camera.pauseQRDetection() + + sendGiniAnalyticsEventForInvalidQRCode() + playVoiceOverMessage(success: false) + + let alert = UIAlertController(title: Strings.unsupportedQRAlertTitle, + message: nil, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.scanAnotherQRCode, style: .default) { [weak self] _ in + self?.handleScanAnotherQRCode() + }) + alert.addAction(UIAlertAction(title: Strings.takePhotoOfDocument, style: .default) { [weak self] _ in + self?.handleTakePhotoOfDocument() + }) + + present(alert, animated: true) + } + + private func handleScanAnotherQRCode() { + cameraPreviewViewController.camera.resumeQRDetection() + detectedQRCodeDocument = nil + } + + private func handleTakePhotoOfDocument() { + // QR detection stays paused — resuming here would immediately re-trigger the alert + cameraPreviewViewController.cameraFrameView.isHidden = false + detectedQRCodeDocument = nil + } + private func isAccessibilityLargeTextEnabled() -> Bool { let contentSizeCategory = UIApplication.shared.preferredContentSizeCategory return contentSizeCategory.isAccessibilityCategory @@ -767,6 +823,7 @@ private extension CameraViewController { static let switcherPadding: CGFloat = 8 static let phoneSwitcherSize: CGSize = CGSize(width: 124, height: 40) static let tableSwitcherSize: CGSize = CGSize(width: 40, height: 124) + static let hideQRCodeDelay: TimeInterval = 1.5 } private struct Strings { @@ -778,6 +835,21 @@ private extension CameraViewController { comment: "Info label") static let cameraTitle = NSLocalizedStringPreferredFormat("ginicapture.navigationbar.camera.title", comment: "Camera title") + + static let unsupportedQRAlertTitleKey = "ginicapture.QRscanning.alert.title" + static let unsupportedQRAlertTitleComment = "Unsupported QR code alert title" + static let unsupportedQRAlertTitle = NSLocalizedStringPreferredFormat(unsupportedQRAlertTitleKey, + comment: unsupportedQRAlertTitleComment) + + static let scanAnotherQRCodeKey = "ginicapture.QRscanning.alert.scanAnother" + static let scanAnotherQRCodeComment = "Scan another QR code button" + static let scanAnotherQRCode = NSLocalizedStringPreferredFormat(scanAnotherQRCodeKey, + comment: scanAnotherQRCodeComment) + + static let takePhotoOfDocumentKey = "ginicapture.QRscanning.alert.takePhoto" + static let takePhotoOfDocumentComment = "Take photo of document button" + static let takePhotoOfDocument = NSLocalizedStringPreferredFormat(takePhotoOfDocumentKey, + comment: takePhotoOfDocumentComment) } } // swiftlint:enable type_body_length diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift index d9f0cb7e4d..ea91628071 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift @@ -73,7 +73,7 @@ final class CameraPreviewViewController: UIViewController { private var notAuthorizedView: UIView? private let giniConfiguration: GiniConfiguration private typealias FocusIndicator = UIImageView - private var camera: CameraProtocol + private(set) var camera: CameraProtocol private var defaultImageView: UIImageView? private var focusIndicatorImageView: UIImageView? diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Storage/GiniCaptureUserDefaultsStorage.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Storage/GiniCaptureUserDefaultsStorage.swift index b0eb3d2151..b3bf860e22 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Storage/GiniCaptureUserDefaultsStorage.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Storage/GiniCaptureUserDefaultsStorage.swift @@ -32,6 +32,11 @@ public struct GiniCaptureUserDefaultsStorage { defaultValue: nil) public static var savePhotosLocallyEnabled: Bool? + // Configuration flag for the unsupported QR code warning alert + @GiniUserDefault("ginicapture.defaults.clientConfigurations.unsupportedQRCodeWarningEnabled", + defaultValue: nil) + public static var unsupportedQRCodeWarningEnabled: Bool? + // User preference for the Save photos locally feature @GiniUserDefault("ginicapture.defaults.userSettings.savePhotosSwitchOn", defaultValue: nil) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/de.lproj/Localizable.strings b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/de.lproj/Localizable.strings index 599019983a..a8072a3a4d 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/de.lproj/Localizable.strings +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/de.lproj/Localizable.strings @@ -220,6 +220,9 @@ "ginicapture.QRscanning.correct" = "QR-Code erkannt"; "ginicapture.QRscanning.incorrect.title" = "Unbekannter QR-Code"; "ginicapture.QRscanning.incorrect.description" = "Dieser Code enthält keine Zahlungsinformationen. Bitte anderen QR-Code oder Rechnung abfotografieren."; +"ginicapture.QRscanning.alert.title" = "Dieser QR-Code ist kein Zahlungs-QR-Code"; +"ginicapture.QRscanning.alert.scanAnother" = "Anderen QR-Code scannen"; +"ginicapture.QRscanning.alert.takePhoto" = "Dokument fotografieren"; "ginicapture.QRscanning.loading" = "Rechnung wird übermittelt"; "ginicapture.QRscanning.education.loading.captureTip" = "Fotografieren Sie direkt ein Zahlungsformular oder eine Rechnung - auch ohne QR-Code"; diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/en.lproj/Localizable.strings b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/en.lproj/Localizable.strings index 01ddb464e7..af330464f6 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/en.lproj/Localizable.strings +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Resources/en.lproj/Localizable.strings @@ -217,6 +217,9 @@ "ginicapture.QRscanning.correct" = "QR code detected"; "ginicapture.QRscanning.incorrect.title" = "Unknown QR code"; "ginicapture.QRscanning.incorrect.description" = "This code does not contain any payment details. Please use another QR code or take an image of your invoice."; +"ginicapture.QRscanning.alert.title" = "This is not a payment QR code"; +"ginicapture.QRscanning.alert.scanAnother" = "Scan another QR code"; +"ginicapture.QRscanning.alert.takePhoto" = "Take photo of document"; "ginicapture.QRscanning.loading" = "Retrieving invoice"; "ginicapture.QRscanning.education.loading.captureTip" = "You can take photos of receipts or remittance slips – even without a QR code"; diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraMock.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraMock.swift index 750126be08..65fd73e0b1 100644 --- a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraMock.swift +++ b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraMock.swift @@ -10,6 +10,14 @@ import AVFoundation @testable import GiniCaptureSDK final class CameraMock: CameraProtocol { + func pauseQRDetection() { + // This method will remain empty; no implementation is needed. + } + + func resumeQRDetection() { + // This method will remain empty; no implementation is needed. + } + func setupIBANDetection(textOrientation: CGImagePropertyOrientation, regionOfInterest: CGRect?, videoPreviewLayer: AVCaptureVideoPreviewLayer?, @@ -57,7 +65,7 @@ final class CameraMock: CameraProtocol { // This method will remain empty; no implementation is needed. } - func setup(completion: ((CameraError?) -> Void)) { + func setup(completion: @escaping ((CameraError?) -> Void)) { switch state { case .authorized: completion(nil) diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraPreviewViewControllerTests.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraPreviewViewControllerTests.swift index 8244139430..16f11a9423 100644 --- a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraPreviewViewControllerTests.swift +++ b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/CameraPreviewViewControllerTests.swift @@ -50,16 +50,9 @@ final class CameraPreviewViewControllerTests: XCTestCase { cameraPreviewViewController = CameraPreviewViewController(giniConfiguration: giniConfiguration) _ = cameraPreviewViewController.view let bottomAnchor = cameraPreviewViewController.view.bottomAnchor + // Verifies setupCamera does not crash when QR scanning is enabled. + // QR metadata output is configured separately via setupQRScanningOutput (called by CameraViewController). cameraPreviewViewController.setupCamera(bottomAnchor: bottomAnchor) - - DispatchQueue.main.async { - let metadataOutput = self.cameraPreviewViewController.previewView.session - .outputs - .compactMap { $0 as? AVCaptureMetadataOutput } - .first - - XCTAssertNotNil(metadataOutput, "the camera session should have the metadata output") - } } func testCaptureImage() { @@ -83,12 +76,105 @@ final class CameraPreviewViewControllerTests: XCTestCase { let defaultFlashState = camera.isFlashOn let giniConfiguration = GiniConfiguration() giniConfiguration.flashToggleEnabled = true - + cameraPreviewViewController = CameraPreviewViewController(giniConfiguration: giniConfiguration, camera: camera) _ = cameraPreviewViewController.view cameraPreviewViewController.isFlashOn = false - + XCTAssertNotEqual(defaultFlashState, camera.isFlashOn, "camera flash state should change it after toggle it") } + + // MARK: - QR Detection Pause/Resume + + private func makeCamera() -> Camera { + return Camera(giniConfiguration: GiniConfiguration()) + } + + private func flushSessionQueue(_ camera: Camera, timeout: TimeInterval = 2.0) { + let expect = expectation(description: "session queue flushed") + camera.sessionQueue.async { + expect.fulfill() + } + wait(for: [expect], timeout: timeout) + } + + func testPauseQRDetectionBeforeSetupDoesNotCrash() { + let camera = makeCamera() + + camera.pauseQRDetection() + flushSessionQueue(camera) + } + + func testResumeQRDetectionBeforeSetupDoesNotCrash() { + let camera = makeCamera() + + camera.resumeQRDetection() + flushSessionQueue(camera) + } + + func testPauseQRDetectionClearsMetadataObjectTypes() { + let camera = makeCamera() + let output = AVCaptureMetadataOutput() + camera.setQRMetadataOutputForTesting(output) + + camera.pauseQRDetection() + flushSessionQueue(camera) + + XCTAssertTrue(output.metadataObjectTypes.isEmpty, + "metadataObjectTypes should be empty after pauseQRDetection") + } + + func testResumeQRDetectionGuardsAgainstUnavailableQRType() { + let camera = makeCamera() + let output = AVCaptureMetadataOutput() + camera.setQRMetadataOutputForTesting(output) + + camera.resumeQRDetection() + flushSessionQueue(camera) + + // On CI simulators .qr is not in availableMetadataObjectTypes, so the guard + // returns and metadataObjectTypes stays at its default (empty). + XCTAssertTrue(output.metadataObjectTypes.isEmpty, + "metadataObjectTypes should remain unchanged when .qr is unavailable") + } + + func testSetupQRScanningOutputAssignsMetadataOutput() { + let camera = makeCamera() + + // Trigger setup but don't wait for the main-queue completion callback — + // just flush the serial sessionQueue, which runs after configureQROutput finishes. + camera.setupQRScanningOutput { _ in } + flushSessionQueue(camera, timeout: 10.0) + + XCTAssertNotNil(camera.qrMetadataOutput, + "qrMetadataOutput should be assigned after configureQROutput runs") + } + + func testResumeQRDetectionEnablesQRTypeWhenAvailable() { + let camera = makeCamera() + let output = FakeQRAvailableMetadataOutput() + camera.setQRMetadataOutputForTesting(output) + + camera.resumeQRDetection() + flushSessionQueue(camera) + + XCTAssertEqual(output.metadataObjectTypes, [.qr], + "metadataObjectTypes should be set to [.qr] when .qr is available") + } +} + +// Test stub that pretends .qr is supported, allowing the `availableMetadataObjectTypes.contains(.qr)` +// guard branch in resumeQRDetection to be exercised on CI simulators (which have no real camera). +private final class FakeQRAvailableMetadataOutput: AVCaptureMetadataOutput { + private var _objectTypes: [AVMetadataObject.ObjectType]? = [] + + override var availableMetadataObjectTypes: [AVMetadataObject.ObjectType] { + return [.qr] + } + + override var metadataObjectTypes: [AVMetadataObject.ObjectType]! { + get { _objectTypes } + set { _objectTypes = newValue } + } }