Skip to content

Commit

Permalink
Card scanner module - Business layer (#2008)
Browse files Browse the repository at this point in the history
# Summary

This PR introduces the core UI and business logic for the card scanner,
following the MVVM architectural pattern.

### `CardScannerViewModel`
- Configures and manages the `AVCaptureSession`:
  - Starts and stops the session in sync with the view lifecycle.
  - Sets up input and output sources.
- Configures the `AVCaptureDevice`:
  - Adjusts minimum and maximum frame rates.
  - Sets focus and exposure modes.
- Processes captured images:
- Extracts and scales the region of interest using
`cropRegionOfInterest`.

### `CardScannerViewController`
- Acts as the main view controller for the card scanner.
- Contains `CardScannerOverlayView`, which renders the mask and
highlights the region of interest.

## Next Steps
- [ ] Implement the assembler layer and SDK entry point.
- [ ] Add unit tests for `CardImageParser`.
- _The idea is to have a series of card images against we can write
automated tests._
- [ ] Add unit tests for the MVVM module.

# Ticket

**COIOS-826**
  • Loading branch information
nauaros authored Feb 24, 2025
2 parents aedfe69 + 24b809d commit 360e35d
Show file tree
Hide file tree
Showing 8 changed files with 632 additions and 1 deletion.
34 changes: 33 additions & 1 deletion Adyen.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@
B6EE0F422BBEBF7600B9810D /* APIClientMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E757F02525D2ED4C007C813D /* APIClientMock.swift */; };
B6EE0F442BBEC94F00B9810D /* AnalyticsProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C0005B280468E100CE2EEC /* AnalyticsProviderMock.swift */; };
B6EE0F452BBECBEF00B9810D /* Adyen.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2C0E03322097917008616F6 /* Adyen.framework */; platformFilter = ios; };
C9037F102D563DCD003A5154 /* CardScannerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9037F0F2D563DCD003A5154 /* CardScannerViewModel.swift */; };
C9037F122D5A1976003A5154 /* CardScannerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9037F112D5A1976003A5154 /* CardScannerOverlayView.swift */; };
C9037F142D5A19BD003A5154 /* ROIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9037F132D5A19BD003A5154 /* ROIView.swift */; };
C9037F162D5A1AC0003A5154 /* CardScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9037F152D5A1AC0003A5154 /* CardScannerViewController.swift */; };
C90D26A727565750001A488F /* FormViewController+ViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90D26A627565750001A488F /* FormViewController+ViewProtocol.swift */; };
C9126051275A3A2800C03DC4 /* BACSItemsFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9126050275A3A2800C03DC4 /* BACSItemsFactoryTests.swift */; };
C92665362C49346200D3D852 /* SubmittableComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92665352C49346200D3D852 /* SubmittableComponent.swift */; };
Expand All @@ -503,6 +507,7 @@
C927083E27590BDB00D15EA0 /* BACSInputFormViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C927083D27590BDB00D15EA0 /* BACSInputFormViewControllerTests.swift */; };
C927084027590FE800D15EA0 /* BACSInputPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C927083F27590FE800D15EA0 /* BACSInputPresenterTests.swift */; };
C92708422759106500D15EA0 /* BACSRouterProtocolMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92708412759106500D15EA0 /* BACSRouterProtocolMock.swift */; };
C92B807C2D5B69CF0020A099 /* AVCaptureVideoOrientation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92B807A2D5B69CF0020A099 /* AVCaptureVideoOrientation+Extensions.swift */; };
C92F6DBE26AFF336004BD516 /* PostalAddressMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92F6DBD26AFF336004BD516 /* PostalAddressMocks.swift */; };
C92F6DC126B04B63004BD516 /* XCTestCase+FormAddressItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92F6DC026B04B63004BD516 /* XCTestCase+FormAddressItem.swift */; };
C930FB6E269D79E0006A26D2 /* AffirmDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C930FB6D269D79E0006A26D2 /* AffirmDetails.swift */; };
Expand All @@ -513,6 +518,7 @@
C9454C37276A340B0086C218 /* BACSDirectDebitPresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9454C35276A33A00086C218 /* BACSDirectDebitPresentationDelegate.swift */; };
C9454C38276A34150086C218 /* BACSDirectDebitPresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9454C35276A33A00086C218 /* BACSDirectDebitPresentationDelegate.swift */; };
C94632BE27BA6985003DD81F /* AnalyticsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94632BD27BA6985003DD81F /* AnalyticsProvider.swift */; };
C94639D02D5F68BC004A1B0C /* CaptureSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94639CF2D5F68BC004A1B0C /* CaptureSessionManager.swift */; };
C95903DE275A48D000E7D3BC /* BACSDirectDebitComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95903DD275A48D000E7D3BC /* BACSDirectDebitComponentTests.swift */; };
C96688BF26A6FC1C00DC7297 /* AffirmComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96688BE26A6FC1C00DC7297 /* AffirmComponentTests.swift */; };
C96E07A3283B92E300345732 /* BACSDirectDebitComponentTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96E07A1283B92D500345732 /* BACSDirectDebitComponentTrackerTests.swift */; };
Expand Down Expand Up @@ -1838,6 +1844,10 @@
B6C9DA782D0C3F62005D65C7 /* DualBrandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DualBrandView.swift; sourceTree = "<group>"; };
B6C9DA7A2D102C91005D65C7 /* DualBrandViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DualBrandViewTests.swift; sourceTree = "<group>"; };
B6EE0F432BBEBFD000B9810D /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = IntegrationTests.xctestplan; sourceTree = "<group>"; };
C9037F0F2D563DCD003A5154 /* CardScannerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerViewModel.swift; sourceTree = "<group>"; };
C9037F112D5A1976003A5154 /* CardScannerOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerOverlayView.swift; sourceTree = "<group>"; };
C9037F132D5A19BD003A5154 /* ROIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ROIView.swift; sourceTree = "<group>"; };
C9037F152D5A1AC0003A5154 /* CardScannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScannerViewController.swift; sourceTree = "<group>"; };
C90D26A627565750001A488F /* FormViewController+ViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormViewController+ViewProtocol.swift"; sourceTree = "<group>"; };
C90D26B1275900B9001A488F /* BACSInputFormViewProtocolMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSInputFormViewProtocolMock.swift; sourceTree = "<group>"; };
C90D26B32759048C001A488F /* BACSInputPresenterProtocolMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSInputPresenterProtocolMock.swift; sourceTree = "<group>"; };
Expand All @@ -1847,6 +1857,7 @@
C927083D27590BDB00D15EA0 /* BACSInputFormViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSInputFormViewControllerTests.swift; sourceTree = "<group>"; };
C927083F27590FE800D15EA0 /* BACSInputPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSInputPresenterTests.swift; sourceTree = "<group>"; };
C92708412759106500D15EA0 /* BACSRouterProtocolMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSRouterProtocolMock.swift; sourceTree = "<group>"; };
C92B807A2D5B69CF0020A099 /* AVCaptureVideoOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVCaptureVideoOrientation+Extensions.swift"; sourceTree = "<group>"; };
C92F6DBD26AFF336004BD516 /* PostalAddressMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostalAddressMocks.swift; sourceTree = "<group>"; };
C92F6DC026B04B63004BD516 /* XCTestCase+FormAddressItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FormAddressItem.swift"; sourceTree = "<group>"; };
C930FB6D269D79E0006A26D2 /* AffirmDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffirmDetails.swift; sourceTree = "<group>"; };
Expand All @@ -1856,6 +1867,7 @@
C93B01B82760B06300D311A1 /* BACSConfirmationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSConfirmationPresenter.swift; sourceTree = "<group>"; };
C9454C35276A33A00086C218 /* BACSDirectDebitPresentationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSDirectDebitPresentationDelegate.swift; sourceTree = "<group>"; };
C94632BD27BA6985003DD81F /* AnalyticsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsProvider.swift; sourceTree = "<group>"; };
C94639CF2D5F68BC004A1B0C /* CaptureSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureSessionManager.swift; sourceTree = "<group>"; };
C95903DD275A48D000E7D3BC /* BACSDirectDebitComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BACSDirectDebitComponentTests.swift; sourceTree = "<group>"; };
C95C89312BF63A3500C47296 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C96688BE26A6FC1C00DC7297 /* AffirmComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffirmComponentTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3433,6 +3445,14 @@
path = Input;
sourceTree = "<group>";
};
C92B807B2D5B69CF0020A099 /* Extensions */ = {
isa = PBXGroup;
children = (
C92B807A2D5B69CF0020A099 /* AVCaptureVideoOrientation+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
C92F6DBC26AFF289004BD516 /* DummyData */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3570,9 +3590,15 @@
C99FF2B62D50E872007179C5 /* Sources */ = {
isa = PBXGroup;
children = (
C99FF2B52D50E872007179C5 /* CardImageParser.swift */,
C9037F112D5A1976003A5154 /* CardScannerOverlayView.swift */,
C9037F152D5A1AC0003A5154 /* CardScannerViewController.swift */,
C9037F0F2D563DCD003A5154 /* CardScannerViewModel.swift */,
C92B807B2D5B69CF0020A099 /* Extensions */,
C9EEEFEB2D5516B300B7AFFC /* Formatters */,
C99FF2B42D50E872007179C5 /* Models */,
C99FF2B52D50E872007179C5 /* CardImageParser.swift */,
C9037F132D5A19BD003A5154 /* ROIView.swift */,
C94639CF2D5F68BC004A1B0C /* CaptureSessionManager.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -6932,7 +6958,13 @@
files = (
C99FF2BB2D50E872007179C5 /* CardScannerError.swift in Sources */,
C99FF2BC2D50E872007179C5 /* CreditCard.swift in Sources */,
C9037F142D5A19BD003A5154 /* ROIView.swift in Sources */,
C9037F102D563DCD003A5154 /* CardScannerViewModel.swift in Sources */,
C9EEEFEC2D5516B300B7AFFC /* ExpirationDateFormatter.swift in Sources */,
C9037F162D5A1AC0003A5154 /* CardScannerViewController.swift in Sources */,
C9037F122D5A1976003A5154 /* CardScannerOverlayView.swift in Sources */,
C94639D02D5F68BC004A1B0C /* CaptureSessionManager.swift in Sources */,
C92B807C2D5B69CF0020A099 /* AVCaptureVideoOrientation+Extensions.swift in Sources */,
C99FF2BD2D50E872007179C5 /* CardImageParser.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
167 changes: 167 additions & 0 deletions AdyenCardScanner/Sources/CaptureSessionManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//
// Copyright (c) 2025 Adyen N.V.
//
// This file is open source and available under the MIT license. See the LICENSE file for more info.
//

import Foundation
import AVFoundation
import CoreImage

protocol CaptureSessionDelegate: AnyObject {
func didCapture(image: CIImage?)
}

protocol CaptureSessionManaging {
var delegate: CaptureSessionDelegate? { get set }
func configureSession()
func startCaptureSession()
func stopCaptureSession()
func updateVideoOrientation()
var videoPreviewLayer: AVCaptureVideoPreviewLayer { get }
}

class CaptureSessionManager: NSObject, CaptureSessionManaging {

enum Constants {
static let videoSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
static let captureDeviceMinFrameDuration = CMTime(value: 1, timescale: 15) // 15 fps
static let captureDeviceMaxFrameDuration = CMTime(value: 1, timescale: 30) // 30 fps
}

// MARK: - Properties

private let sessionQueue = DispatchQueue(label: "com.cardscanner.sessionQueue", qos: .userInitiated)
private let videoOutputQueue = DispatchQueue(label: "com.cardscanner.videoOutputQueue")

private let captureDevice: AVCaptureDevice
let videoPreviewLayer: AVCaptureVideoPreviewLayer
weak var delegate: CaptureSessionDelegate?

private let captureSession: AVCaptureSession = {
let session = AVCaptureSession()
session.sessionPreset = .photo
return session
}()

// MARK: - Initializers

init(captureDevice: AVCaptureDevice) {
self.captureDevice = captureDevice

self.videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
self.videoPreviewLayer.videoGravity = .resizeAspectFill
}

// MARK: - CaptureSessionManaging

func configureSession() {
sessionQueue.async {
try? self.configureCaptureSession()
}
}

func startCaptureSession() {
sessionQueue.async {
guard !self.captureSession.isRunning else { return }
self.captureSession.startRunning()
}
}

func stopCaptureSession() {
sessionQueue.async {
guard self.captureSession.isRunning else { return }
self.captureSession.stopRunning()
}
}

func updateVideoOrientation() {
guard
let connection = videoPreviewLayer.connection,
connection.isVideoOrientationSupported else {
return
}

connection.videoOrientation = .currentVideoOrientation
videoPreviewLayer.removeAllAnimations()
}

// MARK: - Private

private func configureCaptureSession() throws {
captureSession.beginConfiguration()

guard
let videoInput = try? AVCaptureDeviceInput(device: captureDevice),
captureSession.canAddInput(videoInput)
else {
throw CardScannerError(kind: .capture)
}

configureCaptureDevice(captureDevice)

captureSession.addInput(videoInput)

let videoOutput = AVCaptureVideoDataOutput()
let videoSettings = Constants.videoSettings
videoOutput.videoSettings = videoSettings
videoOutput.setSampleBufferDelegate(self, queue: videoOutputQueue)

guard captureSession.canAddOutput(videoOutput) else {
let cardScannerError = CardScannerError(kind: .capture)
throw cardScannerError
}

captureSession.addOutput(videoOutput)

guard
let connection = videoOutput.connection(with: .video),
connection.isVideoStabilizationSupported else {
let cardScannerError = CardScannerError(kind: .capture)
throw cardScannerError
}
connection.preferredVideoStabilizationMode = .auto

captureSession.commitConfiguration()
}

private func configureCaptureDevice(_ device: AVCaptureDevice) {
do {
try device.lockForConfiguration()

device.activeVideoMinFrameDuration = Constants.captureDeviceMinFrameDuration
device.activeVideoMaxFrameDuration = Constants.captureDeviceMaxFrameDuration

if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}

if device.isExposureModeSupported(.continuousAutoExposure) {
device.exposureMode = .continuousAutoExposure
}

device.unlockForConfiguration()
} catch {
// Intentional empty error handling.
// The card scanning can continue even if the capture device is not configured.
}
}
}

extension CaptureSessionManager: AVCaptureVideoDataOutputSampleBufferDelegate {

func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
if let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) {
let image = CIImage(cvImageBuffer: imageBuffer)
delegate?.didCapture(image: image)
} else {
delegate?.didCapture(image: nil)
}
}
}
109 changes: 109 additions & 0 deletions AdyenCardScanner/Sources/CardScannerOverlayView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// Copyright (c) 2025 Adyen N.V.
//
// This file is open source and available under the MIT license. See the LICENSE file for more info.
//

import UIKit

class CardScannerOverlayView: UIView {

enum Style {
static let backgroundColor = UIColor.black.withAlphaComponent(0.4)
}

enum Constants {
static let roiAspectRatio: CGFloat = 1.585 // Credit card aspect ratio
}

// MARK: - UI elements

private let topMask = UIView()
private let bottomMask = UIView()
private let leftMask = UIView()

private let rightMask = UIView()
private let roiView: ROIView = {
let view = ROIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = Style.backgroundColor
return view
}()

// MARK: - Properties

@Published private(set) var roiFrame: CGRect = .zero

// MARK: - Initializers

override init(frame: CGRect) {
super.init(frame: frame)
setupMaskViews()
setupMasksLayout()
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func layoutSubviews() {
super.layoutSubviews()
updateRoiLayout()
self.roiFrame = roiView.frame
}

// MARK: - Private

private func setupMaskViews() {
let maskViews = [topMask, bottomMask, leftMask, rightMask]
maskViews.forEach {
$0.backgroundColor = Style.backgroundColor
$0.translatesAutoresizingMaskIntoConstraints = false
addSubview($0)
}

addSubview(roiView)
}

private func setupMasksLayout() {
NSLayoutConstraint.activate([
topMask.topAnchor.constraint(equalTo: topAnchor),
topMask.leadingAnchor.constraint(equalTo: leadingAnchor),
topMask.trailingAnchor.constraint(equalTo: trailingAnchor),
topMask.bottomAnchor.constraint(equalTo: roiView.topAnchor),

bottomMask.topAnchor.constraint(equalTo: roiView.bottomAnchor),
bottomMask.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomMask.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomMask.bottomAnchor.constraint(equalTo: bottomAnchor),

leftMask.topAnchor.constraint(equalTo: topMask.bottomAnchor),
leftMask.leadingAnchor.constraint(equalTo: leadingAnchor),
leftMask.trailingAnchor.constraint(equalTo: roiView.leadingAnchor),
leftMask.bottomAnchor.constraint(equalTo: bottomMask.topAnchor),

rightMask.topAnchor.constraint(equalTo: topMask.bottomAnchor),
rightMask.leadingAnchor.constraint(equalTo: roiView.trailingAnchor),
rightMask.trailingAnchor.constraint(equalTo: trailingAnchor),
rightMask.bottomAnchor.constraint(equalTo: bottomMask.topAnchor)
])
}

private func updateRoiLayout() {
// Deactivate old constraints
NSLayoutConstraint.deactivate(roiView.constraints)

let padding: CGFloat = 12

let roiWidth = min(bounds.width, bounds.height) - (padding * 2)
let roiHeightMultiplier = 1.0 / Constants.roiAspectRatio

NSLayoutConstraint.activate([
roiView.centerXAnchor.constraint(equalTo: centerXAnchor),
roiView.centerYAnchor.constraint(equalTo: centerYAnchor),
roiView.widthAnchor.constraint(equalToConstant: roiWidth),
roiView.heightAnchor.constraint(equalTo: roiView.widthAnchor, multiplier: roiHeightMultiplier)
])
}
}
Loading

0 comments on commit 360e35d

Please sign in to comment.