Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Scripts/Tests/Run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ PROJECT='ProcessOut.xcodeproj'
DESTINATION=$(./Scripts/Tests/Destination.swift)

# Run Tests
for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutCheckout3DS" "ProcessOutNetcetera3DS"; do
for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutNetcetera3DS"; do
xcodebuild clean test \
-destination "$DESTINATION" \
-project $PROJECT \
Expand Down
136 changes: 136 additions & 0 deletions Sources/ProcessOutUI/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,142 @@
}
}
},
"card-scanner.denied-authorization.cancel" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "إضافة يدويًا"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add manually"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajouter manuellement"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dodaj ręcznie"
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicionar manualmente"
}
}
}
},
"card-scanner.denied-authorization.message" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "الوصول إلى الكاميرا مطلوب لمسح بطاقتك. يرجى تعديل إعداداتك أو إدخال التفاصيل يدويًا."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Camera permission is required to scan your card. Please adjust your settings or enter the details manually."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "L'accès à la caméra est nécessaire pour scanner votre carte. Veuillez ajuster vos paramètres ou saisir les informations manuellement."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dostęp do kamery jest wymagany do zeskanowania karty. Proszę dostosować ustawienia lub wprowadzić dane ręcznie."
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "O acesso à câmera é necessário para escanear o seu cartão. Por favor, ajuste suas configurações ou insira os dados manualmente."
}
}
}
},
"card-scanner.denied-authorization.open-settings" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "فتح الإعدادات"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open settings"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ouvrir les paramètres"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otwórz ustawienia"
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abrir configurações"
}
}
}
},
"card-scanner.denied-authorization.title" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "تم رفض الوصول إلى الكاميرا"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Camera access denied"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Accès à la caméra refusé"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Odrzucono dostęp do kamery"
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "Acesso à câmera negado"
}
}
}
},
"card-scanner.description" : {
"localizations" : {
"ar" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ public struct POCardScannerConfiguration {
}
}

public struct DeniedCameraAuthorization {

public init(
confirmation: POConfirmationDialogConfiguration = .init(),
shouldSuggestAuthorizationChange: Bool = true
) {
self.confirmation = confirmation
self.shouldSuggestAuthorizationChange = shouldSuggestAuthorizationChange
}

/// Dialog to display when authorization is denied.
public let confirmation: POConfirmationDialogConfiguration

/// Boolean flag indicating whether user should be suggested to change camera authorization
/// via settings when possible.
///
/// - Warning: When authorization is changed via settings, system kills application process,
/// so make sure your app supports state restoration.
///
/// Default value is `false`.
public let shouldSuggestAuthorizationChange: Bool
}

/// Custom title. Use empty string to hide title.
public let title: String?

Expand All @@ -47,6 +70,9 @@ public struct POCardScannerConfiguration {
/// Default value is `false`.
public let shouldScanExpiredCard: Bool

/// Denied camera authorization configuration.
public let deniedCameraAuthorization: DeniedCameraAuthorization?

/// Cancel button configuration.
public let cancelButton: CancelButton?

Expand All @@ -57,12 +83,14 @@ public struct POCardScannerConfiguration {
title: String? = nil,
description: String? = nil,
shouldScanExpiredCard: Bool = false,
deniedCameraAuthorization: DeniedCameraAuthorization? = .init(),
cancelButton: CancelButton? = .init(),
localization: LocalizationConfiguration = .device()
) {
self.title = title
self.description = description
self.shouldScanExpiredCard = shouldScanExpiredCard
self.deniedCameraAuthorization = deniedCameraAuthorization
self.cancelButton = cancelButton
self.localization = localization
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ protocol CardScannerInteractor: BaseInteractor<CardScannerInteractorState> {

/// Enables or disables torch based on given value.
func setTorchEnabled(_ isEnabled: Bool)

/// Opens system application settings.
func openApplicationSetting()
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ enum CardScannerInteractorState: InteractorState {
var card: POScannedCard?
}

struct NotAuthorized {

/// Indicates whether app is permitted to use media capture devices.
let isRestricted: Bool
}

/// Idle state.
case idle

Expand All @@ -43,6 +49,9 @@ enum CardScannerInteractorState: InteractorState {
/// Started state.
case started(Started)

/// Indicates that user is not authorized to use camera.
case notAuthorized(NotAuthorized)

/// Completed state.
case completed(Result<POScannedCard, POFailure>)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Andrii Vysotskyi on 25.11.2024.
//

import class UIKit.UIApplication
import AVFoundation
@_spi(PO) import ProcessOut

Expand Down Expand Up @@ -32,10 +33,18 @@ final class DefaultCardScannerInteractor: BaseInteractor<CardScannerInteractorSt
let configuration: POCardScannerConfiguration

override func start() {
guard case .idle = state else {
switch state {
case .idle, .notAuthorized:
break
default:
return
}
Task { @MainActor in
let (isCameraAccessAuthorized, cameraAuthorizationStatus) = await cameraSession.requestAccess()
guard isCameraAccessAuthorized else {
setNotAuthorizedState(cameraAuthorizationStatus: cameraAuthorizationStatus)
return
}
await cardRecognitionSession.setDelegate(self)
if await cameraSession.start() {
await cardRecognitionSession.setCameraSession(cameraSession)
Expand Down Expand Up @@ -69,6 +78,12 @@ final class DefaultCardScannerInteractor: BaseInteractor<CardScannerInteractorSt
state = .started(newState)
}

func openApplicationSetting() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}

// MARK: - Private Properties

private let cameraSession: CameraSession
Expand Down Expand Up @@ -114,6 +129,14 @@ final class DefaultCardScannerInteractor: BaseInteractor<CardScannerInteractorSt
stopSessions()
}

private func setNotAuthorizedState(cameraAuthorizationStatus: AVAuthorizationStatus) {
guard case .starting = state else {
return
}
let newState = CardScannerInteractorState.NotAuthorized(isRestricted: cameraAuthorizationStatus == .restricted)
state = .notAuthorized(newState)
}

// MARK: - Torch

private func enableTorch(_ isEnabled: Bool) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import CoreImage
/// A session that manages the capture pipeline, which includes the capture session, device inputs, and capture outputs.
protocol CameraSession: Sendable, AnyObject {

/// Requests access to capture device if needed and return authorization status.
func requestAccess() async -> (isAuthorized: Bool, AVAuthorizationStatus)

/// Starts camera session.
@discardableResult
func start() async -> Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,27 @@ actor DefaultCameraSession:

// MARK: - CameraSession

func requestAccess() async -> (isAuthorized: Bool, AVAuthorizationStatus) {
let isAuthorized: Bool, mediaType = AVMediaType.video
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
isAuthorized = true
case .denied, .restricted:
isAuthorized = false
case .notDetermined:
// If the system hasn't determined their authorization status,
// explicitly prompt them for approval.
isAuthorized = await AVCaptureDevice.requestAccess(for: mediaType)
@unknown default:
isAuthorized = false
}
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
return (isAuthorized, status)
}

@discardableResult
func start() async -> Bool {
guard await isAuthorized else {
guard await requestAccess().isAuthorized else {
return false
}
guard !captureSession.isRunning else {
Expand Down Expand Up @@ -158,26 +176,6 @@ actor DefaultCameraSession:
/// Boolean value indicating whether new video frames should be discarded.
private nonisolated(unsafe) var shouldDiscardVideoFrames = POUnfairlyLocked(wrappedValue: false)

// MARK: - Authorization

/// A Boolean value that indicates whether a user authorizes this app to use device cameras.
private var isAuthorized: Bool {
get async {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
// If the system hasn't determined their authorization status,
// explicitly prompt them for approval.
return await AVCaptureDevice.requestAccess(for: .video)
@unknown default:
return false
}
}
}

// MARK: - Session Configuration

private func configureSession() -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ extension POStringResource {

enum CardScanner {

enum DeniedAuthorization { // swiftlint:disable:this nesting

/// Confirmation title.
static let title = POStringResource("card-scanner.denied-authorization.title", comment: "")

/// Confirmation message.
static let message = POStringResource("card-scanner.denied-authorization.message", comment: "")

/// Open settings button title..
static let openSettings = POStringResource("card-scanner.denied-authorization.open-settings", comment: "")

/// Cancel button title.
static let cancel = POStringResource("card-scanner.denied-authorization.cancel", comment: "")
}

/// Card scanner title.
static let title = POStringResource("card-scanner.title", comment: "")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public struct POCardScannerView: View {
AnyView(
style.makeBody(configuration: styleConfiguration)
)
.poConfirmationDialog(item: $viewModel.state.confirmationDialog)
.onAppear(perform: viewModel.start)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ struct CardScannerViewModelState {

/// Cancel button.
let cancelButton: POButtonViewModel?

/// Confirmation dialog to present to user.
var confirmationDialog: POConfirmationDialog?
}

extension CardScannerViewModelState: AnimationIdentityProvider {
Expand Down
Loading