diff --git a/Scripts/Tests/Run.sh b/Scripts/Tests/Run.sh index ff899332e..35958ed39 100755 --- a/Scripts/Tests/Run.sh +++ b/Scripts/Tests/Run.sh @@ -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 \ diff --git a/Sources/ProcessOutUI/Resources/Localizable.xcstrings b/Sources/ProcessOutUI/Resources/Localizable.xcstrings index 48ec28ff6..fada51115 100644 --- a/Sources/ProcessOutUI/Resources/Localizable.xcstrings +++ b/Sources/ProcessOutUI/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Configuration/POCardScannerConfiguration.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Configuration/POCardScannerConfiguration.swift index 7579427af..5b00e358e 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Configuration/POCardScannerConfiguration.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Configuration/POCardScannerConfiguration.swift @@ -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? @@ -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? @@ -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 } diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractor.swift index 7e10f2543..418471c7a 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractor.swift @@ -12,4 +12,7 @@ protocol CardScannerInteractor: BaseInteractor { /// Enables or disables torch based on given value. func setTorchEnabled(_ isEnabled: Bool) + + /// Opens system application settings. + func openApplicationSetting() } diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractorState.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractorState.swift index 791f7f70e..4e004334a 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractorState.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/CardScannerInteractorState.swift @@ -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 @@ -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) } diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/DefaultCardScannerInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/DefaultCardScannerInteractor.swift index 9210cb22e..a693df98b 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/DefaultCardScannerInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Interactor/DefaultCardScannerInteractor.swift @@ -5,6 +5,7 @@ // Created by Andrii Vysotskyi on 25.11.2024. // +import class UIKit.UIApplication import AVFoundation @_spi(PO) import ProcessOut @@ -32,10 +33,18 @@ final class DefaultCardScannerInteractor: BaseInteractor (isAuthorized: Bool, AVAuthorizationStatus) + /// Starts camera session. @discardableResult func start() async -> Bool diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Sessions/Camera/DefaultCameraSession.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Sessions/Camera/DefaultCameraSession.swift index 341b6ca8e..3fc59086a 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Sessions/Camera/DefaultCameraSession.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Sessions/Camera/DefaultCameraSession.swift @@ -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 { @@ -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 { diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Symbols/StringResource+CardScanner.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Symbols/StringResource+CardScanner.swift index e869f95e4..5cc95509f 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/Symbols/StringResource+CardScanner.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/Symbols/StringResource+CardScanner.swift @@ -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: "") diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/View/POCardScannerView.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/View/POCardScannerView.swift index c5697683e..9e8972a11 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/View/POCardScannerView.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/View/POCardScannerView.swift @@ -25,6 +25,7 @@ public struct POCardScannerView: View { AnyView( style.makeBody(configuration: styleConfiguration) ) + .poConfirmationDialog(item: $viewModel.state.confirmationDialog) .onAppear(perform: viewModel.start) } diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/CardScannerViewModelState.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/CardScannerViewModelState.swift index 867b85e74..8c147aa05 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/CardScannerViewModelState.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/CardScannerViewModelState.swift @@ -46,6 +46,9 @@ struct CardScannerViewModelState { /// Cancel button. let cancelButton: POButtonViewModel? + + /// Confirmation dialog to present to user. + var confirmationDialog: POConfirmationDialog? } extension CardScannerViewModelState: AnimationIdentityProvider { diff --git a/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/DefaultCardScannerViewModel.swift b/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/DefaultCardScannerViewModel.swift index 1fe72b5b6..fdd881ed5 100644 --- a/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/DefaultCardScannerViewModel.swift +++ b/Sources/ProcessOutUI/Sources/Modules/CardScanner/ViewModel/DefaultCardScannerViewModel.swift @@ -54,6 +54,8 @@ final class DefaultCardScannerViewModel: ViewModel { updateWithStartingState() case .started(let currentState): update(with: currentState) + case .notAuthorized(let currentState): + update(with: currentState) case .completed: return } @@ -66,7 +68,8 @@ final class DefaultCardScannerViewModel: ViewModel { isTorchEnabled: .constant(false), preview: .init(source: nil), recognizedCard: nil, - cancelButton: cancelButtonViewModel + cancelButton: cancelButtonViewModel, + confirmationDialog: nil ) } @@ -84,7 +87,20 @@ final class DefaultCardScannerViewModel: ViewModel { ), preview: .init(source: startedState.previewSource), recognizedCard: cardViewModel(with: startedState.card), - cancelButton: cancelButtonViewModel + cancelButton: cancelButtonViewModel, + confirmationDialog: nil + ) + } + + private func update(with notAuthorizedState: CardScannerInteractorState.NotAuthorized) { + state = .init( + title: title, + description: description, + isTorchEnabled: .constant(false), + preview: .init(source: nil), + recognizedCard: nil, + cancelButton: cancelButtonViewModel, + confirmationDialog: deniedCameraAuthroizationConfirmation(isRestricted: notAuthorizedState.isRestricted) ) } @@ -137,4 +153,46 @@ final class DefaultCardScannerViewModel: ViewModel { } return .init(number: card.number, expiration: card.expiration?.description, cardholderName: card.cardholderName) } + + private func deniedCameraAuthroizationConfirmation(isRestricted: Bool) -> POConfirmationDialog? { + guard let configuration = interactor.configuration.deniedCameraAuthorization else { + return nil + } + let secondaryButton: POConfirmationDialog.Button? + if !isRestricted, configuration.shouldSuggestAuthorizationChange { + secondaryButton = .init( + title: configuration.confirmation.confirmActionTitle ?? String( + resource: .CardScanner.DeniedAuthorization.openSettings, + configuration: interactor.configuration.localization + ), + action: { [weak self] in + self?.interactor.openApplicationSetting() + } + ) + } else { + secondaryButton = nil + } + let confirmationDialog = POConfirmationDialog( + title: configuration.confirmation.title ?? String( + resource: .CardScanner.DeniedAuthorization.title, + configuration: interactor.configuration.localization + ), + message: configuration.confirmation.message ?? String( + resource: .CardScanner.DeniedAuthorization.message, + configuration: interactor.configuration.localization + ), + primaryButton: .init( + title: configuration.confirmation.cancelActionTitle ?? String( + resource: .CardScanner.DeniedAuthorization.cancel, + configuration: interactor.configuration.localization + ), + role: .cancel, + action: { [weak self] in + self?.interactor.cancel() + } + ), + secondaryButton: secondaryButton + ) + return confirmationDialog + } } diff --git a/Tests/ProcessOutUITests/Sources/Mocks/CardScanner/MockCameraSession.swift b/Tests/ProcessOutUITests/Sources/Mocks/CardScanner/MockCameraSession.swift index cde6a02a5..1af991b33 100644 --- a/Tests/ProcessOutUITests/Sources/Mocks/CardScanner/MockCameraSession.swift +++ b/Tests/ProcessOutUITests/Sources/Mocks/CardScanner/MockCameraSession.swift @@ -16,6 +16,10 @@ actor MockCameraSession: CameraSession { // MARK: - CameraSession + func requestAccess() async -> (isAuthorized: Bool, AVAuthorizationStatus) { + (isAuthorized: false, .denied) + } + func start() async -> Bool { startCallsCount += 1 return await startFromClosure()