From a1b487fe17923bf56e3c8c7d2484ed20d35db6df Mon Sep 17 00:00:00 2001 From: Juma Allan Date: Wed, 8 May 2024 20:32:06 +0300 Subject: [PATCH] Offline Mode on iOS (#167) * Reworked Local Storage (#147) * reworked file creation logic * added create info.json file * made create file more abstract * removed whitespace * updated info.json * refactored DocV and EnhancedDocV * refactored BiometricKYC * Update LocalStorage.swift * remove whitespaces * Update SelfieViewModel.swift * added encryption and create prepupload and authentication file * refactored save offline job * fixed return statement * fixed selfie duplication and removed authtoken * fixed pr comments * Added File Manager Helpers (#148) * reworked file creation logic * added create info.json file * made create file more abstract * removed whitespace * updated info.json * refactored DocV and EnhancedDocV * refactored BiometricKYC * Update LocalStorage.swift * remove whitespaces * Update SelfieViewModel.swift * added encryption and create prepupload and authentication file * refactored save offline job * fixed return statement * fixed selfie duplication and removed authtoken * added file manager helper files * fixed pr comments * fixed merge conflicts * Update IdInfo in BiometricKYC (#152) * added update IdInfo in BiometricKYC * Update UploadRequest.swift * Prepare v10.0.10 release * Update Changelog --------- Co-authored-by: Vansh Gandhi * Move Files from Unsubmitted to Submitted Directories (#149) * reworked file creation logic * added create info.json file * made create file more abstract * removed whitespace * updated info.json * refactored DocV and EnhancedDocV * refactored BiometricKYC * Update LocalStorage.swift * remove whitespaces * Update SelfieViewModel.swift * added encryption and create prepupload and authentication file * refactored save offline job * fixed return statement * fixed selfie duplication and removed authtoken * added file manager helper files * move files from unsubmitted to submitted * Update Podfile.lock * updated move from unsubmitted to submitted directories * fixed pr comments * update error response * Upload Offline Jobs (#151) * reworked file creation logic * made create file more abstract * removed whitespace * updated info.json * move files from unsubmitted to submitted * Update Podfile.lock * updated move from unsubmitted to submitted directories * added upload offline jobs * removed jobStatusResponse * updated jobStatusResponse on docv * reworked offline mode submission * added pr comment * fixed PR comments * Update IdInfo in BiometricKYC (#152) * added update IdInfo in BiometricKYC * Update UploadRequest.swift * Prepare v10.0.10 release * Update Changelog --------- Co-authored-by: Vansh Gandhi * refactored BiometricKYC * move files from unsubmitted to submitted * Update Podfile.lock * Reworked Local Storage (#147) * reworked file creation logic * added create info.json file * made create file more abstract * removed whitespace * updated info.json * refactored DocV and EnhancedDocV * refactored BiometricKYC * Update LocalStorage.swift * remove whitespaces * Update SelfieViewModel.swift * added encryption and create prepupload and authentication file * refactored save offline job * fixed return statement * fixed selfie duplication and removed authtoken * fixed pr comments * added upload offline jobs * removed jobStatusResponse * reworked offline mode submission * fixed PR comments * rebased and fixed pr comments * remove redeclared funcs * updated offline mode config * added submitJob utility function * refactored image file types * rebase with feat/offline epic * rebase changelog and versions * cleaning up * fixed jsonDecoder * fixed pr comments * cleaning up allFiles to be a let instead of var * Fixed PR comments and cleaned up submitJob * fixed offline upload * Update OrchestratedBiometricKycViewModel.swift * remove test data * fixed move to submitted on http error * catch non smile id errors --------- Co-authored-by: Vansh Gandhi * Update Offline Message in Orchestrated Screens in Offline State (#161) * updated offline message * added error message on error state * feat: swiftformat * disable open brace lint warning --------- Co-authored-by: JNdhlovu * rebase with main * prepare for release --------- Co-authored-by: Vansh Gandhi Co-authored-by: JNdhlovu --- CHANGELOG.md | 9 +- Example/Podfile.lock | 24 +- Example/SmileID.xcodeproj/project.pbxproj | 6 +- Example/SmileID/HomeView.swift | 8 +- Example/SmileID/HomeViewController.swift | 2 +- Example/SmileID/HomeViewModel.swift | 46 +-- Example/SmileID/Util.swift | 18 +- SmileID.podspec | 6 +- .../BiometricKycResultDelegate.swift | 11 +- .../OrchestratedBiometricKycScreen.swift | 6 +- .../OrchestratedBiometricKycViewModel.swift | 88 ++-- .../DocumentVerificationResultDelegate.swift | 12 +- ...edDocumentVerificationResultDelegate.swift | 12 +- ...stratedDocumentVerificationViewModel.swift | 203 +++++---- ...chestratedDocumentVerificationScreen.swift | 6 +- .../Classes/Helpers/LocalStorage.swift | 388 +++++++++++------- .../SmileID/Classes/Networking/APIError.swift | 38 +- .../SelfieCapture/SelfieViewModel.swift | 92 +++-- .../SmartSelfieResultDelegate.swift | 8 +- .../OrchestratedSelfieCaptureScreen.swift | 12 +- Sources/SmileID/Classes/SmileID.swift | 118 +++++- Sources/SmileID/Classes/Util.swift | 13 + .../Localization/en.lproj/Localizable.strings | 2 + Tests/Mocks/NetworkingMocks.swift | 2 +- 24 files changed, 713 insertions(+), 417 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3a36f16..6ce3b5448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Release Notes +## 10.1.1 + +#### Added + +* Added an Offline Mode, enabled by calling `SmileID.setAllowOfflineMode(true)`. If a job is attempted while the device is offline, and offline mode has been enabled, the UI will complete successfully and the job can be submitted at a later time by calling `SmileID.submitJob(jobId)` + ## 10.1.0 * Add PrivacyInfo Manifest * Added polling extensions for products -* Added an Offline Mode, enabled by calling `SmileID.setAllowOfflineMode(true)`. If a job is attempted while the device is offline, and offline mode has been enabled, the UI will complete successfully and the job can be submitted at a later time by calling `SmileID.submitJob(jobId)` ## 10.0.11 @@ -11,7 +16,6 @@ * PartnerParams extras fixed to be in the correct format for the requests * PartnerParams extras fixed to cater for the Photo param used in sandbox testing - ## 10.0.10 * Set `IdInfo.entered` to true for Biometric KYC Jobs @@ -233,4 +237,3 @@ #### Dependencies * Zip - diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 3dc3bba0b..071c272ce 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -1,14 +1,12 @@ PODS: - - lottie-ios (4.4.2) - netfox (1.21.0) - - Sentry (8.21.0): - - Sentry/Core (= 8.21.0) - - SentryPrivate (= 8.21.0) - - Sentry/Core (8.21.0): - - SentryPrivate (= 8.21.0) - - SentryPrivate (8.21.0) - - SmileID (10.1.0): - - lottie-ios (~> 4.4.2) + - Sentry (8.20.0): + - Sentry/Core (= 8.20.0) + - SentryPrivate (= 8.20.0) + - Sentry/Core (8.20.0): + - SentryPrivate (= 8.20.0) + - SentryPrivate (8.20.0) + - SmileID (10.0.10): - Zip (~> 2.1.0) - SwiftLint (0.54.0) - Zip (2.1.2) @@ -21,7 +19,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - lottie-ios - netfox - Sentry - SentryPrivate @@ -33,11 +30,10 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - lottie-ios: 4445b0bdb583c7a5325529f62246d311ee85fcd0 netfox: 9d5cc727fe7576c4c7688a2504618a156b7d44b7 - Sentry: ebc12276bd17613a114ab359074096b6b3725203 - SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe - SmileID: fa0d8d8738afc21fae721d398379020639d1e964 + Sentry: a8d7b373b9f9868442b02a0c425192f693103cbf + SentryPrivate: 006b24af16828441f70e2ab6adf241bd0a8ad130 + SmileID: ffaa1eab202cb9c402ade1cc66ceaf5a2c99c686 SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 diff --git a/Example/SmileID.xcodeproj/project.pbxproj b/Example/SmileID.xcodeproj/project.pbxproj index 1d3f47409..775968414 100644 --- a/Example/SmileID.xcodeproj/project.pbxproj +++ b/Example/SmileID.xcodeproj/project.pbxproj @@ -32,7 +32,7 @@ 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; 620F1E982B69194900185CD2 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 620F1E972B69194900185CD2 /* AlertView.swift */; }; - 620F1E9A2B691ABB00185CD2 /* (null) in Resources */ = {isa = PBXBuildFile; }; + 620F1E9A2B691ABB00185CD2 /* BuildFile in Resources */ = {isa = PBXBuildFile; }; 624777D02B0CDC9F00952842 /* EnhancedKycWithIdInputScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624777CF2B0CDC9F00952842 /* EnhancedKycWithIdInputScreen.swift */; }; 62F6766F2B0D173600417419 /* EnhancedKycWithIdInputScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F6766E2B0D173600417419 /* EnhancedKycWithIdInputScreenViewModel.swift */; }; 62F676712B0E00E800417419 /* EnhancedKycResultDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F676702B0E00E800417419 /* EnhancedKycResultDelegate.swift */; }; @@ -436,7 +436,7 @@ buildActionMask = 2147483647; files = ( 1EFAB3172A375265008E3C13 /* Images.xcassets in Resources */, - 620F1E9A2B691ABB00185CD2 /* (null) in Resources */, + 620F1E9A2B691ABB00185CD2 /* BuildFile in Resources */, 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */, 5829A8C02BC7429A001C1E7E /* PrivacyInfo.xcprivacy in Resources */, 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */, @@ -464,7 +464,6 @@ "${BUILT_PRODUCTS_DIR}/SentryPrivate/SentryPrivate.framework", "${BUILT_PRODUCTS_DIR}/SmileID/SmileID.framework", "${BUILT_PRODUCTS_DIR}/Zip/Zip.framework", - "${BUILT_PRODUCTS_DIR}/lottie-ios/Lottie.framework", "${BUILT_PRODUCTS_DIR}/netfox/netfox.framework", ); name = "[CP] Embed Pods Frameworks"; @@ -473,7 +472,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SentryPrivate.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SmileID.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Zip.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Lottie.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/netfox.framework", ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Example/SmileID/HomeView.swift b/Example/SmileID/HomeView.swift index 86aebbd1f..6289190aa 100644 --- a/Example/SmileID/HomeView.swift +++ b/Example/SmileID/HomeView.swift @@ -19,7 +19,7 @@ struct HomeView: View { Text("Test Our Products") .font(SmileID.theme.header2) .foregroundColor(.black) - + MyVerticalGrid( maxColumns: 2, items: [ @@ -124,16 +124,16 @@ struct SmartSelfieEnrollmentDelegate: SmartSelfieResultDelegate { _ userId: String, _ selfieFile: URL, _ livenessImages: [URL], - _ jobStatusResponse: SmartSelfieJobStatusResponse? + _ didSubmitSmartSelfieJob: Bool ) -> Void let onError: (Error) -> Void func didSucceed( selfieImage: URL, livenessImages: [URL], - jobStatusResponse: SmartSelfieJobStatusResponse? + didSubmitSmartSelfieJob: Bool ) { - onEnrollmentSuccess(userId, selfieImage, livenessImages, jobStatusResponse) + onEnrollmentSuccess(userId, selfieImage, livenessImages, didSubmitSmartSelfieJob) } func didError(error: Error) { diff --git a/Example/SmileID/HomeViewController.swift b/Example/SmileID/HomeViewController.swift index f6842c494..6cd56a7ce 100644 --- a/Example/SmileID/HomeViewController.swift +++ b/Example/SmileID/HomeViewController.swift @@ -58,7 +58,7 @@ class HomeViewController: UIViewController, SmartSelfieResultDelegate { func didSucceed( selfieImage: URL, livenessImages: [URL], - jobStatusResponse: JobStatusResponse? + didSubmitSmartSelfieJob: Bool ) { cameraVC?.dismiss(animated: true, completion: { switch self.currentJob { diff --git a/Example/SmileID/HomeViewModel.swift b/Example/SmileID/HomeViewModel.swift index b72ed24a0..69abac3fa 100644 --- a/Example/SmileID/HomeViewModel.swift +++ b/Example/SmileID/HomeViewModel.swift @@ -48,18 +48,14 @@ class HomeViewModel: ObservableObject, userId: String, selfieImage _: URL, livenessImages _: [URL], - jobStatusResponse: JobStatusResponse? + didSubmitJob: Bool ) { dismissModal() showToast = true UIPasteboard.general.string = userId toastMessage = jobResultMessageBuilder( jobName: "SmartSelfie Enrollment", - jobComplete: jobStatusResponse?.jobComplete, - jobSuccess: jobStatusResponse?.jobSuccess, - code: jobStatusResponse?.code, - resultCode: jobStatusResponse?.result?.resultCode, - resultText: jobStatusResponse?.result?.resultText, + didSubmitJob: didSubmitJob, suffix: "The User ID has been copied to your clipboard" ) } @@ -68,34 +64,26 @@ class HomeViewModel: ObservableObject, func didSucceed( selfieImage _: URL, livenessImages _: [URL], - jobStatusResponse: JobStatusResponse? + didSubmitSmartSelfieJob: Bool ) { dismissModal() showToast = true toastMessage = jobResultMessageBuilder( jobName: "SmartSelfie Authentication", - jobComplete: jobStatusResponse?.jobComplete, - jobSuccess: jobStatusResponse?.jobSuccess, - code: jobStatusResponse?.code, - resultCode: jobStatusResponse?.result?.resultCode, - resultText: jobStatusResponse?.result?.resultText + didSubmitJob: didSubmitSmartSelfieJob ) } func didSucceed( selfieImage _: URL, livenessImages _: [URL], - jobStatusResponse: JobStatusResponse + didSubmitBiometricJob: Bool ) { dismissModal() showToast = true toastMessage = jobResultMessageBuilder( jobName: "Biometric KYC", - jobComplete: jobStatusResponse.jobComplete, - jobSuccess: jobStatusResponse.jobSuccess, - code: jobStatusResponse.code, - resultCode: jobStatusResponse.result?.resultCode, - resultText: jobStatusResponse.result?.resultText + didSubmitJob: didSubmitBiometricJob ) } @@ -106,11 +94,7 @@ class HomeViewModel: ObservableObject, showToast = true toastMessage = jobResultMessageBuilder( jobName: "Enhanced KYC", - jobComplete: true, - jobSuccess: true, - code: nil, - resultCode: enhancedKycResponse.resultCode, - resultText: enhancedKycResponse.resultText + didSubmitJob: true ) } @@ -118,17 +102,13 @@ class HomeViewModel: ObservableObject, selfie _: URL, documentFrontImage _: URL, documentBackImage _: URL?, - jobStatusResponse: JobStatusResponse + didSubmitDocumentVerificationJob: Bool ) { dismissModal() showToast = true toastMessage = jobResultMessageBuilder( jobName: "Document Verification", - jobComplete: jobStatusResponse.jobComplete, - jobSuccess: jobStatusResponse.jobSuccess, - code: jobStatusResponse.code, - resultCode: jobStatusResponse.result?.resultCode, - resultText: jobStatusResponse.result?.resultText + didSubmitJob: didSubmitDocumentVerificationJob ) } @@ -136,17 +116,13 @@ class HomeViewModel: ObservableObject, selfie _: URL, documentFrontImage _: URL, documentBackImage _: URL?, - jobStatusResponse: JobStatusResponse + didSubmitEnhancedDocVJob: Bool ) { dismissModal() showToast = true toastMessage = jobResultMessageBuilder( jobName: "Enhanced Document Verification", - jobComplete: jobStatusResponse.jobComplete, - jobSuccess: jobStatusResponse.jobSuccess, - code: jobStatusResponse.code, - resultCode: jobStatusResponse.result?.resultCode, - resultText: jobStatusResponse.result?.resultText + didSubmitJob: didSubmitEnhancedDocVJob ) } diff --git a/Example/SmileID/Util.swift b/Example/SmileID/Util.swift index 6becf075a..6273159a9 100644 --- a/Example/SmileID/Util.swift +++ b/Example/SmileID/Util.swift @@ -11,24 +11,14 @@ func openUrl(_ urlString: String) { func jobResultMessageBuilder( jobName: String, - jobComplete: Bool?, - jobSuccess: Bool?, - code: String?, - resultCode: String?, - resultText: String?, + didSubmitJob: Bool?, suffix: String? = nil ) -> String { var message = "\(jobName) " - if jobComplete == true { - if jobSuccess == true { - message += "completed successfully" - } else { - message += "completed unsuccessfully" - } - message += - " (resultText=\(resultText ?? "null"), code=\(code ?? "null"), resultCode=\(resultCode ?? "null"))" + if didSubmitJob == true { + message += "completed successfully" } else { - message += "still pending" + message += "saved offline" } if let suffix = suffix { message += ". \(suffix)" diff --git a/SmileID.podspec b/SmileID.podspec index db9be6498..2578200b5 100644 --- a/SmileID.podspec +++ b/SmileID.podspec @@ -1,11 +1,11 @@ Pod::Spec.new do |s| s.name = 'SmileID' - s.version = '10.1.0' + s.version = '10.1.1' 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.1.0" } + s.source = { :git => "https://github.com/smileidentity/ios.git", :tag => "v10.1.1" } s.ios.deployment_target = '13.0' s.dependency 'Zip', '~> 2.1.0' s.dependency 'lottie-ios', '~> 4.4.2' @@ -14,4 +14,4 @@ Pod::Spec.new do |s| s.resource_bundles = { 'SmileID_SmileID' => ['Sources/SmileID/Resources/**/*.{storyboard,storyboardc,xib,nib,xcassets,json,png,ttf,lproj,xcprivacy}'] } -end +end \ No newline at end of file diff --git a/Sources/SmileID/Classes/BiometricKYC/BiometricKycResultDelegate.swift b/Sources/SmileID/Classes/BiometricKYC/BiometricKycResultDelegate.swift index 112f65e5f..5ca476d5f 100644 --- a/Sources/SmileID/Classes/BiometricKYC/BiometricKycResultDelegate.swift +++ b/Sources/SmileID/Classes/BiometricKYC/BiometricKycResultDelegate.swift @@ -1,19 +1,16 @@ -/// The result of a selfie capture session and Biometric KYC job submission. The Job itself may -/// or may not be complete yet. This can be checked with `jobStatusResponse.jobComplete`. If not -/// yet complete, the job status will need to be fetched again later. If the job is complete, the -/// final job success can be checked with `jobStatusResponse.jobSuccess`. - import Foundation + +/// The result of a selfie capture session and Biometric KYC job submission. public protocol BiometricKycResultDelegate { /// This function is called as a result of a successful selfie capture and job submission /// - Parameters: /// - selfieImage: The local url of the colour selfie image captured /// - livenessImages: An array of local urls of images captured for liveness checks - /// - jobStatusResponse: The response object after submitting the jib + /// - didSubmitBiometricJob: Indicates whether the job was submitted to the SmileID backend (e.g. it would be false in offline mode) func didSucceed( selfieImage: URL, livenessImages: [URL], - jobStatusResponse: BiometricKycJobStatusResponse + didSubmitBiometricJob: Bool ) /// An error occurred during the selfie capture session or job submission diff --git a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycScreen.swift b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycScreen.swift index 78286c823..93569abbd 100644 --- a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycScreen.swift +++ b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycScreen.swift @@ -54,7 +54,7 @@ struct OrchestratedBiometricKycScreen: View { skipApiSubmission: true, onResult: viewModel ) - case .processing(let state): + case let .processing(state): ProcessingScreen( processingState: state, inProgressTitle: SmileIDResourcesHelper.localizedString( @@ -68,12 +68,12 @@ struct OrchestratedBiometricKycScreen: View { for: "BiometricKYC.Success.Title" ), successSubtitle: SmileIDResourcesHelper.localizedString( - for: "BiometricKYC.Success.Subtitle" + for: $viewModel.errorMessage.wrappedValue ?? "BiometricKYC.Success.Subtitle" ), successIcon: SmileIDResourcesHelper.CheckBold, errorTitle: SmileIDResourcesHelper.localizedString(for: "BiometricKYC.Error.Title"), errorSubtitle: SmileIDResourcesHelper.localizedString( - for: "BiometricKYC.Error.Subtitle" + for: $viewModel.errorMessage.wrappedValue ?? "BiometricKYC.Error.Subtitle" ), errorIcon: SmileIDResourcesHelper.Scan, continueButtonText: SmileIDResourcesHelper.localizedString( diff --git a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift index 959494f46..f0c1d5670 100644 --- a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift +++ b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift @@ -8,6 +8,7 @@ internal enum BiometricKycStep { internal class OrchestratedBiometricKycViewModel: ObservableObject { // MARK: - Input Properties + private let userId: String private let jobId: String private let allowNewEnroll: Bool @@ -15,12 +16,15 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { private var idInfo: IdInfo // MARK: - Other Properties + private var error: Error? private var selfieCaptureResultStore: SelfieCaptureResultStore? - private var jobStatusResponse: BiometricKycJobStatusResponse? + private var didSubmitBiometricJob: Bool = false // MARK: - UI Properties - @Published @MainActor private (set) var step: BiometricKycStep = .selfie + + @Published var errorMessage: String? + @Published @MainActor private(set) var step: BiometricKycStep = .selfie init( userId: String, @@ -45,14 +49,13 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { } func onFinished(delegate: BiometricKycResultDelegate) { - if let jobStatusResponse = jobStatusResponse, - let selfieCaptureResultStore = selfieCaptureResultStore { + if let selfieCaptureResultStore { delegate.didSucceed( selfieImage: selfieCaptureResultStore.selfie, livenessImages: selfieCaptureResultStore.livenessImages, - jobStatusResponse: jobStatusResponse + didSubmitBiometricJob: didSubmitBiometricJob ) - } else if let error = error { + } else if let error { delegate.didError(error: error) } else { delegate.didError(error: SmileIDError.unknown("onFinish with no result or error")) @@ -65,10 +68,11 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { do { let livenessImages = selfieCaptureResultStore.livenessImages let selfieImage = selfieCaptureResultStore.selfie - let infoJson = try LocalStorage.createInfoJson( + let infoJson = try LocalStorage.createInfoJsonFile( + jobId: jobId, + idInfo: idInfo.copy(entered: true), selfie: selfieImage, - livenessImages: livenessImages, - idInfo: idInfo.copy(entered: true) + livenessImages: livenessImages ) let zipUrl = try LocalStorage.zipFiles( at: livenessImages + [selfieImage] + [infoJson] @@ -82,33 +86,65 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { country: idInfo.country, idType: idInfo.idType ) + if SmileID.allowOfflineMode { + try LocalStorage.saveOfflineJob( + jobId: jobId, + userId: userId, + jobType: .biometricKyc, + enrollment: false, + allowNewEnroll: allowNewEnroll, + partnerParams: extraPartnerParams + ) + } let authResponse = try await SmileID.api.authenticate(request: authRequest).async() let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: extraPartnerParams), - allowNewEnroll: String(allowNewEnroll), // TODO - Fix when Michael changes this to boolean + allowNewEnroll: String(allowNewEnroll), // TODO: - Fix when Michael changes this to boolean timestamp: authResponse.timestamp, signature: authResponse.signature ) let prepUploadResponse = try await SmileID.api.prepUpload( request: prepUploadRequest ).async() - let _ = try await SmileID.api.upload( + _ = try await SmileID.api.upload( zip: zip, to: prepUploadResponse.uploadUrl ).async() - let jobStatusRequest = JobStatusRequest( - userId: userId, - jobId: jobId, - includeImageLinks: false, - includeHistory: false, - timestamp: authResponse.timestamp, - signature: authResponse.signature - ) - jobStatusResponse = try await SmileID.api.getJobStatus( - request: jobStatusRequest - ).async() + didSubmitBiometricJob = true + do { + try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) + } catch { + print("Error moving job to submitted directory: \(error)") + self.error = error + DispatchQueue.main.async { self.step = .processing(.error) } + return + } DispatchQueue.main.async { self.step = .processing(.success) } + } catch let error as SmileIDError { + do { + try LocalStorage.handleOfflineJobFailure( + jobId: self.jobId, + error: error + ) + } catch { + print("Error moving job to submitted directory: \(error)") + self.error = error + return + } + if SmileID.allowOfflineMode, LocalStorage.isNetworkFailure(error: error) { + didSubmitBiometricJob = true + DispatchQueue.main.async { + self.errorMessage = "Offline.Message" + self.step = .processing(.success) + } + } else { + didSubmitBiometricJob = false + print("Error submitting job: \(error)") + self.error = error + DispatchQueue.main.async { self.step = .processing(.error) } + } } catch { + didSubmitBiometricJob = false print("Error submitting job: \(error)") self.error = error DispatchQueue.main.async { self.step = .processing(.error) } @@ -121,13 +157,13 @@ extension OrchestratedBiometricKycViewModel: SmartSelfieResultDelegate { func didSucceed( selfieImage: URL, livenessImages: [URL], - jobStatusResponse: SmartSelfieJobStatusResponse? + didSubmitSmartSelfieJob _: Bool ) { selfieCaptureResultStore = SelfieCaptureResultStore( selfie: selfieImage, livenessImages: livenessImages ) - if let selfieCaptureResultStore = selfieCaptureResultStore { + if let selfieCaptureResultStore { submitJob(selfieCaptureResultStore: selfieCaptureResultStore) } else { error = SmileIDError.unknown("Failed to save selfie capture result") @@ -135,8 +171,8 @@ extension OrchestratedBiometricKycViewModel: SmartSelfieResultDelegate { } } - func didError(error: Error) { - self.error = SmileIDError.unknown("Failed to capture selfie") + func didError(error _: Error) { + error = SmileIDError.unknown("Failed to capture selfie") DispatchQueue.main.async { self.step = .processing(.error) } } } diff --git a/Sources/SmileID/Classes/DocumentVerification/DocumentVerificationResultDelegate.swift b/Sources/SmileID/Classes/DocumentVerification/DocumentVerificationResultDelegate.swift index 9c80b429e..8e228f3b5 100644 --- a/Sources/SmileID/Classes/DocumentVerification/DocumentVerificationResultDelegate.swift +++ b/Sources/SmileID/Classes/DocumentVerification/DocumentVerificationResultDelegate.swift @@ -1,21 +1,19 @@ -/// The result of a Document Verification - import Foundation + +/// The result of a Document Verification public protocol DocumentVerificationResultDelegate { /// Delegate method called after a successful Document Verification capture and submission. - /// It indicates that the capture and network requests were successful. The job may or may not - /// be complete. Use `jobStatusResponse.jobComplete` to check if a job is complete and - /// `jobStatusResponse.jobSuccess` to check if a job is successful. + /// It indicates that the capture and network requests were successful. /// - Parameters: /// - selfie: URL of captured selfie JPEG /// - documentFrontImage: URL of captured front document image JPEG /// - documentBackImage: URL of captured back document image JPEG (if applicable) - /// - jobStatusResponse: The response from the job status request + /// - didSubmitDocumentVerificationJob: Indicates whether the job was submitted to the SmileID backend (e.g. it would be false in offline mode) func didSucceed( selfie: URL, documentFrontImage: URL, documentBackImage: URL?, - jobStatusResponse: DocumentVerificationJobStatusResponse + didSubmitDocumentVerificationJob: Bool ) /// Delegate method called when an error occurs during Document Verification. This may diff --git a/Sources/SmileID/Classes/DocumentVerification/EnhancedDocumentVerificationResultDelegate.swift b/Sources/SmileID/Classes/DocumentVerification/EnhancedDocumentVerificationResultDelegate.swift index f5634528c..9a020070b 100644 --- a/Sources/SmileID/Classes/DocumentVerification/EnhancedDocumentVerificationResultDelegate.swift +++ b/Sources/SmileID/Classes/DocumentVerification/EnhancedDocumentVerificationResultDelegate.swift @@ -1,21 +1,19 @@ -/// The result of an Enhanced Document Verification - import Foundation + +/// The result of an Enhanced Document Verification public protocol EnhancedDocumentVerificationResultDelegate { /// Delegate method called after a successful Enhanced Document Verification capture and - /// submission. It indicates that the capture and network requests were successful. The job - /// may or may not be complete. Use `jobStatusResponse.jobComplete` to check if a job is - /// complete and `jobStatusResponse.jobSuccess` to check if a job is successful. + /// submission. It indicates that the capture and network requests were successful. /// - Parameters: /// - selfie: URL of captured selfie JPEG /// - documentFrontImage: URL of captured front document image JPEG /// - documentBackImage: URL of captured back document image JPEG (if applicable) - /// - jobStatusResponse: The response from the job status request + /// - didSubmitEnhancedDocVJob: Indicates whether the job was submitted to the SmileID backend (e.g. it would be false in offline mode) func didSucceed( selfie: URL, documentFrontImage: URL, documentBackImage: URL?, - jobStatusResponse: EnhancedDocumentVerificationJobStatusResponse + didSubmitEnhancedDocVJob: Bool ) /// Delegate method called when an error occurs during Document Verification. This may diff --git a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift index 1235a5788..1ad7ba279 100644 --- a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift +++ b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift @@ -16,22 +16,22 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse internal let countryCode: String internal let documentType: String? internal let captureBothSides: Bool - internal var selfieFile: Data? internal let jobType: JobType internal let extraPartnerParams: [String: String] // Other properties internal var documentFrontFile: Data? internal var documentBackFile: Data? - internal var livenessFiles: [Data]? - internal var jobStatusResponse: JobStatusResponse? + internal var selfieFile: URL? + internal var livenessFiles: [URL]? internal var savedFiles: DocumentCaptureResultStore? - internal var networkingSubscriber: AnyCancellable? internal var stepToRetry: DocumentCaptureFlow? + internal var didSubmitJob: Bool = false internal var error: Error? // UI properties @Published var acknowledgedInstructions = false + @Published var errorMessage: String? @Published var step = DocumentCaptureFlow.frontDocumentCapture internal init( @@ -51,7 +51,7 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse self.countryCode = countryCode self.documentType = documentType self.captureBothSides = captureBothSides - self.selfieFile = selfieFile.flatMap { try? Data(contentsOf: $0) } + self.selfieFile = selfieFile self.jobType = jobType self.extraPartnerParams = extraPartnerParams } @@ -77,7 +77,7 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse } func acknowledgeInstructions() { - self.acknowledgedInstructions = true + acknowledgedInstructions = true } func onError(error: Error) { @@ -98,18 +98,18 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse } } - func onFinished(delegate: T) { + func onFinished(delegate _: T) { fatalError("Must override onFinished") } func submitJob() { - guard let documentFrontFile = documentFrontFile else { + guard let documentFrontFile else { // Set step to .frontDocumentCapture so that the Retry button goes back to this step step = .frontDocumentCapture onError(error: SmileIDError.unknown("Error getting document front file")) return } - guard let selfieFile = selfieFile else { + guard let selfieFile else { // Set step to .selfieCapture so that the Retry button goes back to this step step = .selfieCapture onError(error: SmileIDError.unknown("Error getting selfie file")) @@ -118,75 +118,112 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse DispatchQueue.main.async { self.step = .processing(.inProgress) } - - let zip: Data - do { - let savedFiles = try LocalStorage.saveDocumentImages( - front: documentFrontFile, - back: documentBackFile, - selfie: selfieFile, - livenessImages: livenessFiles, - countryCode: countryCode, - documentType: documentType - ) - let zipUrl = try LocalStorage.zipFiles(at: savedFiles.allFiles) - zip = try Data(contentsOf: zipUrl) - self.savedFiles = savedFiles - } catch { - print("Error saving document images: \(error)") - onError(error: SmileIDError.unknown("Error saving document images")) - return - } - - let authRequest = AuthenticationRequest( - jobType: jobType, - enrollment: false, - jobId: jobId, - userId: userId - ) - - let auth = SmileID.api.authenticate(request: authRequest) - networkingSubscriber = auth.flatMap { [self] authResponse in + Task { + let zip: Data + do { + var allFiles = [URL]() + let frontDocumentUrl = try LocalStorage.createDocumentFile( + jobId: jobId, + fileType: FileType.documentFront, + document: documentFrontFile + ) + allFiles.append(contentsOf: [selfieFile, frontDocumentUrl]) + var backDocumentUrl: URL? + if let documentBackFile { + let url = try LocalStorage.createDocumentFile( + jobId: jobId, + fileType: FileType.documentBack, + document: documentBackFile + ) + backDocumentUrl = url + allFiles.append(url) + } + if let livenessFiles { + allFiles.append(contentsOf: livenessFiles) + } + let info = try LocalStorage.createInfoJsonFile( + jobId: jobId, + idInfo: IdInfo(country: countryCode), + documentFront: frontDocumentUrl, + documentBack: backDocumentUrl, + selfie: selfieFile, + livenessImages: livenessFiles + ) + allFiles.append(info) + let zipUrl = try LocalStorage.zipFiles(at: allFiles) + zip = try Data(contentsOf: zipUrl) + self.savedFiles = DocumentCaptureResultStore( + allFiles: allFiles, + documentFront: frontDocumentUrl, + documentBack: backDocumentUrl, + selfie: selfieFile, + livenessImages: livenessFiles ?? [] + ) + let authRequest = AuthenticationRequest( + jobType: jobType, + enrollment: false, + jobId: jobId, + userId: userId + ) + if SmileID.allowOfflineMode { + try LocalStorage.saveOfflineJob( + jobId: jobId, + userId: userId, + jobType: jobType, + enrollment: false, + allowNewEnroll: allowNewEnroll, + partnerParams: extraPartnerParams + ) + } + let authResponse = try await SmileID.api.authenticate(request: authRequest).async() 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 timestamp: authResponse.timestamp, signature: authResponse.signature ) - return SmileID.api.prepUpload(request: prepUploadRequest) - } - .flatMap { prepUploadResponse in - SmileID.api.upload(zip: zip, to: prepUploadResponse.uploadUrl) - } - .zip(auth) - .flatMap { _, authResponse -> AnyPublisher, Error> in - let jobStatusRequest = JobStatusRequest( - userId: authResponse.partnerParams.userId, - jobId: authResponse.partnerParams.jobId, - includeImageLinks: false, - includeHistory: false, - timestamp: authResponse.timestamp, - signature: authResponse.signature - ) - return SmileID.api.getJobStatus(request: jobStatusRequest) - } - .sink( - receiveCompletion: { completion in - switch completion { - case .failure(let error): - print("Error submitting job: \(error)") - self.onError(error: SmileIDError.unknown("Network error")) - default: - break - } - }, - receiveValue: { response in - self.jobStatusResponse = response + let prepUploadResponse = try await SmileID.api.prepUpload(request: prepUploadRequest).async() + _ = try await SmileID.api.upload( + zip: zip, + to: prepUploadResponse.uploadUrl + ).async() + didSubmitJob = true + do { + try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) + } catch { + print("Error moving job to submitted directory: \(error)") + self.onError(error: error) + return + } + DispatchQueue.main.async { self.step = .processing(.success) } + } catch let error as SmileIDError { + do { + try LocalStorage.handleOfflineJobFailure( + jobId: self.jobId, + error: error + ) + } catch { + print("Error moving job to submitted directory: \(error)") + self.onError(error: error) + return + } + if SmileID.allowOfflineMode, LocalStorage.isNetworkFailure(error: error) { + didSubmitJob = true DispatchQueue.main.async { + self.errorMessage = "Offline.Message" self.step = .processing(.success) } + } else { + didSubmitJob = false + print("Error submitting job: \(error)") + self.onError(error: error) } - ) + } catch { + didSubmitJob = false + print("Error submitting job: \(error)") + self.onError(error: error) + } + } } /// If stepToRetry is ProcessingScreen, we're retrying a network issue, so we need to kick off @@ -207,9 +244,9 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse } extension IOrchestratedDocumentVerificationViewModel: SmartSelfieResultDelegate { - func didSucceed(selfieImage: URL, livenessImages: [URL], jobStatusResponse: SmartSelfieJobStatusResponse?) { - selfieFile = try? Data(contentsOf: selfieImage) - livenessFiles = livenessImages.compactMap { try? Data(contentsOf: $0) } + func didSucceed(selfieImage: URL, livenessImages: [URL], didSubmitSmartSelfieJob _: Bool) { + selfieFile = selfieImage + livenessFiles = livenessImages submitJob() } @@ -218,18 +255,19 @@ extension IOrchestratedDocumentVerificationViewModel: SmartSelfieResultDelegate } } -// swiftlint:disable colon +// swiftlint:disable opening_brace internal class OrchestratedDocumentVerificationViewModel: - IOrchestratedDocumentVerificationViewModel { + IOrchestratedDocumentVerificationViewModel +{ override func onFinished(delegate: DocumentVerificationResultDelegate) { - if let jobStatusResponse = jobStatusResponse, let savedFiles = savedFiles { + if let savedFiles { delegate.didSucceed( selfie: savedFiles.selfie, documentFrontImage: savedFiles.documentFront, documentBackImage: savedFiles.documentBack, - jobStatusResponse: jobStatusResponse + didSubmitDocumentVerificationJob: didSubmitJob ) - } else if let error = error { + } else if let error { // We check error as the 2nd case because as long as jobStatusResponse is not nil, it // was a success delegate.didError(error: error) @@ -239,19 +277,20 @@ internal class OrchestratedDocumentVerificationViewModel: } } -// swiftlint:disable colon +// swiftlint:disable opening_brace internal class OrchestratedEnhancedDocumentVerificationViewModel: // swiftlint:disable line_length - IOrchestratedDocumentVerificationViewModel { + IOrchestratedDocumentVerificationViewModel +{ override func onFinished(delegate: EnhancedDocumentVerificationResultDelegate) { - if let jobStatusResponse = jobStatusResponse, let savedFiles = savedFiles { + if let savedFiles { delegate.didSucceed( selfie: savedFiles.selfie, documentFrontImage: savedFiles.documentFront, documentBackImage: savedFiles.documentBack, - jobStatusResponse: jobStatusResponse + didSubmitEnhancedDocVJob: didSubmitJob ) - } else if let error = error { + } else if let error { // We check error as the 2nd case because as long as jobStatusResponse is not nil, it // was a success delegate.didError(error: error) diff --git a/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift b/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift index 9a2bf3e78..79bfb8e6b 100644 --- a/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift +++ b/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift @@ -197,7 +197,7 @@ private struct IOrchestratedDocumentVerificationScreen: View { skipApiSubmission: true, onResult: viewModel ) - case .processing(let state): + case let .processing(state): ProcessingScreen( processingState: state, inProgressTitle: SmileIDResourcesHelper.localizedString( @@ -211,12 +211,12 @@ private struct IOrchestratedDocumentVerificationScreen: View { for: "Document.Complete.Header" ), successSubtitle: SmileIDResourcesHelper.localizedString( - for: "Document.Complete.Callout" + for: $viewModel.errorMessage.wrappedValue ?? "Document.Complete.Callout" ), successIcon: SmileIDResourcesHelper.CheckBold, errorTitle: SmileIDResourcesHelper.localizedString(for: "Document.Error.Header"), errorSubtitle: SmileIDResourcesHelper.localizedString( - for: "Confirmation.FailureReason" + for: $viewModel.errorMessage.wrappedValue ?? "Confirmation.FailureReason" ), errorIcon: SmileIDResourcesHelper.Scan, continueButtonText: SmileIDResourcesHelper.localizedString( diff --git a/Sources/SmileID/Classes/Helpers/LocalStorage.swift b/Sources/SmileID/Classes/Helpers/LocalStorage.swift index 4fb5384d5..b5707118f 100644 --- a/Sources/SmileID/Classes/Helpers/LocalStorage.swift +++ b/Sources/SmileID/Classes/Helpers/LocalStorage.swift @@ -2,11 +2,13 @@ import Foundation import Zip public class LocalStorage { - private static let defaultFolderName = "sid_jobs" - private static let imagePrefix = "si_" + private static let defaultFolderName = "SmileID" + private static let unsubmittedFolderName = "unsubmitted" + private static let submittedFolderName = "submitted" private static let fileManager = FileManager.default private static let previewImageName = "PreviewImage.jpg" private static let jsonEncoder = JSONEncoder() + private static let jsonDecoder = JSONDecoder() static var defaultDirectory: URL { get throws { @@ -20,152 +22,199 @@ public class LocalStorage { } } - static func saveImage( - image: Data, - to folder: String = "sid-\(UUID().uuidString)", - name: String + static var unsubmittedJobDirectory: URL { + get throws { + try defaultDirectory.appendingPathComponent(unsubmittedFolderName) + } + } + + static var submittedJobDirectory: URL { + get throws { + try defaultDirectory.appendingPathComponent(submittedFolderName) + } + } + + private static func createSmileFile( + to folder: String, + name: String, + file data: Data ) throws -> URL { - try createDefaultDirectory() - let destinationFolder = try defaultDirectory.appendingPathComponent(folder) - try createDirectory(at: destinationFolder, overwrite: false) - let fileName = filename(for: name) - return try write(image, to: destinationFolder.appendingPathComponent(fileName)) + try createDirectory(at: unsubmittedJobDirectory) + let destinationFolder = try unsubmittedJobDirectory.appendingPathComponent(folder) + return try write(data, to: destinationFolder.appendingPathComponent(name)) } - static func saveSelfieImages( - selfieImage: Data, - livenessImages: [Data], - to folder: String = "sid-\(UUID().uuidString)" - ) throws -> SelfieCaptureResultStore { - try createDefaultDirectory() - let destinationFolder = try defaultDirectory.appendingPathComponent(folder) - var livenessUrls = [URL]() - try createDirectory(at: destinationFolder, overwrite: false) - var imageInfoArray = try livenessImages.map { [self] imageData in - let fileName = filename(for: "liveness") - let url = try write(imageData, to: destinationFolder.appendingPathComponent(fileName)) - livenessUrls.append(url) - return UploadImageInfo(imageTypeId: .livenessJpgFile, fileName: fileName) - } - let fileName = filename(for: "selfie") - let selfieUrl = try write( - selfieImage, - to: destinationFolder.appendingPathComponent(fileName) - ) - imageInfoArray.append(UploadImageInfo(imageTypeId: .selfieJpgFile, fileName: fileName)) - return SelfieCaptureResultStore( - selfie: selfieUrl, - livenessImages: livenessUrls - ) + private static func filename(for name: String) -> String { + "\(name)_\(Date().millisecondsSince1970).jpg" + } + + static func createSelfieFile( + jobId: String, + selfieFile data: Data + ) throws -> URL { + try createSmileFile(to: jobId, name: filename(for: FileType.selfie.name), file: data) + } + + static func createLivenessFile( + jobId: String, + livenessFile data: Data + ) throws -> URL { + try createSmileFile(to: jobId, name: filename(for: FileType.liveness.name), file: data) + } + + static func createDocumentFile( + jobId: String, + fileType: FileType, + document data: Data + ) throws -> URL { + try createSmileFile(to: jobId, name: filename(for: fileType.name), file: data) + } + + static func getFileByType( + jobId: String, + fileType: FileType + ) throws -> URL? { + let contents = try getDirectoryContents(jobId: jobId) + return contents.first(where: { $0.lastPathComponent.contains(fileType.name) })! + } + + static func getFilesByType( + jobId: String, + fileType: FileType + ) throws -> [URL]? { + let contents = try getDirectoryContents(jobId: jobId) + return contents.filter { $0.lastPathComponent.contains(fileType.name) } } - static func createInfoJson( - selfie: URL, - livenessImages: [URL], + static func createInfoJsonFile( + jobId: String, idInfo: IdInfo? = nil, - to folder: String = "sid-\(UUID().uuidString)" + documentFront: URL? = nil, + documentBack: URL? = nil, + selfie: URL? = nil, + livenessImages: [URL]? = nil ) throws -> URL { - try createDefaultDirectory() - let destinationFolder = try defaultDirectory.appendingPathComponent(folder) - var imageInfoArray: [UploadImageInfo] = [] - imageInfoArray.append( - UploadImageInfo(imageTypeId: .selfieJpgFile, fileName: selfie.lastPathComponent) - ) - for livenessImage in livenessImages { - imageInfoArray.append( + var imageInfoArray = [UploadImageInfo]() + if let selfie { + imageInfoArray.append(UploadImageInfo( + imageTypeId: .selfieJpgFile, + fileName: selfie.lastPathComponent + )) + } + if let livenessImages { + let livenessImageInfos = livenessImages.map { liveness in UploadImageInfo( imageTypeId: .livenessJpgFile, - fileName: livenessImage.lastPathComponent + fileName: liveness.lastPathComponent ) - ) - } - let jsonData = try jsonEncoder.encode(UploadRequest(images: imageInfoArray, idInfo: idInfo)) - let url = try write(jsonData, to: destinationFolder.appendingPathComponent("info.json")) - return url - } - - /// Saves front and back images of documents to disk, generates an `info.json` - /// and returns the url of all the files that have been saved - /// - Parameters: - /// - front: JPEG data representation ID image front - /// - back: JPEG data for the back of the ID image - /// - livenessImages: The selfie capture liveness images - /// - selfie: The selfie capture - /// - countryCode: The document country code - /// - documentType: The optional document type - /// - folder: The name of the folder the files should be saved - /// - Returns: A document result store which encapsulates the urls of the saved images - static func saveDocumentImages( - front: Data, - back: Data?, - selfie: Data, - livenessImages: [Data]?, - countryCode: String, - documentType: String?, - to folder: String = "sid-\(UUID().uuidString)" - ) throws -> DocumentCaptureResultStore { - try createDefaultDirectory() - let destinationFolder = try defaultDirectory.appendingPathComponent(folder) - var allFiles = [URL]() - var livenessImagesUrl = [URL]() - var documentBack: URL? - try createDirectory(at: destinationFolder, overwrite: false) - var imageInfoArray = [UploadImageInfo]() - let filename = filename(for: "idFront") - let documentFront = try write(front, to: destinationFolder.appendingPathComponent(filename)) - allFiles.append(documentFront) - imageInfoArray.append(UploadImageInfo(imageTypeId: .idCardJpgFile, fileName: filename)) - - if let back = back { - let filename = self.filename(for: "idBack") - let url = try write(back, to: destinationFolder.appendingPathComponent(filename)) - documentBack = url - allFiles.append(url) - imageInfoArray.append( - UploadImageInfo(imageTypeId: .idCardRearJpgFile, fileName: filename) - ) + } + imageInfoArray.append(contentsOf: livenessImageInfos) } - let livenessInfoArray = try livenessImages?.map { [self] imageData in - let fileName = self.filename(for: "liveness") - let url = try write(imageData, to: destinationFolder.appendingPathComponent(fileName)) - allFiles.append(url) - livenessImagesUrl.append(url) - return UploadImageInfo(imageTypeId: .livenessJpgFile, fileName: fileName) + if let documentFront { + imageInfoArray.append(UploadImageInfo( + imageTypeId: .idCardJpgFile, + fileName: documentFront.lastPathComponent + )) } - if let livenessInfoArray = livenessInfoArray { - imageInfoArray.append(contentsOf: livenessInfoArray) + if let documentBack { + imageInfoArray.append(UploadImageInfo( + imageTypeId: .idCardRearJpgFile, + fileName: documentBack.lastPathComponent + )) } - let selfieFileName = self.filename(for: "selfie") - let selfieUrl = try write( - selfie, - to: destinationFolder.appendingPathComponent(selfieFileName) - ) - allFiles.append(selfieUrl) - imageInfoArray.append( - UploadImageInfo(imageTypeId: .selfieJpgFile, fileName: selfieFileName) - ) - let idInfo = IdInfo(country: countryCode, idType: documentType) - let jsonData = try jsonEncoder.encode(UploadRequest(images: imageInfoArray, idInfo: idInfo)) - let jsonUrl = try write(jsonData, to: destinationFolder.appendingPathComponent("info.json")) - allFiles.append(jsonUrl) - return DocumentCaptureResultStore( - allFiles: allFiles, - documentFront: documentFront, - documentBack: documentBack, - selfie: selfieUrl, - livenessImages: livenessImagesUrl - ) + let data = try jsonEncoder.encode(UploadRequest( + images: imageInfoArray, + idInfo: idInfo + )) + return try createSmileFile(to: jobId, name: "info.json", file: data) + } + + static func getInfoJsonFile( + jobId: String + ) throws -> URL { + let contents = try getDirectoryContents(jobId: jobId) + return contents.first(where: { $0.lastPathComponent == "info.json" })! + } + + private static func createPrepUploadFile( + jobId: String, + prepUpload: PrepUploadRequest + ) throws -> URL { + let data = try jsonEncoder.encode(prepUpload) + return try createSmileFile(to: jobId, name: "prep_upload.json", file: data) + } + + static func fetchPrepUploadFile( + jobId: String + ) throws -> PrepUploadRequest { + let contents = try getDirectoryContents(jobId: jobId) + let preupload = contents.first(where: { $0.lastPathComponent == "prep_upload.json" }) + let data = try Data(contentsOf: preupload!) + return try jsonDecoder.decode(PrepUploadRequest.self, from: data) } - private static func createDefaultDirectory() throws { - try createDirectory(at: defaultDirectory, overwrite: false) + private static func createAuthenticationRequestFile( + jobId: String, + authentationRequest: AuthenticationRequest + ) throws -> URL { + let data = try jsonEncoder.encode(authentationRequest) + return try createSmileFile(to: jobId, name: "authentication_request.json", file: data) + } + + static func fetchAuthenticationRequestFile( + jobId: String + ) throws -> AuthenticationRequest { + let contents = try getDirectoryContents(jobId: jobId) + let authenticationrequest = contents.first(where: { $0.lastPathComponent == "authentication_request.json" }) + let data = try Data(contentsOf: authenticationrequest!) + return try jsonDecoder.decode(AuthenticationRequest.self, from: data) + } + + static func fetchUploadZip( + jobId: String + ) throws -> Data { + let contents = try getDirectoryContents(jobId: jobId) + let zipUrl = contents.first(where: { $0.lastPathComponent == "upload.zip" }) + return try Data(contentsOf: zipUrl!) } - private static func filename(for imageType: String) -> String { - "\(imagePrefix)\(imageType)_\(Date().millisecondsSince1970).jpg" + static func saveOfflineJob( + jobId: String, + userId: String, + jobType: JobType, + enrollment: Bool, + allowNewEnroll: Bool, + partnerParams: [String: String] + ) throws { + do { + _ = try createPrepUploadFile( + jobId: jobId, + prepUpload: PrepUploadRequest( + partnerParams: PartnerParams( + jobId: jobId, + userId: userId, + jobType: jobType, + extras: partnerParams + ), + allowNewEnroll: String(allowNewEnroll), + timestamp: "", // remove this so it is not stored offline + signature: "" // remove this so it is not stored offline + ) + ) + _ = try createAuthenticationRequestFile( + jobId: jobId, + authentationRequest: AuthenticationRequest( + jobType: jobType, + enrollment: enrollment, + jobId: jobId, + userId: userId, + authToken: "" // remove this so it is not stored offline + ) + ) + } } - static func write(_ data: Data, to url: URL) throws -> URL { + private static func write(_ data: Data, to url: URL, options completeFileProtection: Bool = true) throws -> URL { let directoryURL = url.deletingLastPathComponent() try fileManager.createDirectory( at: directoryURL, @@ -173,31 +222,77 @@ public class LocalStorage { attributes: nil ) if !fileManager.fileExists(atPath: url.relativePath) { - try data.write(to: url) + try data.write(to: url, options: completeFileProtection ? .completeFileProtection : []) return url } else { try fileManager.removeItem(atPath: url.relativePath) - try data.write(to: url) + try data.write(to: url, options: completeFileProtection ? .completeFileProtection : []) return url } } - static func createDirectory(at url: URL, overwrite: Bool = true) throws { - if !fileManager.fileExists(atPath: url.relativePath) { - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) - } else { - if overwrite { - try delete(at: url) - try createDirectory(at: url) - } + private static func createDirectory(at url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + private static func getDirectoryContents( + jobId: String + ) throws -> [URL] { + let folderPathURL = try unsubmittedJobDirectory.appendingPathComponent(jobId) + return try fileManager.contentsOfDirectory(at: folderPathURL, includingPropertiesForKeys: nil) + } + + static func getUnsubmittedJobs() -> [String] { + do { + return try fileManager.contentsOfDirectory(atPath: unsubmittedJobDirectory.relativePath) + } catch { + print("Error fetching unsubmitted jobs: \(error.localizedDescription)") + return [] + } + } + + static func getSubmittedJobs() -> [String] { + do { + return try fileManager.contentsOfDirectory(atPath: submittedJobDirectory.relativePath) + } catch { + print("Error fetching submitted jobs: \(error.localizedDescription)") + return [] } } + static func moveToSubmittedJobs(jobId: String) throws { + try createDirectory(at: submittedJobDirectory) + let unsubmittedFileDirectory = try unsubmittedJobDirectory.appendingPathComponent(jobId) + let submittedFileDirectory = try submittedJobDirectory.appendingPathComponent(jobId) + try fileManager.moveItem(at: unsubmittedFileDirectory, to: submittedFileDirectory) + } + + static func handleOfflineJobFailure( + jobId: String, + error: SmileIDError + ) throws { + if !(SmileID.allowOfflineMode && isNetworkFailure(error: error)) { + try LocalStorage.moveToSubmittedJobs(jobId: jobId) + } + } + + static func isNetworkFailure( + error: SmileIDError + ) -> Bool { + switch error { + case .httpError: + true + default: + false + } + } + + // todo - rework this as we change zip library public static func toZip( uploadRequest: UploadRequest, to folder: String = "sid-\(UUID().uuidString)" ) throws -> URL { - try createDefaultDirectory() + try createDirectory(at: defaultDirectory) let destinationFolder = try defaultDirectory.appendingPathComponent(folder) let jsonData = try jsonEncoder.encode(uploadRequest) let jsonUrl = try write(jsonData, to: destinationFolder.appendingPathComponent("info.json")) @@ -211,20 +306,23 @@ public class LocalStorage { try Zip.quickZipFiles(urls, fileName: "upload") } - static func delete(at url: URL) throws { + private static func delete(at url: URL) throws { if fileManager.fileExists(atPath: url.relativePath) { try fileManager.removeItem(atPath: url.relativePath) } } - static func delete(at urls: [URL]) throws { - for url in urls where fileManager.fileExists(atPath: url.relativePath) { - try fileManager.removeItem(atPath: url.relativePath) + static func delete(at jobIds: [String]) throws { + try jobIds.forEach { + let unsubmittedJob = try unsubmittedJobDirectory.appendingPathComponent($0) + try delete(at: unsubmittedJob) + let submittedJob = try submittedJobDirectory.appendingPathComponent($0) + try delete(at: submittedJob) } } static func deleteAll() throws { - if fileManager.fileExists(atPath: try defaultDirectory.relativePath) { + if try fileManager.fileExists(atPath: defaultDirectory.relativePath) { try fileManager.removeItem(atPath: defaultDirectory.relativePath) } } diff --git a/Sources/SmileID/Classes/Networking/APIError.swift b/Sources/SmileID/Classes/Networking/APIError.swift index aeb588f83..0ba206a3d 100644 --- a/Sources/SmileID/Classes/Networking/APIError.swift +++ b/Sources/SmileID/Classes/Networking/APIError.swift @@ -9,27 +9,33 @@ public enum SmileIDError: Error { case httpError(Int, Data) case jobStatusTimeOut case consentDenied + case invalidJobId + case fileNotFound(String) } extension SmileIDError: LocalizedError { public var errorDescription: String? { switch self { - case .encode(let error): - return String(describing: error) - case .request(let error): - return String(describing: error) - case .decode(let error): - return String(describing: error) - case .unknown(let message): - return message - case .httpError(let statusCode, let data): - return "HTTP Error with status code \(statusCode) and \(String(describing: data))" - case .api(_, let message): - return message - case .jobStatusTimeOut: - return "Job submitted successfully but polling job status timed out" - case .consentDenied: - return "Consent Denied" + case .encode(let error): + return String(describing: error) + case .request(let error): + return String(describing: error) + case .decode(let error): + return String(describing: error) + case .unknown(let message): + return message + case .httpError(let statusCode, let data): + return "HTTP Error with status code \(statusCode) and \(String(describing: data))" + case .api(_, let message): + return message + case .jobStatusTimeOut: + return "Job submitted successfully but polling job status timed out" + case .consentDenied: + return "Consent Denied" + case .invalidJobId: + return "Invalid jobId or not found" + case .fileNotFound(let message): + return message } } } diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift index e804c4c90..70ed3e9ef 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift @@ -2,6 +2,7 @@ import ARKit import Combine import Foundation +// swiftlint:disable opening_brace public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { // Constants private let intraImageMinDelay: TimeInterval = 0.35 @@ -32,14 +33,11 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { var previousHeadPitch = Double.infinity var previousHeadYaw = Double.infinity var isSmiling = false - var currentlyUsingArKit: Bool { - // false positive swift lint rule - // swiftlint:disable implicit_getter - get { ARFaceTrackingConfiguration.isSupported && !useBackCamera } - } + var currentlyUsingArKit: Bool { ARFaceTrackingConfiguration.isSupported && !useBackCamera } + var selfieImage: URL? var livenessImages: [URL] = [] - var jobStatusResponse: SmartSelfieJobStatusResponse? + internal var didSubmitSmartSelfieJob: Bool = false var error: Error? private let arKitFramePublisher = PassthroughSubject() @@ -47,6 +45,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { // UI Properties @Published var directive: String = "Instructions.Start" + @Published var errorMessage: String? @Published var processingState: ProcessingState? @Published var selfieToConfirm: Data? @Published var captureProgress: Double = 0 @@ -69,7 +68,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { self.allowNewEnroll = allowNewEnroll self.skipApiSubmission = skipApiSubmission self.extraPartnerParams = extraPartnerParams - self.cameraManager.sampleBufferPublisher + cameraManager.sampleBufferPublisher .merge(with: arKitFramePublisher) .throttle(for: 0.35, scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) // Drop the first ~2 seconds to allow the user to settle in @@ -88,7 +87,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { do { try faceDetector.detect(imageBuffer: image) { [self] request, error in - if let error = error { + if let error { print("Error analyzing image: \(error.localizedDescription)") self.error = error return @@ -108,8 +107,8 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { self.selfieToConfirm = nil self.processingState = nil } - self.selfieImage = nil - self.livenessImages = [] + selfieImage = nil + livenessImages = [] } return } @@ -135,7 +134,8 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { if boundingBox.minX < minFaceCenteredThreshold || boundingBox.minY < minFaceCenteredThreshold || boundingBox.maxX > maxFaceCenteredThreshold - || boundingBox.maxY > maxFaceCenteredThreshold { + || boundingBox.maxY > maxFaceCenteredThreshold + { DispatchQueue.main.async { self.directive = "Instructions.PutFaceInOval" } return } @@ -164,7 +164,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { } // TODO: Use mouth deformation as an alternate signal for non-ARKit capture - if userNeedsToSmile && currentlyUsingArKit && !isSmiling { + if userNeedsToSmile, currentlyUsingArKit, !isSmiling { return } @@ -186,7 +186,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { ) else { throw SmileIDError.unknown("Error resizing liveness image") } - let imageUrl = try LocalStorage.saveImage(image: imageData, name: "liveness") + let imageUrl = try LocalStorage.createLivenessFile(jobId: jobId, livenessFile: imageData) livenessImages.append(imageUrl) DispatchQueue.main.async { self.captureProgress = Double(self.livenessImages.count) / Double(self.numTotalSteps) @@ -200,7 +200,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { ) else { throw SmileIDError.unknown("Error resizing selfie image") } - let selfieImage = try LocalStorage.saveImage(image: imageData, name: "selfie") + let selfieImage = try LocalStorage.createSelfieFile(jobId: jobId, selfieFile: imageData) self.selfieImage = selfieImage DispatchQueue.main.async { self.captureProgress = 1 @@ -252,7 +252,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { } func switchCamera() { - self.cameraManager.switchCamera(to: useBackCamera ? .back : .front) + cameraManager.switchCamera(to: useBackCamera ? .back : .front) } func onSelfieRejected() { @@ -263,13 +263,12 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { } selfieImage = nil livenessImages = [] - jobStatusResponse = nil shouldAnalyzeImages = true } func onRetry() { // If selfie file is present, all captures were completed, so we're retrying a network issue - if selfieImage != nil && livenessImages.count == numLivenessImages { + if selfieImage != nil, livenessImages.count == numLivenessImages { submitJob() } else { shouldAnalyzeImages = true @@ -288,7 +287,8 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { guard let selfieImage, livenessImages.count == numLivenessImages else { throw SmileIDError.unknown("Selfie capture failed") } - let infoJson = try LocalStorage.createInfoJson( + let infoJson = try LocalStorage.createInfoJsonFile( + jobId: jobId, selfie: selfieImage, livenessImages: livenessImages ) @@ -303,6 +303,16 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { jobId: jobId, userId: userId ) + if SmileID.allowOfflineMode { + try LocalStorage.saveOfflineJob( + jobId: jobId, + userId: userId, + jobType: jobType, + enrollment: isEnroll, + allowNewEnroll: allowNewEnroll, + partnerParams: extraPartnerParams + ) + } let authResponse = try await SmileID.api.authenticate(request: authRequest).async() let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: extraPartnerParams), @@ -313,23 +323,43 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { let prepUploadResponse = try await SmileID.api.prepUpload( request: prepUploadRequest ).async() - let _ = try await SmileID.api.upload( + _ = try await SmileID.api.upload( zip: zip, to: prepUploadResponse.uploadUrl ).async() - let jobStatusRequest = JobStatusRequest( - userId: userId, - jobId: jobId, - includeImageLinks: false, - includeHistory: false, - timestamp: authResponse.timestamp, - signature: authResponse.signature - ) - jobStatusResponse = try await SmileID.api.getJobStatus( - request: jobStatusRequest - ).async() + didSubmitSmartSelfieJob = true + do { + try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) + } catch { + print("Error moving job to submitted directory: \(error)") + self.error = error + } DispatchQueue.main.async { self.processingState = .success } + } catch let error as SmileIDError { + do { + try LocalStorage.handleOfflineJobFailure( + jobId: self.jobId, + error: error + ) + } catch { + print("Error moving job to submitted directory: \(error)") + self.error = error + return + } + if SmileID.allowOfflineMode, LocalStorage.isNetworkFailure(error: error) { + didSubmitSmartSelfieJob = true + DispatchQueue.main.async { + self.errorMessage = "Offline.Message" + self.processingState = .success + } + } else { + didSubmitSmartSelfieJob = false + print("Error submitting job: \(error)") + self.error = error + DispatchQueue.main.async { self.processingState = .error } + } } catch { + didSubmitSmartSelfieJob = false print("Error submitting job: \(error)") self.error = error DispatchQueue.main.async { self.processingState = .error } @@ -342,7 +372,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { callback.didSucceed( selfieImage: selfieImage, livenessImages: livenessImages, - jobStatusResponse: jobStatusResponse + didSubmitSmartSelfieJob: didSubmitSmartSelfieJob ) } else if let error { callback.didError(error: error) diff --git a/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift b/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift index 87381b07d..285efb5c8 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift @@ -1,16 +1,16 @@ -/// The result of a selfie capture session and job submission - import Foundation + +/// The result of a selfie capture session and job submission public protocol SmartSelfieResultDelegate { /// This function is called as a result of a successful selfie capture /// - Parameters: /// - selfieImage: The local url of the colour selfie image captured /// - livenessImages: An array of local urls of images captured for liveness checks - /// - jobStatusResponse: The response object after submitting the job. If nil, it means API submission was skipped + /// - didSubmitSmartSelfieJob: Indicates whether the job was submitted to the SmileID backend (e.g. it would be false in offline mode) func didSucceed( selfieImage: URL, livenessImages: [URL], - jobStatusResponse: SmartSelfieJobStatusResponse? + didSubmitSmartSelfieJob: Bool ) /// An error occurred during the selfie capture session diff --git a/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreen.swift b/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreen.swift index 3a32bd082..d5659b1a5 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreen.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreen.swift @@ -29,7 +29,7 @@ public struct OrchestratedSelfieCaptureScreen: View { self.showAttribution = showAttribution self.showInstructions = showInstructions self.onResult = onResult - self.viewModel = SelfieViewModel( + viewModel = SelfieViewModel( isEnroll: isEnroll, userId: userId, jobId: jobId, @@ -40,7 +40,7 @@ public struct OrchestratedSelfieCaptureScreen: View { } public var body: some View { - if showInstructions && !acknowledgedInstructions { + if showInstructions, !acknowledgedInstructions { SmartSelfieInstructionsScreen(showAttribution: showAttribution) { acknowledgedInstructions = true } @@ -58,14 +58,14 @@ public struct OrchestratedSelfieCaptureScreen: View { for: "Confirmation.SelfieCaptureComplete" ), successSubtitle: SmileIDResourcesHelper.localizedString( - for: "Confirmation.SuccessBody" + for: $viewModel.errorMessage.wrappedValue ?? "Confirmation.SuccessBody" ), successIcon: SmileIDResourcesHelper.CheckBold, errorTitle: SmileIDResourcesHelper.localizedString( for: "Confirmation.Failure" ), errorSubtitle: SmileIDResourcesHelper.localizedString( - for: "Confirmation.FailureReason" + for: $viewModel.errorMessage.wrappedValue ?? "Confirmation.FailureReason" ), errorIcon: SmileIDResourcesHelper.Scan, continueButtonText: SmileIDResourcesHelper.localizedString( @@ -105,8 +105,8 @@ public struct OrchestratedSelfieCaptureScreen: View { allowAgentMode: allowAgentMode, viewModel: viewModel ) - .onAppear { UIScreen.main.brightness = 1 } - .onDisappear { UIScreen.main.brightness = originalBrightness } + .onAppear { UIScreen.main.brightness = 1 } + .onDisappear { UIScreen.main.brightness = originalBrightness } } } } diff --git a/Sources/SmileID/Classes/SmileID.swift b/Sources/SmileID/Classes/SmileID.swift index 21927b675..91f5e7249 100644 --- a/Sources/SmileID/Classes/SmileID.swift +++ b/Sources/SmileID/Classes/SmileID.swift @@ -3,7 +3,7 @@ import SwiftUI import UIKit public class SmileID { - public static let version = "10.1.0" + public static let version = "10.1.1" @Injected var injectedApi: SmileIDServiceable public static var configuration: Config { config } @@ -22,6 +22,7 @@ public class SmileID { public private(set) static var config: Config! public private(set) static var useSandbox = false + public private(set) static var allowOfflineMode = true public private(set) static var callbackUrl: String = "" internal static var apiKey: String? public private(set) static var theme: SmileIdTheme = DefaultTheme() @@ -64,6 +65,121 @@ public class SmileID { SmileID.useSandbox = useSandbox } + /// Sets the state of offline mode for the SDK. + /// This function enables or disables the SDK's ability to operate in offline mode, + /// where it can continue functioning without an active internet connection. When offline mode + /// is enabled (allowOfflineMode = true), the SDK will attempt to use capture and cache + /// images in local file storage and will not attempt to submit the job. Conversely, when offline + /// mode is disabled (allowOfflineMode = false), the application will require an active internet + /// connection for all operations that involve data fetching or submission. + /// + /// - Parameter allowOfflineMode: A Boolean value indicating whether offline mode should + /// be enabled (true) or disabled (false). + public class func setAllowOfflineMode(allowOfflineMode: Bool) { + SmileID.allowOfflineMode = allowOfflineMode + } + + /// Retrieves a list of unsubmitted job IDs. + public class func getUnsubmittedJobs() -> [String] { + LocalStorage.getUnsubmittedJobs() + } + + /// Retrieves a list of submitted job IDs. + public class func getSubmittedJobs() -> [String] { + LocalStorage.getSubmittedJobs() + } + + /// Initiates the cleanup process for a single job by its ID. + /// This is a convenience method that wraps the cleanup process, allowing for a single job ID + /// to be specified for cleanup. + /// + /// - Parameter jobId: the job IDs to clean up. + public class func cleanup(jobId: String) throws { + try cleanup(jobIds: [jobId]) + } + + /// Initiates the cleanup process for multiple jobs by their IDs. + /// If no IDs are provided, a default cleanup process is initiated that may target + /// specific jobs based on the implementation in com.smileidentity.util.cleanup. + /// + /// - Parameter jobIds: An optional list of job IDs to clean up. If null, the method defaults + /// to a predefined cleanup process. + public class func cleanup(jobIds: [String]? = nil) throws { + if let jobIds { + try LocalStorage.delete(at: jobIds) + } else { + try LocalStorage.deleteAll() + } + } + + /// Submits a previously captured job to SmileID for processing. + /// + /// - Parameters: + /// - jobId: The unique identifier for the job to be submitted. + public class func submitJob( + jobId: String, + deleteFilesOnSuccess: Bool = true + ) throws { + let jobIds = LocalStorage.getUnsubmittedJobs() + if !jobIds.contains(jobId) { + throw SmileIDError.invalidJobId + } + guard let authRequestFile = try? LocalStorage.fetchAuthenticationRequestFile(jobId: jobId) else { + throw SmileIDError.fileNotFound("Authentication Request file is missing") + } + guard let prepUploadFile = try? LocalStorage.fetchPrepUploadFile(jobId: jobId) else { + throw SmileIDError.fileNotFound("Prep Upload file is missing") + } + Task { + let zip: Data + do { + let authRequest = AuthenticationRequest( + jobType: authRequestFile.jobType, + enrollment: authRequestFile.enrollment, + jobId: authRequestFile.jobId, + userId: authRequestFile.userId + ) + let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let prepUploadRequest = PrepUploadRequest( + partnerParams: authResponse.partnerParams.copy(extras: prepUploadFile.partnerParams.extras), + allowNewEnroll: String(prepUploadFile.allowNewEnroll), // TODO - Fix when Michael changes this to boolean + timestamp: authResponse.timestamp, + signature: authResponse.signature + ) + let prepUploadResponse = try await SmileID.api.prepUpload(request: prepUploadRequest).async() + let allFiles = try LocalStorage.getFilesByType(jobId: jobId, fileType: FileType.liveness)! + [ + try LocalStorage.getFileByType(jobId: jobId, fileType: FileType.selfie), + try LocalStorage.getFileByType(jobId: jobId, fileType: FileType.documentFront), + try LocalStorage.getFileByType(jobId: jobId, fileType: FileType.documentBack), + try LocalStorage.getInfoJsonFile(jobId: jobId) + ].compactMap { $0 } + let zipUrl = try LocalStorage.zipFiles(at: allFiles) + zip = try Data(contentsOf: zipUrl) + _ = try await SmileID.api.upload( + zip: zip, + to: prepUploadResponse.uploadUrl + ).async() + if deleteFilesOnSuccess { + do { + try LocalStorage.delete(at: [jobId]) + } catch { + print("Error deleting submitted job: \(error)") + } + } else { + do { + try LocalStorage.moveToSubmittedJobs(jobId: jobId) + } catch { + print("Error moving job to submitted directory: \(error)") + } + } + print("Upload finished") + } catch { + print("Error submitting job: \(error)") + throw error + } + } + } + /// Set the callback URL for all submitted jobs. If no value is set, the default callback URL /// from the partner portal will be used. /// - Parameter url: A valid URL pointing to your server diff --git a/Sources/SmileID/Classes/Util.swift b/Sources/SmileID/Classes/Util.swift index 2ade680fc..f42b009d1 100644 --- a/Sources/SmileID/Classes/Util.swift +++ b/Sources/SmileID/Classes/Util.swift @@ -35,3 +35,16 @@ private struct StackedShape: Shape { } } } + +extension String: Error {} + +enum FileType: String { + case selfie = "si_selfie" + case liveness = "si_liveness" + case documentFront = "si_document_front" + case documentBack = "si_document_back" + + var name: String { + return rawValue + } +} diff --git a/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings b/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings index 51c563cc5..e2e0c1d65 100644 --- a/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings +++ b/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings @@ -92,3 +92,5 @@ "BiometricKYC.Success.Subtitle" = "You may now proceed"; "BiometricKYC.Error.Title" = "Your submission failed to process"; "BiometricKYC.Error.Subtitle" = "This could be because of image quality or internet connectivity"; + +"Offline.Message" = "No internet connection. Your verification will be completed once you are back online"; diff --git a/Tests/Mocks/NetworkingMocks.swift b/Tests/Mocks/NetworkingMocks.swift index 65444e623..4f2d29fd1 100644 --- a/Tests/Mocks/NetworkingMocks.swift +++ b/Tests/Mocks/NetworkingMocks.swift @@ -265,7 +265,7 @@ class MockResultDelegate: SmartSelfieResultDelegate { func didSucceed( selfieImage _: URL, livenessImages _: [URL], - jobStatusResponse _: JobStatusResponse? + didSubmitSmartSelfieJob: Bool ) { successExpectation?.fulfill() }