-
Notifications
You must be signed in to change notification settings - Fork 129
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Card scanner module - Business layer (#2008)
# 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
Showing
8 changed files
with
632 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
]) | ||
} | ||
} |
Oops, something went wrong.