Repository: Adyen/adyen-ios
Pull request #2091: Refactor card scanner completion
Author: login: atmamont
Labels: name: chore
description: a pull request that has chore changes that shouldn't be in the release notes
Created: 2025-04-08T14:39:18Z | Merged: 2025-04-09T14:17:24Z

# Summary

This PR:
- fixes tests target by adding analytics handler where it is needed (no specific analytics coverage though in the current scope)
- wraps card scanner provider into addiitional layer using composition. New layer responsibility is to dispatch card scanner completion once. Unit tests added.

This is a short-term solution, this problem belongs more to the `AdyenCardScanner` module.

# Todo
- [x] fix broken integrations for non-included AdyenCardScanner case
- [x] unit tests
- [x] manual testing with logs check

# Ticket

<ticket>
COIOS-886
</ticket>



Diff:
diff --git a/Adyen.xcodeproj/project.pbxproj b/Adyen.xcodeproj/project.pbxproj
index b4ae0c173f..7563324d6c 100644
--- a/Adyen.xcodeproj/project.pbxproj
+++ b/Adyen.xcodeproj/project.pbxproj
@@ -443,6 +443,8 @@
 		B605AC0B2D897A930084D583 /* CardScannerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B605AC092D897A930084D583 /* CardScannerControllerTests.swift */; };
 		B605AC112D8D988D0084D583 /* AdyenCardScanner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9FC2C3D2D50D0B700C9D0F3 /* AdyenCardScanner.framework */; };
 		B605AC122D8D988D0084D583 /* AdyenCardScanner.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C9FC2C3D2D50D0B700C9D0F3 /* AdyenCardScanner.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+		B60946132DA562FD00CEE6CF /* CardScannerProviderDispatchOnceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60946122DA562FD00CEE6CF /* CardScannerProviderDispatchOnceTests.swift */; };
+		B60946152DA5656100CEE6CF /* CardScannerTestsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60946142DA5656100CEE6CF /* CardScannerTestsHelpers.swift */; };
 		B62A075C2D71FB43006072E7 /* FormCardNumberItemView+ScanCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62A075B2D71FB43006072E7 /* FormCardNumberItemView+ScanCard.swift */; };
 		B62D48B42BBE8DBE001EF01A /* AnalyticsFlavorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C16AB280702B200534419 /* AnalyticsFlavorTests.swift */; };
 		B62D48B52BBE8DBE001EF01A /* AnalyticsEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C16A928059A5A00534419 /* AnalyticsEventTests.swift */; };
