diff --git a/.github/workflows/auto-author-assign.yml b/.github/workflows/auto-author-assign.yml new file mode 100644 index 00000000..7a7439bc --- /dev/null +++ b/.github/workflows/auto-author-assign.yml @@ -0,0 +1,16 @@ +# .github/workflows/auto-author-assign.yml +name: Auto Author Assign + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + assign-author: + if: ${{ !contains(github.event.pull_request.assignees, '') }} + runs-on: ubuntu-latest + steps: + - uses: toshimaru/auto-author-assign@v2.1.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0024d0..126fb736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Release Notes +## 10.2.17 +### Added skipApiSubmission: Whether to skip api submission to SmileID and return only captured images on SmartSelfie enrollment, SmartSelfie authentic , Document verification and Enhanced DocV + ## 10.2.16 ### Fixed * Clear images on retry or start capture with the same jobId diff --git a/Example/Podfile.lock b/Example/Podfile.lock index e05e36e9..aa431d6b 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -12,7 +12,7 @@ PODS: - Sentry (8.36.0): - Sentry/Core (= 8.36.0) - Sentry/Core (8.36.0) - - SmileID (10.2.16): + - SmileID (10.2.17): - FingerprintJS - lottie-ios (~> 4.4.2) - ZIPFoundation (~> 0.9) @@ -51,7 +51,7 @@ SPEC CHECKSUMS: lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418 netfox: 9d5cc727fe7576c4c7688a2504618a156b7d44b7 Sentry: f8374b5415bc38dfb5645941b3ae31230fbeae57 - SmileID: 93184d185549dec6858a3cc567bd9423de79abbb + SmileID: dc04628f6e1572fc6e407649bfd05f91647ed947 SwiftLint: 3fe909719babe5537c552ee8181c0031392be933 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c diff --git a/Example/SmileID.xcodeproj/project.pbxproj b/Example/SmileID.xcodeproj/project.pbxproj index ebf5b21c..ef649f15 100644 --- a/Example/SmileID.xcodeproj/project.pbxproj +++ b/Example/SmileID.xcodeproj/project.pbxproj @@ -891,7 +891,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99P7YGX9Q6; @@ -901,7 +901,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "com.smileidentity.example-ios"; PRODUCT_NAME = "Smile ID"; @@ -924,7 +924,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99P7YGX9Q6; @@ -934,7 +934,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "com.smileidentity.example-ios"; PRODUCT_NAME = "Smile ID"; diff --git a/Example/SmileID/Home/HomeView.swift b/Example/SmileID/Home/HomeView.swift index 43029189..bed74ca4 100644 --- a/Example/SmileID/Home/HomeView.swift +++ b/Example/SmileID/Home/HomeView.swift @@ -54,7 +54,7 @@ struct HomeView: View { ) ProductCell( image: "smart_selfie_enroll", - name: "SmartSelfie™ Enrollment (Strict Mode)", + name: "SmartSelfie™ Enrollment (Strict Mode)\n(BETA)", onClick: { viewModel.onProductClicked() }, @@ -74,7 +74,7 @@ struct HomeView: View { ) ProductCell( image: "smart_selfie_authentication", - name: "SmartSelfie™ Authentication (Strict Mode)", + name: "SmartSelfie™ Authentication (Strict Mode)\n(BETA)", onClick: { viewModel.onProductClicked() }, diff --git a/Example/SmileID/Home/ProductCell.swift b/Example/SmileID/Home/ProductCell.swift index a25967ce..d8283b8f 100644 --- a/Example/SmileID/Home/ProductCell.swift +++ b/Example/SmileID/Home/ProductCell.swift @@ -46,6 +46,16 @@ struct ProductCell: View { content: { NavigationView { content() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + isPresented = false + } label: { + Text(SmileIDResourcesHelper.localizedString(for: "Action.Cancel")) + .foregroundColor(SmileID.theme.accent) + } + } + } } .environment(\.modalMode, $isPresented) } diff --git a/Example/Tests/FaceValidatorTests.swift b/Example/Tests/FaceValidatorTests.swift index a345a42f..46de33a7 100644 --- a/Example/Tests/FaceValidatorTests.swift +++ b/Example/Tests/FaceValidatorTests.swift @@ -24,8 +24,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithValidFace() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), - brighness: 100 + selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9) ) XCTAssertTrue(result.faceInBounds) @@ -36,8 +35,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithFaceTooSmall() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 100, height: 100), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), - brighness: 100 + selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9) ) XCTAssertFalse(result.faceInBounds) @@ -48,8 +46,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithFaceTooLarge() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 250, height: 250), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), - brighness: 100 + selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9) ) XCTAssertFalse(result.faceInBounds) @@ -60,8 +57,7 @@ class FaceValidatorTests: XCTestCase { func testValidWithFaceOffCentre() { let result = performValidation( faceBoundingBox: CGRect(x: 125, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), - brighness: 100 + selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9) ) XCTAssertFalse(result.faceInBounds) @@ -69,23 +65,10 @@ class FaceValidatorTests: XCTestCase { XCTAssertEqual(result.userInstruction, .headInFrame) } - func testValidateWithPoorBrightness() { - let result = performValidation( - faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), - brighness: 70 - ) - - XCTAssertTrue(result.faceInBounds) - XCTAssertFalse(result.hasDetectedValidFace) - XCTAssertEqual(result.userInstruction, .goodLight) - } - func testValidateWithPoorSelfieQuality() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.6, passed: 0.4), - brighness: 70 + selfieQualityData: SelfieQualityData(failed: 0.6, passed: 0.4) ) XCTAssertTrue(result.faceInBounds) @@ -97,7 +80,6 @@ class FaceValidatorTests: XCTestCase { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), selfieQualityData: SelfieQualityData(failed: 0.3, passed: 0.7), - brighness: 100, livenessTask: .lookLeft ) @@ -112,7 +94,6 @@ extension FaceValidatorTests { func performValidation( faceBoundingBox: CGRect, selfieQualityData: SelfieQualityData, - brighness: Int, livenessTask: LivenessTask? = nil ) -> FaceValidationResult { let faceGeometry = FaceGeometryData( @@ -125,7 +106,6 @@ extension FaceValidatorTests { faceValidator.validate( faceGeometry: faceGeometry, selfieQuality: selfieQualityData, - brightness: brighness, currentLivenessTask: livenessTask ) diff --git a/Package.resolved b/Package.resolved index 1d8b42aa..713e1c05 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "fingerprintjs-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fingerprintjs/fingerprintjs-ios", + "state" : { + "revision" : "bd93291c149e328919a9a2881575494f6ea9245f", + "version" : "1.5.0" + } + }, { "identity" : "lottie-spm", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 7a80b14d..0768a9a2 100644 --- a/Package.swift +++ b/Package.swift @@ -15,12 +15,16 @@ let package = Package( dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")), .package(url: "https://github.com/airbnb/lottie-spm", from: "4.4.2"), - .package(url: "https://github.com/fingerprintjs/fingerprintjs-ios", from: "4.4.2") + .package(url: "https://github.com/fingerprintjs/fingerprintjs-ios", from: "1.5.0") ], targets: [ .target( name: "SmileID", - dependencies: ["ZIPFoundation", "FingerprintJS", .product(name: "Lottie", package: "lottie-spm")], + dependencies: [ + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "FingerprintJS", package: "fingerprintjs-ios"), + .product(name: "Lottie", package: "lottie-spm") + ], path: "Sources/SmileID", resources: [.process("Resources")] ), diff --git a/SmileID.podspec b/SmileID.podspec index 76ff6f57..9f3375ed 100644 --- a/SmileID.podspec +++ b/SmileID.podspec @@ -1,11 +1,11 @@ Pod::Spec.new do |s| s.name = 'SmileID' - s.version = '10.2.16' + s.version = '10.2.17' s.summary = 'The Official Smile Identity iOS SDK.' s.homepage = 'https://docs.usesmileid.com/integration-options/mobile/ios-v10-beta' s.license = { :type => 'MIT', :file => 'LICENSE' } s.author = { 'Japhet' => 'japhet@usesmileid.com', 'Juma Allan' => 'juma@usesmileid.com', 'Vansh Gandhi' => 'vansh@usesmileid.com'} - s.source = { :git => "https://github.com/smileidentity/ios.git", :tag => "v10.2.16" } + s.source = { :git => "https://github.com/smileidentity/ios.git", :tag => "v10.2.17" } s.ios.deployment_target = '13.0' s.dependency 'ZIPFoundation', '~> 0.9' s.dependency 'FingerprintJS' diff --git a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift index b54ec201..75425e7a 100644 --- a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift +++ b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift @@ -8,26 +8,27 @@ enum DocumentCaptureFlow: Equatable { case processing(ProcessingState) } -internal class IOrchestratedDocumentVerificationViewModel: ObservableObject { +class IOrchestratedDocumentVerificationViewModel: ObservableObject { // Input properties - internal let userId: String - internal let jobId: String - internal let allowNewEnroll: Bool - internal let countryCode: String - internal let documentType: String? - internal let captureBothSides: Bool - internal let jobType: JobType - internal let extraPartnerParams: [String: String] + let userId: String + let jobId: String + let allowNewEnroll: Bool + let countryCode: String + let documentType: String? + let captureBothSides: Bool + let skipApiSubmission: Bool + let jobType: JobType + let extraPartnerParams: [String: String] // Other properties - internal var documentFrontFile: Data? - internal var documentBackFile: Data? - internal var selfieFile: URL? - internal var livenessFiles: [URL]? - internal var savedFiles: DocumentCaptureResultStore? - internal var stepToRetry: DocumentCaptureFlow? - internal var didSubmitJob: Bool = false - internal var error: Error? + var documentFrontFile: Data? + var documentBackFile: Data? + var selfieFile: URL? + var livenessFiles: [URL]? + var savedFiles: DocumentCaptureResultStore? + var stepToRetry: DocumentCaptureFlow? + var didSubmitJob: Bool = false + var error: Error? var localMetadata: LocalMetadata // UI properties @@ -39,13 +40,14 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse @Published var errorMessage: String? @Published var step = DocumentCaptureFlow.frontDocumentCapture - internal init( + init( userId: String, jobId: String, allowNewEnroll: Bool, countryCode: String, documentType: String?, captureBothSides: Bool, + skipApiSubmission: Bool, selfieFile: URL?, jobType: JobType, extraPartnerParams: [String: String] = [:], @@ -57,6 +59,7 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse self.countryCode = countryCode self.documentType = documentType self.captureBothSides = captureBothSides + self.skipApiSubmission = skipApiSubmission self.selfieFile = selfieFile self.jobType = jobType self.extraPartnerParams = extraPartnerParams @@ -177,6 +180,10 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse selfie: selfieFile, livenessImages: livenessFiles ?? [] ) + if skipApiSubmission { + DispatchQueue.main.async { self.step = .processing(.success) } + return + } let authRequest = AuthenticationRequest( jobType: jobType, enrollment: false, @@ -197,7 +204,7 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse let authResponse = try await SmileID.api.authenticate(request: authRequest) let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: self.extraPartnerParams), - allowNewEnroll: String(allowNewEnroll), // TODO: - Fix when Michael changes this to boolean + allowNewEnroll: String(allowNewEnroll), // TODO: - Fix when Michael changes this to boolean metadata: localMetadata.metadata.items, timestamp: authResponse.timestamp, signature: authResponse.signature @@ -321,14 +328,14 @@ extension IOrchestratedDocumentVerificationViewModel: SmartSelfieResultDelegate } // swiftlint:disable opening_brace -internal class OrchestratedDocumentVerificationViewModel: +class OrchestratedDocumentVerificationViewModel: IOrchestratedDocumentVerificationViewModel { override func onFinished(delegate: DocumentVerificationResultDelegate) { if let savedFiles, - let selfiePath = getRelativePath(from: selfieFile), - let documentFrontPath = getRelativePath(from: savedFiles.documentFront), - let documentBackPath = getRelativePath(from: savedFiles.documentBack) + let selfiePath = getRelativePath(from: selfieFile), + let documentFrontPath = getRelativePath(from: savedFiles.documentFront), + let documentBackPath = getRelativePath(from: savedFiles.documentBack) { delegate.didSucceed( selfie: selfiePath, @@ -347,16 +354,16 @@ internal class OrchestratedDocumentVerificationViewModel: } // swiftlint:disable opening_brace -internal class OrchestratedEnhancedDocumentVerificationViewModel: +class OrchestratedEnhancedDocumentVerificationViewModel: IOrchestratedDocumentVerificationViewModel< EnhancedDocumentVerificationResultDelegate, EnhancedDocumentVerificationJobResult > { override func onFinished(delegate: EnhancedDocumentVerificationResultDelegate) { if let savedFiles, - let selfiePath = getRelativePath(from: selfieFile), - let documentFrontPath = getRelativePath(from: savedFiles.documentFront), - let documentBackPath = getRelativePath(from: savedFiles.documentBack) + let selfiePath = getRelativePath(from: selfieFile), + let documentFrontPath = getRelativePath(from: savedFiles.documentFront), + let documentBackPath = getRelativePath(from: savedFiles.documentBack) { delegate.didSucceed( selfie: selfiePath, diff --git a/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift b/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift index 648b81cf..c6695876 100644 --- a/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift +++ b/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift @@ -14,9 +14,10 @@ struct OrchestratedDocumentVerificationScreen: View { let allowGalleryUpload: Bool let allowAgentMode: Bool let showInstructions: Bool + let skipApiSubmission: Bool let extraPartnerParams: [String: String] let onResult: DocumentVerificationResultDelegate - + var body: some View { IOrchestratedDocumentVerificationScreen( countryCode: countryCode, @@ -31,6 +32,7 @@ struct OrchestratedDocumentVerificationScreen: View { allowGalleryUpload: allowGalleryUpload, allowAgentMode: allowAgentMode, showInstructions: showInstructions, + skipApiSubmission: skipApiSubmission, extraPartnerParams: extraPartnerParams, onResult: onResult, viewModel: OrchestratedDocumentVerificationViewModel( @@ -40,6 +42,7 @@ struct OrchestratedDocumentVerificationScreen: View { countryCode: countryCode, documentType: documentType, captureBothSides: captureBothSides, + skipApiSubmission: skipApiSubmission, selfieFile: bypassSelfieCaptureWithFile, jobType: .documentVerification, extraPartnerParams: extraPartnerParams, @@ -63,9 +66,10 @@ struct OrchestratedEnhancedDocumentVerificationScreen: View { let allowGalleryUpload: Bool let allowAgentMode: Bool let showInstructions: Bool + let skipApiSubmission: Bool let extraPartnerParams: [String: String] let onResult: EnhancedDocumentVerificationResultDelegate - + var body: some View { IOrchestratedDocumentVerificationScreen( countryCode: countryCode, @@ -80,6 +84,7 @@ struct OrchestratedEnhancedDocumentVerificationScreen: View { allowGalleryUpload: allowGalleryUpload, allowAgentMode: allowAgentMode, showInstructions: showInstructions, + skipApiSubmission: skipApiSubmission, extraPartnerParams: extraPartnerParams, onResult: onResult, viewModel: OrchestratedEnhancedDocumentVerificationViewModel( @@ -89,6 +94,7 @@ struct OrchestratedEnhancedDocumentVerificationScreen: View { countryCode: countryCode, documentType: documentType, captureBothSides: captureBothSides, + skipApiSubmission: skipApiSubmission, selfieFile: bypassSelfieCaptureWithFile, jobType: .enhancedDocumentVerification, extraPartnerParams: extraPartnerParams, @@ -111,10 +117,11 @@ private struct IOrchestratedDocumentVerificationScreen: View { let allowGalleryUpload: Bool let allowAgentMode: Bool let showInstructions: Bool + let skipApiSubmission: Bool var extraPartnerParams: [String: String] let onResult: T @ObservedObject var viewModel: IOrchestratedDocumentVerificationViewModel - + init( countryCode: String, documentType: String?, @@ -128,6 +135,7 @@ private struct IOrchestratedDocumentVerificationScreen: View { allowGalleryUpload: Bool, allowAgentMode: Bool, showInstructions: Bool, + skipApiSubmission: Bool, extraPartnerParams: [String: String], onResult: T, viewModel: IOrchestratedDocumentVerificationViewModel @@ -144,97 +152,98 @@ private struct IOrchestratedDocumentVerificationScreen: View { self.allowGalleryUpload = allowGalleryUpload self.allowAgentMode = allowAgentMode self.showInstructions = showInstructions + self.skipApiSubmission = skipApiSubmission self.extraPartnerParams = extraPartnerParams self.onResult = onResult self.viewModel = viewModel } - + var body: some View { switch viewModel.step { - case .frontDocumentCapture: - DocumentCaptureScreen( - side: .front, - showInstructions: showInstructions, - showAttribution: showAttribution, - allowGallerySelection: allowGalleryUpload, - showSkipButton: false, - instructionsHeroImage: SmileIDResourcesHelper.DocVFrontHero, - instructionsTitleText: SmileIDResourcesHelper.localizedString( - for: "Instructions.Document.Front.Header" - ), - instructionsSubtitleText: SmileIDResourcesHelper.localizedString( - for: "Instructions.Document.Front.Callout" - ), - captureTitleText: SmileIDResourcesHelper.localizedString(for: "Action.TakePhoto"), - knownIdAspectRatio: idAspectRatio, - onConfirm: viewModel.onFrontDocumentImageConfirmed, - onError: viewModel.onError - ) - case .backDocumentCapture: - DocumentCaptureScreen( - side: .back, - showInstructions: showInstructions, - showAttribution: showAttribution, - allowGallerySelection: allowGalleryUpload, - showSkipButton: false, - instructionsHeroImage: SmileIDResourcesHelper.DocVBackHero, - instructionsTitleText: SmileIDResourcesHelper.localizedString( - for: "Instructions.Document.Back.Header" - ), - instructionsSubtitleText: SmileIDResourcesHelper.localizedString( - for: "Instructions.Document.Back.Callout" - ), - captureTitleText: SmileIDResourcesHelper.localizedString(for: "Action.TakePhoto"), - knownIdAspectRatio: idAspectRatio, - onConfirm: viewModel.onBackDocumentImageConfirmed, - onError: viewModel.onError, - onSkip: viewModel.onDocumentBackSkip - ) - case .selfieCapture: - OrchestratedSelfieCaptureScreen( - userId: userId, - jobId: jobId, - isEnroll: false, - allowNewEnroll: allowNewEnroll, - allowAgentMode: allowAgentMode, - showAttribution: showAttribution, - showInstructions: showInstructions, - extraPartnerParams: extraPartnerParams, - skipApiSubmission: true, - onResult: viewModel - ) - case let .processing(state): - ProcessingScreen( - processingState: state, - inProgressTitle: SmileIDResourcesHelper.localizedString( - for: "Document.Processing.Header" - ), - inProgressSubtitle: SmileIDResourcesHelper.localizedString( - for: "Document.Processing.Callout" - ), - inProgressIcon: SmileIDResourcesHelper.DocumentProcessing, - successTitle: SmileIDResourcesHelper.localizedString( - for: "Document.Complete.Header" - ), - successSubtitle: SmileIDResourcesHelper.localizedString( - for: $viewModel.errorMessageRes.wrappedValue ?? "Document.Complete.Callout" - ), - successIcon: SmileIDResourcesHelper.CheckBold, - errorTitle: SmileIDResourcesHelper.localizedString(for: "Document.Error.Header"), - errorSubtitle: getErrorSubtitle( - errorMessageRes: $viewModel.errorMessageRes.wrappedValue, - errorMessage: $viewModel.errorMessage.wrappedValue - ), - errorIcon: SmileIDResourcesHelper.Scan, - continueButtonText: SmileIDResourcesHelper.localizedString( - for: "Confirmation.Continue" - ), - onContinue: { viewModel.onFinished(delegate: onResult) }, - retryButtonText: SmileIDResourcesHelper.localizedString(for: "Confirmation.Retry"), - onRetry: viewModel.onRetry, - closeButtonText: SmileIDResourcesHelper.localizedString(for: "Confirmation.Close"), - onClose: { viewModel.onFinished(delegate: onResult) } - ) + case .frontDocumentCapture: + DocumentCaptureScreen( + side: .front, + showInstructions: showInstructions, + showAttribution: showAttribution, + allowGallerySelection: allowGalleryUpload, + showSkipButton: false, + instructionsHeroImage: SmileIDResourcesHelper.DocVFrontHero, + instructionsTitleText: SmileIDResourcesHelper.localizedString( + for: "Instructions.Document.Front.Header" + ), + instructionsSubtitleText: SmileIDResourcesHelper.localizedString( + for: "Instructions.Document.Front.Callout" + ), + captureTitleText: SmileIDResourcesHelper.localizedString(for: "Action.TakePhoto"), + knownIdAspectRatio: idAspectRatio, + onConfirm: viewModel.onFrontDocumentImageConfirmed, + onError: viewModel.onError + ) + case .backDocumentCapture: + DocumentCaptureScreen( + side: .back, + showInstructions: showInstructions, + showAttribution: showAttribution, + allowGallerySelection: allowGalleryUpload, + showSkipButton: false, + instructionsHeroImage: SmileIDResourcesHelper.DocVBackHero, + instructionsTitleText: SmileIDResourcesHelper.localizedString( + for: "Instructions.Document.Back.Header" + ), + instructionsSubtitleText: SmileIDResourcesHelper.localizedString( + for: "Instructions.Document.Back.Callout" + ), + captureTitleText: SmileIDResourcesHelper.localizedString(for: "Action.TakePhoto"), + knownIdAspectRatio: idAspectRatio, + onConfirm: viewModel.onBackDocumentImageConfirmed, + onError: viewModel.onError, + onSkip: viewModel.onDocumentBackSkip + ) + case .selfieCapture: + OrchestratedSelfieCaptureScreen( + userId: userId, + jobId: jobId, + isEnroll: false, + allowNewEnroll: allowNewEnroll, + allowAgentMode: allowAgentMode, + showAttribution: showAttribution, + showInstructions: showInstructions, + extraPartnerParams: extraPartnerParams, + skipApiSubmission: true, + onResult: viewModel + ) + case let .processing(state): + ProcessingScreen( + processingState: state, + inProgressTitle: SmileIDResourcesHelper.localizedString( + for: "Document.Processing.Header" + ), + inProgressSubtitle: SmileIDResourcesHelper.localizedString( + for: "Document.Processing.Callout" + ), + inProgressIcon: SmileIDResourcesHelper.DocumentProcessing, + successTitle: SmileIDResourcesHelper.localizedString( + for: "Document.Complete.Header" + ), + successSubtitle: SmileIDResourcesHelper.localizedString( + for: $viewModel.errorMessageRes.wrappedValue ?? "Document.Complete.Callout" + ), + successIcon: SmileIDResourcesHelper.CheckBold, + errorTitle: SmileIDResourcesHelper.localizedString(for: "Document.Error.Header"), + errorSubtitle: getErrorSubtitle( + errorMessageRes: $viewModel.errorMessageRes.wrappedValue, + errorMessage: $viewModel.errorMessage.wrappedValue + ), + errorIcon: SmileIDResourcesHelper.Scan, + continueButtonText: SmileIDResourcesHelper.localizedString( + for: "Confirmation.Continue" + ), + onContinue: { viewModel.onFinished(delegate: onResult) }, + retryButtonText: SmileIDResourcesHelper.localizedString(for: "Confirmation.Retry"), + onRetry: viewModel.onRetry, + closeButtonText: SmileIDResourcesHelper.localizedString(for: "Confirmation.Close"), + onClose: { viewModel.onFinished(delegate: onResult) } + ) } } } diff --git a/Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift b/Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift index 8a9ba94f..a07790dd 100644 --- a/Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift +++ b/Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift @@ -19,8 +19,7 @@ protocol FaceDetectorResultDelegate: AnyObject { _ detector: FaceDetectorV2, didDetectFace faceGeometry: FaceGeometryData, withFaceQuality faceQuality: Float, - selfieQuality: SelfieQualityData, - brightness: Int + selfieQuality: SelfieQualityData ) func faceDetector(_ detector: FaceDetectorV2, didFailWithError error: Error) } @@ -77,7 +76,6 @@ class FaceDetectorV2: NSObject { rect: faceObservation.boundingBox) ?? .zero let uiImage = UIImage(pixelBuffer: imageBuffer) - let brightness = self.calculateBrightness(uiImage) let croppedImage = try self.cropImageToFace(uiImage) let selfieQualityData = try self.selfieQualityRequest(imageBuffer: croppedImage) @@ -95,8 +93,7 @@ class FaceDetectorV2: NSObject { self, didDetectFace: faceGeometryData, withFaceQuality: faceQualityObservation.faceCaptureQuality ?? 0.0, - selfieQuality: selfieQualityData, - brightness: brightness + selfieQuality: selfieQualityData ) } else { // Fallback on earlier versions @@ -178,28 +175,6 @@ class FaceDetectorV2: NSObject { return resizedImage } - private func calculateBrightness(_ image: UIImage?) -> Int { - guard let image, let cgImage = image.cgImage, - let imageData = cgImage.dataProvider?.data, - let dataPointer = CFDataGetBytePtr(imageData) - else { - return 0 - } - - let bytesPerPixel = cgImage.bitsPerPixel / cgImage.bitsPerComponent - let dataLength = CFDataGetLength(imageData) - var result = 0.0 - for index in stride(from: 0, to: dataLength, by: bytesPerPixel) { - let red = dataPointer[index] - let green = dataPointer[index + 1] - let blue = dataPointer[index + 2] - result += 0.299 * Double(red) + 0.587 * Double(green) + 0.114 * Double(blue) - } - let pixelsCount = dataLength / bytesPerPixel - let brightness = Int(result) / pixelsCount - return brightness - } - private func faceDirection(faceObservation: VNFaceObservation) -> FaceDirection { guard let yaw = faceObservation.yaw?.doubleValue else { return .none diff --git a/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift b/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift index bee6b2ec..d33c5641 100644 --- a/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift +++ b/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift @@ -16,8 +16,9 @@ final class FaceValidator { // MARK: Constants private let selfieQualityThreshold: Float = 0.5 - private let luminanceThreshold: ClosedRange = 80...200 - private let faceBoundsMultiplier: CGFloat = 1.5 + private let luminanceThreshold: ClosedRange = 40...200 + private let selfiefaceBoundsMultiplier: CGFloat = 1.5 + private let livenessfaceBoundsMultiplier: CGFloat = 2.2 private let faceBoundsThreshold: CGFloat = 50 init() {} @@ -29,7 +30,6 @@ final class FaceValidator { func validate( faceGeometry: FaceGeometryData, selfieQuality: SelfieQualityData, - brightness: Int, currentLivenessTask: LivenessTask? ) { // check face bounds @@ -39,16 +39,12 @@ final class FaceValidator { ) let isAcceptableBounds = faceBoundsState == .detectedFaceAppropriateSizeAndPosition - // check brightness - let isAcceptableBrightness = luminanceThreshold.contains(brightness) - // check selfie quality let isAcceptableSelfieQuality = checkSelfieQuality(selfieQuality) // check that face is ready for capture let hasDetectedValidFace = checkValidFace( isAcceptableBounds, - isAcceptableBrightness, isAcceptableSelfieQuality ) @@ -56,7 +52,6 @@ final class FaceValidator { let userInstruction = userInstruction( from: faceBoundsState, detectedValidFace: hasDetectedValidFace, - isAcceptableBrightness: isAcceptableBrightness, isAcceptableSelfieQuality: isAcceptableSelfieQuality, livenessTask: currentLivenessTask ) @@ -72,7 +67,6 @@ final class FaceValidator { private func userInstruction( from faceBoundsState: FaceBoundsState, detectedValidFace: Bool, - isAcceptableBrightness: Bool, isAcceptableSelfieQuality: Bool, livenessTask: LivenessTask? ) -> SelfieCaptureInstruction? { @@ -94,15 +88,19 @@ final class FaceValidator { return .moveCloser } else if faceBoundsState == .detectedFaceTooLarge { return .moveBack - } else if !isAcceptableSelfieQuality || !isAcceptableBrightness { + } else if !isAcceptableSelfieQuality { return .goodLight } return nil } // MARK: Validation Checks - private func checkFaceSizeAndPosition(using boundingBox: CGRect, shouldCheckCentering: Bool) -> FaceBoundsState { + private func checkFaceSizeAndPosition( + using boundingBox: CGRect, + shouldCheckCentering: Bool + ) -> FaceBoundsState { let maxFaceWidth = faceLayoutGuideFrame.width - 20 + let faceBoundsMultiplier = shouldCheckCentering ? selfiefaceBoundsMultiplier : livenessfaceBoundsMultiplier let minFaceWidth = faceLayoutGuideFrame.width / faceBoundsMultiplier if boundingBox.width > maxFaceWidth { @@ -129,9 +127,8 @@ final class FaceValidator { private func checkValidFace( _ isAcceptableBounds: Bool, - _ isAcceptableBrightness: Bool, _ isAcceptableSelfieQuality: Bool ) -> Bool { - return isAcceptableBounds && isAcceptableBrightness && isAcceptableSelfieQuality + return isAcceptableBounds && isAcceptableSelfieQuality } } diff --git a/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift b/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift index 9805aa48..92ccab7f 100644 --- a/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift +++ b/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift @@ -8,25 +8,29 @@ enum LivenessTask { case lookUp } +protocol LivenessCheckManagerDelegate: AnyObject { + func didCompleteLivenessTask() + func didCompleteLivenessChallenge() + func livenessChallengeTimeout() +} + class LivenessCheckManager: ObservableObject { /// The sequence of liveness tasks to be performed. private var livenessTaskSequence: [LivenessTask] = [] /// The index pointing to the current task in the sequence. private var currentTaskIndex: Int = 0 - /// The view model associated with the selfie capture process. - weak var selfieViewModel: SelfieViewModelV2? - /// A closure to trigger photo capture during the liveness check. - var captureImage: (() -> Void)? + + weak var delegate: LivenessCheckManagerDelegate? // MARK: Constants /// The minimum threshold for yaw (left-right head movement) private let minYawAngleThreshold: CGFloat = 0.15 /// The maximum threshold for yaw (left-right head movement) - private let maxYawAngleThreshold: CGFloat = 0.3 + private let maxYawAngleThreshold: CGFloat = 0.25 /// The minimum threshold for pitch (up-down head movement) private let minPitchAngleThreshold: CGFloat = 0.15 /// The maximum threshold for pitch (up-down head movement) - private let maxPitchAngleThreshold: CGFloat = 0.3 + private let maxPitchAngleThreshold: CGFloat = 0.25 /// The timeout duration for each task in seconds. private let taskTimeoutDuration: TimeInterval = 120 @@ -88,7 +92,7 @@ class LivenessCheckManager: ObservableObject { /// Handles the timeout event for a task. private func handleTaskTimeout() { stopTaskTimer() - selfieViewModel?.perform(action: .activeLivenessTimeout) + delegate?.livenessChallengeTimeout() } /// Advances to the next task in the sequence @@ -160,12 +164,11 @@ class LivenessCheckManager: ObservableObject { /// Completes the current task and moves to the next one. /// If all tasks are completed, it signals the completion of the liveness challenge. private func completeCurrentTask() { - captureImage?() - captureImage?() + delegate?.didCompleteLivenessTask() if !advanceToNextTask() { // Liveness challenge complete - selfieViewModel?.perform(action: .activeLivenessCompleted) + delegate?.didCompleteLivenessChallenge() self.currentTask = nil } } diff --git a/Sources/SmileID/Classes/Helpers/HapticManager.swift b/Sources/SmileID/Classes/Helpers/HapticManager.swift new file mode 100644 index 00000000..fd1cf28b --- /dev/null +++ b/Sources/SmileID/Classes/Helpers/HapticManager.swift @@ -0,0 +1,23 @@ +import UIKit + +class HapticManager { + static let shared = HapticManager() + + private init() {} + + // MARK: Notification Feedback + + /// Triggers a notification haptic feedback + /// - Parameter type: The notification type (success, warning, error) + func notification(type: UINotificationFeedbackGenerator.FeedbackType) { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(type) + } + + // MARK: Impact Feedback + + func impact(style: UIImpactFeedbackGenerator.FeedbackStyle) { + let generator = UIImpactFeedbackGenerator(style: style) + generator.impactOccurred() + } +} diff --git a/Sources/SmileID/Classes/Networking/Models/FailureReason.swift b/Sources/SmileID/Classes/Networking/Models/FailureReason.swift index 8d901a64..f68bcd02 100644 --- a/Sources/SmileID/Classes/Networking/Models/FailureReason.swift +++ b/Sources/SmileID/Classes/Networking/Models/FailureReason.swift @@ -1,11 +1,17 @@ import Foundation -public enum FailureReason { - case activeLivenessTimedOut +public enum FailureReason: Encodable { + case mobileActiveLivenessTimeout - var key: String { + private enum CodingKeys: String, CodingKey { + case mobileActiveLivenessTimeout = "mobile_active_liveness_timed_out" + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .activeLivenessTimedOut: return "mobile_active_liveness_timed_out" + case .mobileActiveLivenessTimeout: + try container.encode(true, forKey: .mobileActiveLivenessTimeout) } } } diff --git a/Sources/SmileID/Classes/Networking/ServiceRunnable.swift b/Sources/SmileID/Classes/Networking/ServiceRunnable.swift index 16a94463..567e92f7 100644 --- a/Sources/SmileID/Classes/Networking/ServiceRunnable.swift +++ b/Sources/SmileID/Classes/Networking/ServiceRunnable.swift @@ -337,15 +337,13 @@ extension ServiceRunnable { body.append(lineBreak.data(using: .utf8)!) // Append failure reason if available - if let failureReason { - let activeLivenessTimedOutString = "\(failureReason == .activeLivenessTimedOut)" - if let valueData = "\(activeLivenessTimedOutString)\(lineBreak)".data(using: .utf8) { - body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) - body.append( - "Content-Disposition: form-data; name=\"\(failureReason.key)\"\(lineBreak + lineBreak)".data( - using: .utf8)!) - body.append(valueData) - } + if let failureReason, + let failureReasonData = try? encoder.encode(failureReason) { + body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"failure_reason\"\(lineBreak)".data(using: .utf8)!) + body.append("Content-Type: application/json\(lineBreak + lineBreak)".data(using: .utf8)!) + body.append(failureReasonData) + body.append(lineBreak.data(using: .utf8)!) } // Append final boundary diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieSubmissionManager.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieSubmissionManager.swift index 7b7d5c7a..e470dbfa 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieSubmissionManager.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieSubmissionManager.swift @@ -12,7 +12,7 @@ final class SelfieSubmissionManager { private let isEnroll: Bool private let numLivenessImages: Int private let allowNewEnroll: Bool - private var selfieImage: URL? + private var selfieImageUrl: URL? private var livenessImages: [URL] private var extraPartnerParams: [String: String] private let localMetadata: LocalMetadata @@ -26,7 +26,7 @@ final class SelfieSubmissionManager { isEnroll: Bool, numLivenessImages: Int, allowNewEnroll: Bool, - selfieImage: URL?, + selfieImageUrl: URL?, livenessImages: [URL], extraPartnerParams: [String: String], localMetadata: LocalMetadata @@ -36,16 +36,16 @@ final class SelfieSubmissionManager { self.isEnroll = isEnroll self.numLivenessImages = numLivenessImages self.allowNewEnroll = allowNewEnroll - self.selfieImage = selfieImage + self.selfieImageUrl = selfieImageUrl self.livenessImages = livenessImages self.extraPartnerParams = extraPartnerParams self.localMetadata = localMetadata } - func submitJob(forcedFailure: Bool = false) async throws { + func submitJob(failureReason: FailureReason? = nil) async throws { do { // Validate that the necessary selfie data is present - try validateImages(forcedFailure: forcedFailure) + try validateImages() // Determine the type of job (enrollment or authentication) let jobType = determineJobType() @@ -68,7 +68,7 @@ final class SelfieSubmissionManager { authResponse: authResponse, smartSelfieImage: smartSelfieImage, smartSelfieLivenessImages: smartSelfieLivenessImages, - forcedFailure: forcedFailure + failureReason: failureReason ) // Update local storage after successful submission @@ -81,15 +81,10 @@ final class SelfieSubmissionManager { } } - private func validateImages(forcedFailure: Bool) throws { - if forcedFailure { - guard selfieImage != nil else { - throw SmileIDError.unknown("Selfie capture failed") - } - } else { - guard selfieImage != nil, livenessImages.count == numLivenessImages else { - throw SmileIDError.unknown("Selfie capture failed") - } + private func validateImages() throws { + guard selfieImageUrl != nil, + livenessImages.count == numLivenessImages else { + throw SmileIDError.unknown("Selfie capture failed") } } @@ -119,8 +114,8 @@ final class SelfieSubmissionManager { } private func prepareImagesForSubmission() throws -> (MultipartBody, [MultipartBody]) { - guard let smartSelfieImage = createMultipartBody(from: selfieImage) else { - throw SmileIDError.unknown("Failed to process selfie image") + guard let smartSelfieImage = createMultipartBody(from: selfieImageUrl) else { + throw SmileIDError.fileNotFound("Could not create multipart body for file") } let smartSelfieLivenessImages = livenessImages.compactMap { @@ -136,7 +131,9 @@ final class SelfieSubmissionManager { private func createMultipartBody(from fileURL: URL?) -> MultipartBody? { guard let fileURL = fileURL, let imageData = try? Data(contentsOf: fileURL) - else { return nil } + else { + return nil + } return MultipartBody( withImage: imageData, forKey: fileURL.lastPathComponent, @@ -148,7 +145,7 @@ final class SelfieSubmissionManager { authResponse: AuthenticationResponse, smartSelfieImage: MultipartBody, smartSelfieLivenessImages: [MultipartBody], - forcedFailure: Bool + failureReason: FailureReason? ) async throws -> SmartSelfieResponse { if isEnroll { return try await SmileID.api @@ -162,7 +159,7 @@ final class SelfieSubmissionManager { callbackUrl: SmileID.callbackUrl, sandboxResult: nil, allowNewEnroll: allowNewEnroll, - failureReason: forcedFailure ? .activeLivenessTimedOut : nil, + failureReason: failureReason, metadata: localMetadata.metadata ) } else { @@ -176,7 +173,7 @@ final class SelfieSubmissionManager { partnerParams: extraPartnerParams, callbackUrl: SmileID.callbackUrl, sandboxResult: nil, - failureReason: forcedFailure ? .activeLivenessTimedOut : nil, + failureReason: failureReason, metadata: localMetadata.metadata ) } @@ -187,7 +184,7 @@ final class SelfieSubmissionManager { try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) // Update the references to the submitted selfie and liveness images - self.selfieImage = try LocalStorage.getFileByType( + self.selfieImageUrl = try LocalStorage.getFileByType( jobId: jobId, fileType: FileType.selfie, submitted: true @@ -204,7 +201,7 @@ final class SelfieSubmissionManager { do { let didMove = try LocalStorage.handleOfflineJobFailure(jobId: self.jobId, error: smileIDError) if didMove { - self.selfieImage = try LocalStorage.getFileByType(jobId: jobId, fileType: .selfie, submitted: true) + self.selfieImageUrl = try LocalStorage.getFileByType(jobId: jobId, fileType: .selfie, submitted: true) self.livenessImages = try LocalStorage.getFilesByType(jobId: jobId, fileType: .liveness, submitted: true) ?? [] } diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift index 579803a9..7b9c1c15 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift @@ -5,10 +5,7 @@ enum SelfieViewModelAction { case onViewAppear case windowSizeDetected(CGSize, EdgeInsets) - // Face Detection Actions - case activeLivenessCompleted - case activeLivenessTimeout - + // Job Submission Actions case jobProcessingDone case retryJobSubmission diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift index 8057d48f..a0c24daa 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift @@ -15,15 +15,16 @@ public class SelfieViewModelV2: ObservableObject { // MARK: Private Properties private var faceLayoutGuideFrame = CGRect(x: 0, y: 0, width: 250, height: 350) private var elapsedGuideAnimationDelay: TimeInterval = 0 + private var currentFrameBuffer: CVPixelBuffer? var selfieImage: UIImage? - var selfieImageURL: URL? { + private var selfieImageURL: URL? { didSet { DispatchQueue.main.async { self.selfieCaptured = self.selfieImage != nil } } } - var livenessImages: [URL] = [] + private var livenessImages: [URL] = [] private var hasDetectedValidFace: Bool = false private var shouldBeginLivenessChallenge: Bool { hasDetectedValidFace && selfieImage != nil && livenessCheckManager.currentTask != nil @@ -31,7 +32,8 @@ public class SelfieViewModelV2: ObservableObject { private var shouldSubmitJob: Bool { selfieImage != nil && livenessImages.count == numLivenessImages } - private var forcedFailure: Bool = false + private var submissionTask: Task? + private var failureReason: FailureReason? private var apiResponse: SmartSelfieResponse? private var error: Error? @Published public var errorMessageRes: String? @@ -101,12 +103,14 @@ public class SelfieViewModelV2: ObservableObject { deinit { stopGuideAnimationDelayTimer() + submissionTask?.cancel() + submissionTask = nil } private func initialSetup() { self.faceValidator.delegate = self self.faceDetector.resultDelegate = self - self.livenessCheckManager.selfieViewModel = self + self.livenessCheckManager.delegate = self self.faceValidator.setLayoutGuideFrame(with: faceLayoutGuideFrame) self.userInstruction = .headInFrame @@ -127,7 +131,7 @@ public class SelfieViewModelV2: ObservableObject { .receive(on: DispatchQueue.main) .filter { $0 == .unauthorized } .map { _ in AlertState.cameraUnauthorized } - .sink { alert in self.unauthorizedAlert = alert } + .sink { [weak self] alert in self?.unauthorizedAlert = alert } .store(in: &subscribers) cameraManager.sampleBufferPublisher @@ -137,22 +141,22 @@ public class SelfieViewModelV2: ObservableObject { latest: true ) // Drop the first ~2 seconds to allow the user to settle in - .dropFirst(5) + .dropFirst(5) .compactMap { $0 } - .sink(receiveValue: analyzeFrame) + .sink { [weak self] imageBuffer in + self?.analyzeFrame(imageBuffer: imageBuffer) + } .store(in: &subscribers) } private func analyzeFrame(imageBuffer: CVPixelBuffer) { + currentFrameBuffer = imageBuffer faceDetector.processImageBuffer(imageBuffer) if hasDetectedValidFace && selfieImage == nil { captureSelfieImage(imageBuffer) + HapticManager.shared.notification(type: .success) livenessCheckManager.initiateLivenessCheck() } - - livenessCheckManager.captureImage = { [weak self] in - self?.captureLivenessImage(imageBuffer) - } } // MARK: Actions @@ -160,13 +164,6 @@ public class SelfieViewModelV2: ObservableObject { switch action { case let .windowSizeDetected(windowRect, safeAreaInsets): handleWindowSizeChanged(toRect: windowRect, edgeInsets: safeAreaInsets) - case .activeLivenessCompleted: - self.cameraManager.pauseSession() - handleSubmission() - case .activeLivenessTimeout: - self.forcedFailure = true - self.cameraManager.pauseSession() - handleSubmission() case .onViewAppear: handleViewAppeared() case .jobProcessingDone: @@ -219,12 +216,11 @@ extension SelfieViewModelV2 { selfieImage = nil livenessImages = [] selfieCaptureState = .capturingSelfie - forcedFailure = false + failureReason = nil } private func handleWindowSizeChanged(toRect: CGSize, edgeInsets: EdgeInsets) { let topPadding: CGFloat = edgeInsets.top + 100 - print(edgeInsets.top) faceLayoutGuideFrame = CGRect( x: (toRect.width / 2) - faceLayoutGuideFrame.width / 2, y: topPadding, @@ -241,17 +237,49 @@ extension SelfieViewModelV2 { pixelBuffer, height: selfieImageSize, orientation: .up - ) + ), + let uiImage = UIImage(data: imageData) else { throw SmileIDError.unknown("Error resizing selfie image") } - self.selfieImage = UIImage(data: imageData) + self.selfieImage = flipImageForPreview(uiImage) self.selfieImageURL = try LocalStorage.createSelfieFile(jobId: jobId, selfieFile: imageData) } catch { handleError(error) } } + private func flipImageForPreview(_ image: UIImage) -> UIImage? { + guard let cgImage = image.cgImage else { return nil } + + let contextSize = CGSize(width: image.size.width, height: image.size.height) + UIGraphicsBeginImageContextWithOptions(contextSize, false, 1.0) + defer { + UIGraphicsEndImageContext() + } + guard let context = UIGraphicsGetCurrentContext() else { + return nil + } + + // Apply a 180° counterclockwise rotation + // Translate the context to the center before rotating + // to ensure the image rotates around its center + context.translateBy(x: contextSize.width / 2, y: contextSize.height / 2) + context.rotate(by: -.pi) + + // Draw the image + context.draw( + cgImage, + in: CGRect( + x: -image.size.width / 2, y: -image.size.height / 2, width: image.size.width, height: image.size.height) + ) + + // Get the new UIImage from the context + let correctedImage = UIGraphicsGetImageFromCurrentImageContext() + + return correctedImage + } + private func captureLivenessImage(_ pixelBuffer: CVPixelBuffer) { do { guard @@ -271,14 +299,15 @@ extension SelfieViewModelV2 { } private func handleError(_ error: Error) { - print(error.localizedDescription) + debugPrint(error.localizedDescription) } private func handleSubmission() { DispatchQueue.main.async { self.selfieCaptureState = .processing(.inProgress) } - Task { + guard submissionTask == nil else { return } + submissionTask = Task { try await submitJob() } } @@ -295,14 +324,12 @@ extension SelfieViewModelV2: FaceDetectorResultDelegate { _ detector: FaceDetectorV2, didDetectFace faceGeometry: FaceGeometryData, withFaceQuality faceQuality: Float, - selfieQuality: SelfieQualityData, - brightness: Int + selfieQuality: SelfieQualityData ) { faceValidator .validate( faceGeometry: faceGeometry, selfieQuality: selfieQuality, - brightness: brightness, currentLivenessTask: self.livenessCheckManager.currentTask ) if shouldBeginLivenessChallenge { @@ -314,7 +341,6 @@ extension SelfieViewModelV2: FaceDetectorResultDelegate { DispatchQueue.main.async { self.publishUserInstruction(.headInFrame) } - print(error.localizedDescription) } } @@ -329,6 +355,44 @@ extension SelfieViewModelV2: FaceValidatorDelegate { } } +// MARK: LivenessCheckManagerDelegate Methods +extension SelfieViewModelV2: LivenessCheckManagerDelegate { + func didCompleteLivenessTask() { + // capture first frame + guard let firstFrameBuffer = currentFrameBuffer else { return } + captureLivenessImage(firstFrameBuffer) + + // capture a second frame after a slight delay + // to ensure it's a different frame + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + guard let secondFrameBuffer = self?.currentFrameBuffer else { return } + self?.captureLivenessImage(secondFrameBuffer) + } + HapticManager.shared.notification(type: .success) + } + + func didCompleteLivenessChallenge() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.cameraManager.pauseSession() + self.handleSubmission() + } + } + + func livenessChallengeTimeout() { + let remainingImages = numLivenessImages - livenessImages.count + let count = remainingImages > 0 ? remainingImages : 0 + for _ in 0.. some View { @@ -313,7 +319,7 @@ public class SmileID { showInstructions: showInstructions, useStrictMode: useStrictMode, extraPartnerParams: extraPartnerParams, - skipApiSubmission: false, + skipApiSubmission: skipApiSubmission, onResult: delegate ) } else { @@ -326,7 +332,7 @@ public class SmileID { showAttribution: showAttribution, showInstructions: showInstructions, extraPartnerParams: extraPartnerParams, - skipApiSubmission: false, + skipApiSubmission: skipApiSubmission, onResult: delegate ) } @@ -350,6 +356,7 @@ public class SmileID { /// - showAttribution: Whether to show the Smile ID attribution or not on the Instructions /// screen /// - showInstructions: Whether to deactivate capture screen's instructions for SmartSelfie. + /// - skipApiSubmission: Whether to skip api submission to SmileID and return only captured images /// - extraPartnerParams: Custom values specific to partners /// - delegate: Callback to be invoked when the SmartSelfie™ Authentication is complete. @ViewBuilder public class func smartSelfieAuthenticationScreen( @@ -418,6 +425,7 @@ public class SmileID { /// - showInstructions: Whether to deactivate capture screen's instructions for Document /// Verification (NB! If instructions are disabled, gallery upload won't be possible) /// - showAttribution: Whether to show the Smile ID attribution on the Instructions screen + /// - skipApiSubmission: Whether to skip api submission to SmileID and return only captured images /// - extraPartnerParams: Custom values specific to partners /// - delegate: The delegate object that receives the result of the Document Verification public class func documentVerificationScreen( @@ -433,6 +441,7 @@ public class SmileID { allowGalleryUpload: Bool = false, showInstructions: Bool = true, showAttribution: Bool = true, + skipApiSubmission: Bool = false, extraPartnerParams: [String: String] = [:], delegate: DocumentVerificationResultDelegate ) -> some View { @@ -449,6 +458,7 @@ public class SmileID { allowGalleryUpload: allowGalleryUpload, allowAgentMode: allowAgentMode, showInstructions: showInstructions, + skipApiSubmission: skipApiSubmission, extraPartnerParams: extraPartnerParams, onResult: delegate ) @@ -479,6 +489,7 @@ public class SmileID { /// - showInstructions: Whether to deactivate capture screen's instructions for Document /// Verification (NB! If instructions are disabled, gallery upload won't be possible) /// - showAttribution: Whether to show the Smile ID attribution on the Instructions screen + /// - skipApiSubmission: Whether to skip api submission to SmileID and return only captured images /// - extraPartnerParams: Custom values specific to partners /// - delegate: The delegate object that receives the result of the Document Verification public class func enhancedDocumentVerificationScreen( @@ -493,6 +504,7 @@ public class SmileID { allowAgentMode: Bool = false, allowGalleryUpload: Bool = false, showInstructions: Bool = true, + skipApiSubmission: Bool = false, showAttribution: Bool = true, extraPartnerParams: [String: String] = [:], delegate: EnhancedDocumentVerificationResultDelegate @@ -510,6 +522,7 @@ public class SmileID { allowGalleryUpload: allowGalleryUpload, allowAgentMode: allowAgentMode, showInstructions: showInstructions, + skipApiSubmission: skipApiSubmission, extraPartnerParams: extraPartnerParams, onResult: delegate ) @@ -550,6 +563,7 @@ public class SmileID { /// the front camera will be used. /// - showAttribution: Whether to show the Smile ID attribution on the Instructions screen /// - showInstructions: Whether to deactivate capture screen's instructions for SmartSelfie. + /// - skipApiSubmission: Whether to skip api submission to SmileID and return only captured images /// - extraPartnerParams: Custom values specific to partners /// - delegate: Callback to be invoked when the Biometric KYC is complete. public class func biometricKycScreen( diff --git a/Sources/SmileID/Classes/Util.swift b/Sources/SmileID/Classes/Util.swift index 64563c11..bb2c23b3 100644 --- a/Sources/SmileID/Classes/Util.swift +++ b/Sources/SmileID/Classes/Util.swift @@ -79,6 +79,10 @@ func toErrorMessage(error: SmileIDError) -> (String, String?) { return (error.localizedDescription, nil) case let .httpError(_, message): return ("", message) + case let .fileNotFound(message): + return (message, nil) + case let .unknown(message): + return (message, nil) default: return ("Confirmation.FailureReason", nil) }