@@ -1927,6 +1929,8 @@
 		A0F455A02968472B001742C7 /* PartialPaymentMethodDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartialPaymentMethodDetails.swift; sourceTree = "<group>"; };
 		A0FA143E26D65A5300627127 /* InstallmentPickerElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallmentPickerElement.swift; sourceTree = "<group>"; };
 		B605AC092D897A930084D583 /* CardScannerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerControllerTests.swift; sourceTree = "<group>"; };
+		B60946122DA562FD00CEE6CF /* CardScannerProviderDispatchOnceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerProviderDispatchOnceTests.swift; sourceTree = "<group>"; };
+		B60946142DA5656100CEE6CF /* CardScannerTestsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerTestsHelpers.swift; sourceTree = "<group>"; };
 		B62A075B2D71FB43006072E7 /* FormCardNumberItemView+ScanCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormCardNumberItemView+ScanCard.swift"; sourceTree = "<group>"; };
 		B62D48AC2BBE8D79001EF01A /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		B62D48C22BBE8ED6001EF01A /* XCTestCase+Wait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+Wait.swift"; sourceTree = "<group>"; };
@@ -3499,7 +3503,9 @@
 		B605AC0A2D897A930084D583 /* Card Scanner */ = {
 			isa = PBXGroup;
 			children = (
+				B60946142DA5656100CEE6CF /* CardScannerTestsHelpers.swift */,
 				B605AC092D897A930084D583 /* CardScannerControllerTests.swift */,
+				B60946122DA562FD00CEE6CF /* CardScannerProviderDispatchOnceTests.swift */,
 			);
 			path = "Card Scanner";
 			sourceTree = "<group>";
@@ -7628,6 +7634,7 @@
 				E216D3CB221AFB9A0013CBCF /* IBANValidatorTests.swift in Sources */,
 				E9E3DACB221EDDE800697074 /* CardTypeDetectorTests.swift in Sources */,
 				F9B0195A2611ED94001F23FC /* EContextStoresVoucherViewControllerProviderTests.swift in Sources */,
+				B60946152DA5656100CEE6CF /* CardScannerTestsHelpers.swift in Sources */,
 				B6D808402BCD29DF00F3F5EB /* LocalizationTests.swift in Sources */,
 				C927083E27590BDB00D15EA0 /* BACSInputFormViewControllerTests.swift in Sources */,
 				0067E740296C161E005B8D76 /* UPIComponentTests.swift in Sources */,
@@ -7703,6 +7710,7 @@
 				21CBEF662D8B6B3A00178809 /* ThreeDSServiceProviderTests.swift in Sources */,
 				F9FE39E32679E09200F02A9B /* LoadingViewTests.swift in Sources */,
 				B6EE0F3D2BBEBF5100B9810D /* AnalyticsProviderMock.swift in Sources */,
+				B60946132DA562FD00CEE6CF /* CardScannerProviderDispatchOnceTests.swift in Sources */,
 				A0290DF82C413056005BE269 /* StoredPaymentMethodDelegateMock.swift in Sources */,
 				F96A25472372C39F007DBF0B /* ApplePayDetailsTest.swift in Sources */,
 				A07B2CF226A5D4FE0008185C /* BrazilSocialSecurityNumberFormatterTests.swift in Sources */,
diff --git a/AdyenCard/Components/Card/CardScannerController.swift b/AdyenCard/Components/Card/CardScannerController.swift
index a7d227b892..fdc42c79b6 100644
--- a/AdyenCard/Components/Card/CardScannerController.swift
+++ b/AdyenCard/Components/Card/CardScannerController.swift
@@ -20,13 +20,6 @@ internal protocol CardScannerProviding {
 }
 
 internal protocol CardScannerControlling: CardScannerAvailability {
-
-    init(
-        presenter: UIViewController,
-        availabilityProvider: CardScannerAvailability,
-        cardScannerProvider: CardScannerProviding,
-        analyticsHandler: @escaping CardScannerAnalyticsHandler
-    )
     func openCardScanner()
     var title: String? { get set }
     var onScanComplete: ((Result<CardScanDetails, Error>) -> Void)? { get set }
@@ -41,8 +34,26 @@ internal protocol CardScannerControlling: CardScannerAvailability {
         }
     }
 
-    private struct CardScannerProviderWrapper: CardScannerProviding {
-        func createCardScanner(completion: @escaping (Result<CardScanDetails, Error>) -> Void) -> UIViewController? {
+    internal class CardScannerProviderDispatchOnce: CardScannerProviding {
+        private let scannerProvider: CardScannerProviding
+
+        internal init(scannerProvider: CardScannerProviding) {
+            self.scannerProvider = scannerProvider
+        }
+
+        private var isDispatched = false
+
+        internal func createCardScanner(completion: @escaping (Result<CardScanDetails, any Error>) -> Void) -> UIViewController? {
+            self.scannerProvider.createCardScanner { result in
+                guard !self.isDispatched else { return }
+                self.isDispatched = true
+                completion(result)
+            }
+        }
+    }
+
+    internal struct CardScannerProviderWrapper: CardScannerProviding {
+        internal func createCardScanner(completion: @escaping (Result<CardScanDetails, Error>) -> Void) -> UIViewController? {
 
             let localizationBundle = Bundle.coreInternalResources
             let cardScannerViewController = AdyenCardScanner.CardScanner.createCardScanner(
@@ -74,7 +85,8 @@ internal protocol CardScannerControlling: CardScannerAvailability {
         internal init(
             presenter: UIViewController,
             availabilityProvider: CardScannerAvailability = CardScannerAvailabilityWrapper(),
-            cardScannerProvider: CardScannerProviding = CardScannerProviderWrapper(),
+            cardScannerProvider: CardScannerProviding = CardScannerProviderDispatchOnce(
+                scannerProvider: CardScannerProviderWrapper()),
             analyticsHandler: @escaping CardScannerAnalyticsHandler
         ) {
             self.presenter = presenter
@@ -174,7 +186,8 @@ internal protocol CardScannerControlling: CardScannerAvailability {
         internal init(
             presenter: UIViewController,
             availabilityProvider: CardScannerAvailability = DummyCardScannerAvailability(),
-            cardScannerProvider: CardScannerProviding = DummyCardScannerProvider()
+            cardScannerProvider: CardScannerProviding = DummyCardScannerProvider(),
+            analyticsHandler: CardScannerAnalyticsHandler?
         ) {}
 
         // MARK: - Helpers
diff --git a/AdyenCardScannerTests/Mocks/AppOpenerMock.swift b/AdyenCardScannerTests/Mocks/AppOpenerMock.swift
index 3e9976ed52..a04b07c458 100644
--- a/AdyenCardScannerTests/Mocks/AppOpenerMock.swift
+++ b/AdyenCardScannerTests/Mocks/AppOpenerMock.swift
@@ -12,6 +12,7 @@ class AppOpenerMock: AppOpener {
     var openSettingsAppCalled: Bool {
         openSettingsAppCallsCount > 0
     }
+
     var openSettingsAppClosure: (() async -> Void)?
     
     func openSettingsApp() async {
diff --git a/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerControllerTests.swift b/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerControllerTests.swift
index cfbd6583de..d8581e926f 100644
--- a/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerControllerTests.swift	
+++ b/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerControllerTests.swift	
@@ -6,6 +6,7 @@
 
 #if canImport(AdyenCardScanner)
     @testable import AdyenCard
+    @_spi(AdyenInternal) import Adyen
     @testable import AdyenCardScanner
     import XCTest
 
@@ -61,7 +62,7 @@
             let expectedResult: CardScanDetails = (cardNumber, expiryDate)
             let mockCard = CardScanDetails(cardNumber, expiryDate)
 
-            let (sut, presenter, cardScanner) = makeSUT()
+            let (sut, _, cardScanner) = makeSUT()
             sut.onScanComplete = { result in
                 // Then
                 self.expect(result, toMatch: .success(expectedResult))
@@ -80,7 +81,7 @@
             let expectation = XCTestExpectation(description: "Card scanner should complete the flow")
             let mockError = AdyenCardScanner.CardScannerError(kind: .authorizationDenied)
             let expectedError = CardScannerController.CardScannerError.scanningError
-            let (sut, presenter, cardScanner) = makeSUT()
+            let (sut, _, cardScanner) = makeSUT()
 
             sut.onScanComplete = { result in
                 // Then
@@ -104,11 +105,13 @@
             let sut = CardScannerController(
                 presenter: presenter,
                 availabilityProvider: CardScannerAvailalabilityMock(),
-                cardScannerProvider: cardScanner
+                cardScannerProvider: cardScanner, analyticsHandler: self.sendEvent
             )
             return (sut, presenter, cardScanner)
         }
 
+        private func sendEvent(_ subtype: AnalyticsEventLog.LogSubType) {}
+
         private func expect(
             _ result: Result<CardScanDetails, Error>,
             toMatch expectedResult: Result<CardScanDetails, Error>,
@@ -129,20 +132,5 @@
         private struct CardScannerAvailalabilityMock: CardScannerAvailability {
             var isScannerAvailable: Bool { true }
         }
-
-        private class CardScannerProviderSpy: CardScannerProviding {
-            private var completion: ((Result<AdyenCardScanner.CardScanDetails, Error>) -> Void)? = nil
-
-            func createCardScanner(
-                completion: @escaping (Result<CardScanDetails, Error>) -> Void
-            ) -> UIViewController? {
-                self.completion = completion
-                return UIViewController()
-            }
-
-            func onScanComplete(result: Result<CardScanDetails, Error>) {
-                self.completion?(result)
-            }
-        }
     }
 #endif
diff --git a/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerProviderDispatchOnceTests.swift b/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerProviderDispatchOnceTests.swift
new file mode 100644
index 0000000000..5c0ccb51f4
--- /dev/null
+++ b/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerProviderDispatchOnceTests.swift	
@@ -0,0 +1,31 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+#if canImport(AdyenCardScanner)
+    import XCTest
+    @testable @_spi(AdyenInternal) import AdyenCard
+
+    final class CardScannerProviderDispatchOnceTests: XCTestCase {
+
+        func testCardScannerCompletesOnlyOnce() {
+            let expectation = XCTestExpectation(description: "Card scanner completion should be called once")
+            expectation.expectedFulfillmentCount = 1
+            expectation.assertForOverFulfill = true
+
+            let cardScanner = CardScannerProviderSpy()
+            let sut = CardScannerProviderDispatchOnce(scannerProvider: cardScanner)
+            let _ = sut.createCardScanner { result in
+                expectation.fulfill()
+            }
+
+            cardScanner.onScanComplete(result: .success((nil, Date())))
+            cardScanner.onScanComplete(result: .success((nil, Date())))
+            cardScanner.onScanComplete(result: .failure(NSError(domain: "domain", code: 123)))
+
+            wait(for: [expectation], timeout: 0.3)
+        }
+    }
+#endif
diff --git a/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerTestsHelpers.swift b/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerTestsHelpers.swift
new file mode 100644
index 0000000000..78281fc95d
--- /dev/null
+++ b/Tests/IntegrationTests/Card Tests/Card Scanner/CardScannerTestsHelpers.swift	
@@ -0,0 +1,25 @@
+//
+// Copyright (c) 2025 Adyen N.V.
+//
+// This file is open source and available under the MIT license. See the LICENSE file for more info.
+//
+
+#if canImport(AdyenCardScanner)
+    @testable import AdyenCard
+    @testable import AdyenCardScanner
+
+    internal class CardScannerProviderSpy: CardScannerProviding {
+        private var completion: ((Result<AdyenCardScanner.CardScanDetails, Error>) -> Void)? = nil
+
+        func createCardScanner(
+            completion: @escaping (Result<CardScanDetails, Error>) -> Void
+        ) -> UIViewController? {
+            self.completion = completion
+            return UIViewController()
+        }
+
+        func onScanComplete(result: Result<CardScanDetails, Error>) {
+            self.completion?(result)
+        }
+    }
+#endif


Reviews:
user: login: github-actions[bot]
body: This pull request does not contain a valid label. Please add one of the following labels: `['new', 'changed', 'fixed', 'removed', 'deprecated', 'chore', 'improvement']`
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:39:53Z
user: login: github-actions[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:46:12Z
user: login: github-actions[bot]
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T09:40:51Z
user: login: atmamont
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T09:52:52Z
user: login: neelSharma12
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T09:59:10Z
user: login: erenbesel
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:00:29Z
user: login: nauaros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:37:48Z

Review comments:
diff_hunk: @@ -20,13 +20,6 @@ internal protocol CardScannerProviding {
 }
 
 internal protocol CardScannerControlling: CardScannerAvailability {
-
-    init(
path: AdyenCard/Components/Card/CardScannerController.swift
user: login: atmamont
body: @nauaros 
Having this defined on the interface level typically creates unnecessary load when it comes down to juggling around with various entities conforming to a specific protocol. It also exposes some implementation details and dependencies on the interface level.
created_at: 2025-04-09T09:52:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,8 +34,26 @@ internal protocol CardScannerControlling: CardScannerAvailability {
         }
     }
 
-    private struct CardScannerProviderWrapper: CardScannerProviding {
-        func createCardScanner(completion: @escaping (Result<CardScanDetails, Error>) -> Void) -> UIViewController? {
+    internal class CardScannerProviderDispatchOnce: CardScannerProviding {
+        private let scannerProvider: CardScannerProviding
+
+        internal init(scannerProvider: CardScannerProviding) {
+            self.scannerProvider = scannerProvider
+        }
+
+        private var isDispatched = false
+
+        internal func createCardScanner(completion: @escaping (Result<CardScanDetails, any Error>) -> Void) -> UIViewController? {
+            self.scannerProvider.createCardScanner { result in
+                guard !self.isDispatched else { return }
path: AdyenCard/Components/Card/CardScannerController.swift
user: login: nauaros
body: Thanks! It's a provisional solution that I will address from the SDK side.
created_at: 2025-04-09T14:37:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-09T09:44:51Z
body: # ✅ No changes detected
_Comparing `chore/refactor-card-scanner-completion` to `feature/COIOS-886_card_scanning_analytics`_

---
**Analyzed targets:** Adyen, AdyenCashAppPay, AdyenComponents, AdyenDelegatedAuthentication, AdyenEncryption, AdyenSwiftUI, AdyenTwint
<!-- thollander/actions-comment-pull-request "api_changes" -->
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-09T10:08:10Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Adyen_adyen-ios&pullRequest=2091) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Adyen_adyen-ios&pullRequest=2091&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Adyen_adyen-ios&pullRequest=2091&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Adyen_adyen-ios&pullRequest=2091&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [91.7% Coverage on New Code](https://sonarcloud.io/component_measures?id=Adyen_adyen-ios&pullRequest=2091&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Adyen_adyen-ios&pullRequest=2091&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Adyen_adyen-ios&pullRequest=2091)


author_association: NONE