diff --git a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift index d9c25a5e..4f952058 100644 --- a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift +++ b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift @@ -1,12 +1,12 @@ import Combine import Foundation -internal enum BiometricKycStep { +enum BiometricKycStep { case selfie case processing(ProcessingState) } -internal class OrchestratedBiometricKycViewModel: ObservableObject { +class OrchestratedBiometricKycViewModel: BaseSubmissionViewModel { // MARK: - Input Properties private let userId: String @@ -18,8 +18,8 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { // MARK: - Other Properties - internal var selfieFile: URL? - internal var livenessFiles: [URL]? + var selfieFile: URL? + var livenessFiles: [URL]? private var error: Error? private var didSubmitBiometricJob: Bool = false @@ -56,8 +56,9 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { func onFinished(delegate: BiometricKycResultDelegate) { if let selfieFile = selfieFile, - let livenessFiles = livenessFiles, - let selfiePath = getRelativePath(from: selfieFile) { + let livenessFiles = livenessFiles, + let selfiePath = getRelativePath(from: selfieFile) + { delegate.didSucceed( selfieImage: selfiePath, livenessImages: livenessFiles.compactMap { getRelativePath(from: $0) }, @@ -75,7 +76,6 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { Task { do { try await handleJobSubmission() - updateStep(.processing(.success)) } catch let error as SmileIDError { handleSubmissionFailure(error) } catch { @@ -89,17 +89,13 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { private func handleJobSubmission() async throws { try fetchRequiredFiles() - - let zipData = try createZipData() - - let authResponse = try await authenticate() - - let preUploadResponse = try await prepareForUpload(authResponse: authResponse) - - try await uploadFiles(zipData: zipData, uploadUrl: preUploadResponse.uploadUrl) - didSubmitBiometricJob = true - - try moveJobToSubmittedDirectory() + let infoJson = try LocalStorage.createInfoJsonFile( + jobId: jobId, + idInfo: idInfo.copy(entered: true), + selfie: selfieFile, + livenessImages: livenessFiles + ) + submitJob(jobId: jobId, skipApiSubmission: false, offlineMode: SmileID.allowOfflineMode) } private func fetchRequiredFiles() throws { @@ -121,40 +117,6 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { } } - private func createZipData() throws -> Data { - var allFiles = [URL]() - let infoJson = try LocalStorage.createInfoJsonFile( - jobId: jobId, - idInfo: idInfo.copy(entered: true), - selfie: selfieFile, - livenessImages: livenessFiles - ) - if let selfieFile { - allFiles.append(contentsOf: [selfieFile, infoJson]) - } - if let livenessFiles { - allFiles.append(contentsOf: livenessFiles) - } - return try LocalStorage.zipFiles(at: allFiles) - } - - private func authenticate() async throws -> AuthenticationResponse { - let authRequest = AuthenticationRequest( - jobType: .biometricKyc, - enrollment: false, - jobId: jobId, - userId: userId, - country: idInfo.country, - idType: idInfo.idType - ) - - if SmileID.allowOfflineMode { - try saveOfflineJobIfAllowed() - } - - return try await SmileID.api.authenticate(request: authRequest) - } - private func saveOfflineJobIfAllowed() throws { try LocalStorage.saveOfflineJob( jobId: jobId, @@ -167,38 +129,8 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { ) } - private func prepareForUpload(authResponse: AuthenticationResponse) async throws -> PrepUploadResponse { - let prepUploadRequest = PrepUploadRequest( - partnerParams: authResponse.partnerParams.copy(extras: extraPartnerParams), - allowNewEnroll: String(allowNewEnroll), // TODO: - Fix when Michael changes this to boolean - metadata: localMetadata.metadata.items, - timestamp: authResponse.timestamp, - signature: authResponse.signature - ) - do { - return try await SmileID.api.prepUpload( - request: prepUploadRequest - ) - } catch let error as SmileIDError { - guard case let .api(errorCode, _) = error, - errorCode == "2215" - else { - throw error - } - return try await SmileID.api.prepUpload( - request: prepUploadRequest.copy(retry: "true")) - } - } - - private func uploadFiles(zipData: Data, uploadUrl: String) async throws { - try await SmileID.api.upload( - zip: zipData, - to: uploadUrl - ) - } - private func moveJobToSubmittedDirectory() throws { - try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) + try LocalStorage.moveToSubmittedJobs(jobId: jobId) } private func updateStep(_ newStep: BiometricKycStep) { @@ -220,7 +152,7 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { private func handleSubmissionFailure(_ smileIDError: SmileIDError) { do { _ = try LocalStorage.handleOfflineJobFailure( - jobId: self.jobId, + jobId: jobId, error: smileIDError ) } catch { @@ -237,11 +169,72 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { didSubmitBiometricJob = false print("Error submitting job: \(smileIDError)") let (errorMessageRes, errorMessage) = toErrorMessage(error: smileIDError) - self.error = smileIDError + error = smileIDError updateErrorMessages(errorMessage: errorMessage, errorMessageRes: errorMessageRes) updateStep(.processing(.error)) } } + + override public func createSubmission() throws -> BaseJobSubmission { + guard let selfieImage = selfieFile, + livenessFiles != nil + else { + throw SmileIDError.unknown("Selfie images missing") + } + return BiometricKYCSubmission( + userId: userId, + jobId: jobId, + allowNewEnroll: allowNewEnroll, + livenessFiles: livenessFiles, + selfieFile: selfieImage, + idInfo: idInfo, + extraPartnerParams: extraPartnerParams, + metadata: localMetadata.metadata + ) + } + + override public func triggerProcessingState() { + DispatchQueue.main.async { self.step = .processing(ProcessingState.inProgress) } + } + + override public func handleSuccess(data _: BiometricKycResult) { + DispatchQueue.main.async { self.step = .processing(ProcessingState.success) } + } + + override public func handleError(error: Error) { + if let smileError = error as? SmileIDError { + print("Error submitting job: \(error)") + let (errorMessageRes, errorMessage) = toErrorMessage(error: smileError) + self.error = error + self.errorMessageRes = errorMessageRes + self.errorMessage = errorMessage + DispatchQueue.main.async { self.step = .processing(ProcessingState.error) } + } else { + print("Error submitting job: \(error)") + self.error = error + DispatchQueue.main.async { self.step = .processing(ProcessingState.error) } + } + } + + override public func handleSubmissionFiles(jobId: String) throws { + selfieFile = try LocalStorage.getFileByType( + jobId: jobId, + fileType: FileType.selfie, + submitted: true + ) + livenessFiles = try LocalStorage.getFilesByType( + jobId: jobId, + fileType: FileType.liveness, + submitted: true + ) ?? [] + } + + override public func handleOfflineSuccess() { + DispatchQueue.main.async { + self.errorMessageRes = "Offline.Message" + self.step = .processing(ProcessingState.success) + } + } } extension OrchestratedBiometricKycViewModel: SmartSelfieResultDelegate { diff --git a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift index cdf576d5..ca2869da 100644 --- a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift +++ b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift @@ -8,7 +8,7 @@ enum DocumentCaptureFlow: Equatable { case processing(ProcessingState) } -class IOrchestratedDocumentVerificationViewModel: ObservableObject { +class IOrchestratedDocumentVerificationViewModel: BaseSubmissionViewModel { // Input properties let userId: String let jobId: String @@ -163,7 +163,7 @@ class IOrchestratedDocumentVerificationViewModel: ObservableObj if let livenessFiles { allFiles.append(contentsOf: livenessFiles) } - let info = try LocalStorage.createInfoJsonFile( + let infoJson = try LocalStorage.createInfoJsonFile( jobId: jobId, idInfo: IdInfo(country: countryCode, idType: documentType), documentFront: frontDocumentUrl, @@ -171,123 +171,7 @@ class IOrchestratedDocumentVerificationViewModel: ObservableObj selfie: selfieFile, livenessImages: livenessFiles ) - allFiles.append(info) - let zipData = try LocalStorage.zipFiles(at: allFiles) - self.savedFiles = DocumentCaptureResultStore( - allFiles: allFiles, - documentFront: frontDocumentUrl, - documentBack: backDocumentUrl, - selfie: selfieFile, - livenessImages: livenessFiles ?? [] - ) - if skipApiSubmission { - DispatchQueue.main.async { self.step = .processing(.success) } - return - } - 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, - localMetadata: localMetadata, - partnerParams: extraPartnerParams - ) - } - 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 - metadata: localMetadata.metadata.items, - timestamp: authResponse.timestamp, - signature: authResponse.signature - ) - let prepUploadResponse: PrepUploadResponse - do { - prepUploadResponse = try await SmileID.api.prepUpload( - request: prepUploadRequest - ) - } catch let error as SmileIDError { - switch error { - case .api("2215", _): - prepUploadResponse = try await SmileID.api.prepUpload( - request: prepUploadRequest.copy(retry: "true") - ) - default: - throw error - } - } - let _ = try await SmileID.api.upload( - zip: zipData, - to: prepUploadResponse.uploadUrl - ) - didSubmitJob = true - do { - try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) - self.selfieFile = - try LocalStorage.getFileByType( - jobId: jobId, - fileType: FileType.selfie, - submitted: true - ) ?? selfieFile - self.livenessFiles = - try LocalStorage.getFilesByType( - jobId: jobId, - fileType: FileType.liveness, - submitted: true - ) ?? [] - } 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 { - let didMove = try LocalStorage.handleOfflineJobFailure( - jobId: self.jobId, - error: error - ) - if didMove { - self.selfieFile = - try LocalStorage.getFileByType( - jobId: jobId, - fileType: FileType.selfie, - submitted: true - ) ?? selfieFile - self.livenessFiles = - try LocalStorage.getFilesByType( - jobId: jobId, - fileType: FileType.liveness, - submitted: true - ) ?? [] - } - } catch { - print("Error moving job to submitted directory: \(error)") - self.onError(error: error) - return - } - if SmileID.allowOfflineMode, SmileIDError.isNetworkFailure(error: error) { - didSubmitJob = true - DispatchQueue.main.async { - self.errorMessageRes = "Offline.Message" - self.step = .processing(.success) - } - } else { - didSubmitJob = false - print("Error submitting job: \(error)") - self.onError(error: error) - let (errorMessageRes, errorMessage) = toErrorMessage(error: error) - self.errorMessageRes = errorMessageRes - self.errorMessage = errorMessage - } + submitJob(jobId: self.jobId, skipApiSubmission: false, offlineMode: SmileID.allowOfflineMode) } catch { didSubmitJob = false print("Error submitting job: \(error)") @@ -296,6 +180,49 @@ class IOrchestratedDocumentVerificationViewModel: ObservableObj } } + override public func triggerProcessingState() { + DispatchQueue.main.async { self.step = .processing(ProcessingState.inProgress) } + } + + override public func handleSuccess(data _: S) { + DispatchQueue.main.async { self.step = .processing(ProcessingState.success) } + } + + override public func handleError(error: Error) { + if let smileError = error as? SmileIDError { + print("Error submitting job: \(error)") + let (errorMessageRes, errorMessage) = toErrorMessage(error: smileError) + self.error = error + self.errorMessageRes = errorMessageRes + self.errorMessage = errorMessage + DispatchQueue.main.async { self.step = .processing(ProcessingState.error) } + } else { + print("Error submitting job: \(error)") + self.error = error + DispatchQueue.main.async { self.step = .processing(ProcessingState.error) } + } + } + + override public func handleSubmissionFiles(jobId: String) throws { + selfieFile = try LocalStorage.getFileByType( + jobId: jobId, + fileType: FileType.selfie, + submitted: true + ) + livenessFiles = try LocalStorage.getFilesByType( + jobId: jobId, + fileType: FileType.liveness, + submitted: true + ) ?? [] + } + + override public func handleOfflineSuccess() { + DispatchQueue.main.async { + self.errorMessageRes = "Offline.Message" + self.step = .processing(ProcessingState.success) + } + } + /// If stepToRetry is ProcessingScreen, we're retrying a network issue, so we need to kick off /// the resubmission manually. Otherwise, we're retrying a capture error, so we just need to /// reset the UI state @@ -329,8 +256,26 @@ extension IOrchestratedDocumentVerificationViewModel: SmartSelfieResultDelegate // swiftlint:disable opening_brace class OrchestratedDocumentVerificationViewModel: - IOrchestratedDocumentVerificationViewModel + IOrchestratedDocumentVerificationViewModel { + override func createSubmission() throws -> BaseJobSubmission { + guard let selfieImage = selfieFile, + livenessFiles != nil + else { + throw SmileIDError.unknown("Selfie image missing") + } + guard let documentFront = try LocalStorage.getFileByType(jobId: jobId, fileType: .documentFront) else { + throw SmileIDError.unknown("Document image missing") + } + let documentBack = try LocalStorage.getFileByType(jobId: jobId, fileType: .documentBack) + return DocumentVerificationSubmission(jobId: jobId, userId: userId, countryCode: countryCode, + allowNewEnroll: allowNewEnroll, documentFrontFile: documentFront, + selfieFile: selfieImage, documentBackFile: documentBack, + livenessFiles: livenessFiles, + extraPartnerParams: extraPartnerParams) + } + override func onFinished(delegate: DocumentVerificationResultDelegate) { if let savedFiles, let selfiePath = getRelativePath(from: selfieFile), @@ -354,11 +299,27 @@ class OrchestratedDocumentVerificationViewModel: } // swiftlint:disable opening_brace -class OrchestratedEnhancedDocumentVerificationViewModel: - IOrchestratedDocumentVerificationViewModel< - EnhancedDocumentVerificationResultDelegate, EnhancedDocumentVerificationJobResult - > -{ +class OrchestratedEnhancedDocumentVerificationViewModel: IOrchestratedDocumentVerificationViewModel + { + override func createSubmission() throws -> BaseJobSubmission { + guard let selfieImage = selfieFile, + livenessFiles != nil + else { + throw SmileIDError.unknown("Selfie image missing") + } + guard let documentFront = try LocalStorage.getFileByType(jobId: jobId, fileType: .documentFront) else { + throw SmileIDError.unknown("Document image missing") + } + let documentBack = try LocalStorage.getFileByType(jobId: jobId, fileType: .documentBack) + return EnhancedDocumentVerificationSubmission(jobId: jobId, userId: userId, countryCode: countryCode, + documentType: documentType, allowNewEnroll: allowNewEnroll, + documentFrontFile: documentFront, selfieFile: selfieImage, + documentBackFile: documentBack, livenessFiles: livenessFiles, + extraPartnerParams: extraPartnerParams) + } + override func onFinished(delegate: EnhancedDocumentVerificationResultDelegate) { if let savedFiles, let selfiePath = getRelativePath(from: selfieFile), diff --git a/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift b/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift index 577127e9..fdab6dd8 100644 --- a/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift +++ b/Sources/SmileID/Classes/DocumentVerification/View/OrchestratedDocumentVerificationScreen.swift @@ -104,7 +104,7 @@ struct OrchestratedEnhancedDocumentVerificationScreen: View { } } -private struct IOrchestratedDocumentVerificationScreen: View { +private struct IOrchestratedDocumentVerificationScreen: View { let countryCode: String let documentType: String? let captureBothSides: Bool @@ -120,7 +120,7 @@ private struct IOrchestratedDocumentVerificationScreen: View { let skipApiSubmission: Bool var extraPartnerParams: [String: String] let onResult: T - @ObservedObject var viewModel: IOrchestratedDocumentVerificationViewModel + @ObservedObject var viewModel: IOrchestratedDocumentVerificationViewModel init( countryCode: String, @@ -138,7 +138,7 @@ private struct IOrchestratedDocumentVerificationScreen: View { skipApiSubmission: Bool, extraPartnerParams: [String: String], onResult: T, - viewModel: IOrchestratedDocumentVerificationViewModel + viewModel: IOrchestratedDocumentVerificationViewModel ) { self.countryCode = countryCode self.documentType = documentType diff --git a/Sources/SmileID/Classes/Helpers/LocalStorage.swift b/Sources/SmileID/Classes/Helpers/LocalStorage.swift index 0b9bc7ab..56b4a309 100644 --- a/Sources/SmileID/Classes/Helpers/LocalStorage.swift +++ b/Sources/SmileID/Classes/Helpers/LocalStorage.swift @@ -138,7 +138,7 @@ public class LocalStorage { return contents.first(where: { $0.lastPathComponent == "info.json" })! } - private static func createPrepUploadFile( + static func createPrepUploadFile( jobId: String, prepUpload: PrepUploadRequest ) throws -> URL { @@ -155,7 +155,7 @@ public class LocalStorage { return try jsonDecoder.decode(PrepUploadRequest.self, from: data) } - private static func createAuthenticationRequestFile( + static func createAuthenticationRequestFile( jobId: String, authentationRequest: AuthenticationRequest ) throws -> URL { @@ -383,3 +383,21 @@ public extension Date { Int64((timeIntervalSince1970 * 1000.0).rounded()) } } + +public extension URL { + func asDocumentFrontImage() -> UploadImageInfo { + return UploadImageInfo(imageTypeId: .idCardJpgFile, fileName: lastPathComponent) + } + + func asDocumentBackImage() -> UploadImageInfo { + return UploadImageInfo(imageTypeId: .idCardRearJpgFile, fileName: lastPathComponent) + } + + func asSelfieImage() -> UploadImageInfo { + return UploadImageInfo(imageTypeId: .selfieJpgFile, fileName: lastPathComponent) + } + + func asLivenessImage() -> UploadImageInfo { + return UploadImageInfo(imageTypeId: .livenessJpgFile, fileName: lastPathComponent) + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/Base/BaseDocumentVerificationSubmission.swift b/Sources/SmileID/Classes/JobSubmission/Base/BaseDocumentVerificationSubmission.swift new file mode 100644 index 00000000..8ab059b0 --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/Base/BaseDocumentVerificationSubmission.swift @@ -0,0 +1,125 @@ +// +// BaseDocumentVerificationSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public class BaseDocumentVerificationSubmission: BaseJobSubmission { + // MARK: - Properties + + private let userId: String + private let jobType: JobType + private let countryCode: String + private let documentType: String? + private let allowNewEnroll: Bool + private let documentFrontFile: URL + private let selfieFile: URL + private let documentBackFile: URL? + private let livenessFiles: [URL]? + private let extraPartnerParams: [String: String] + private let metadata: [Metadatum]? + + // MARK: - Initialization + + public init( + jobId: String, + userId: String, + jobType: JobType, + countryCode: String, + documentType: String?, + allowNewEnroll: Bool, + documentFrontFile: URL, + selfieFile: URL, + documentBackFile: URL? = nil, + livenessFiles: [URL]? = nil, + extraPartnerParams: [String: String], + metadata: [Metadatum]? = nil + ) { + self.userId = userId + self.jobType = jobType + self.countryCode = countryCode + self.documentType = documentType + self.allowNewEnroll = allowNewEnroll + self.documentFrontFile = documentFrontFile + self.selfieFile = selfieFile + self.documentBackFile = documentBackFile + self.livenessFiles = livenessFiles + self.extraPartnerParams = extraPartnerParams + self.metadata = metadata + super.init(jobId: jobId) + } + + // MARK: - Overridden Methods + + override public func createAuthRequest() -> AuthenticationRequest { + return AuthenticationRequest( + jobType: jobType, + enrollment: false, + jobId: jobId, + userId: userId, + country: countryCode, + idType: documentType + ) + } + + override public func createPrepUploadRequest(authResponse: AuthenticationResponse? = nil) -> PrepUploadRequest { + let partnerParams = authResponse?.partnerParams.copy(extras: extraPartnerParams) + ?? PartnerParams(jobId: jobId, userId: userId, jobType: jobType, extras: extraPartnerParams) + + return PrepUploadRequest( + partnerParams: partnerParams, + allowNewEnroll: String(allowNewEnroll), + metadata: metadata, + timestamp: authResponse?.timestamp ?? "", + signature: authResponse?.signature ?? "" + ) + } + + override public func createUploadRequest(authResponse _: AuthenticationResponse?) -> UploadRequest { + let frontImageInfo = documentFrontFile.asDocumentFrontImage() + let backImageInfo = documentBackFile?.asDocumentBackImage() + let selfieImageInfo = selfieFile.asSelfieImage() + let livenessImageInfo = livenessFiles?.map { $0.asLivenessImage() } ?? [] + + return UploadRequest( + images: [frontImageInfo] + + (backImageInfo.map { [$0] } ?? []) + + [selfieImageInfo] + + livenessImageInfo, + idInfo: IdInfo(country: countryCode, idType: documentType) + ) + } + + override public func createSuccessResult(didSubmit: Bool) async throws -> + SmileIDResult.Success { + let result = createResultInstance(selfieFile: selfieFile, + documentFrontFile: documentFrontFile, + livenessFiles: livenessFiles, + documentBackFile: documentBackFile, + didSubmitJob: didSubmit) + return SmileIDResult.Success(result: result) + } + + // MARK: - Abstract Methods + + /// Creates the result instance for the document verification submission + /// - Parameters: + /// - selfieFile: The selfie file URL + /// - documentFrontFile: The document front file URL + /// - livenessFiles: Optional array of liveness file URLs + /// - documentBackFile: Optional document back file URL + /// - didSubmitJob: Whether the job was submitted + /// - Returns: Result instance of type ResultType + open func createResultInstance( + selfieFile _: URL, + documentFrontFile _: URL, + livenessFiles _: [URL]?, + documentBackFile _: URL?, + didSubmitJob _: Bool + ) -> ResultType { + fatalError("Must be implemented by subclass") + } +} + +// MARK: - URL Extensions diff --git a/Sources/SmileID/Classes/JobSubmission/Base/BaseJobSubmission.swift b/Sources/SmileID/Classes/JobSubmission/Base/BaseJobSubmission.swift new file mode 100644 index 00000000..15f9311b --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/Base/BaseJobSubmission.swift @@ -0,0 +1,165 @@ +// +// BaseJobSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public class BaseJobSubmission { + // MARK: - Properties + + public let jobId: String + + // MARK: - Initialization + + public init(jobId: String) { + self.jobId = jobId + } + + // MARK: - Methods to Override + + /// Creates the authentication request for the job submission + /// - Returns: Authentication request object + public func createAuthRequest() -> AuthenticationRequest { + fatalError("Must be implemented by subclass") + } + + /// Creates the prep upload request for the job submission + /// - Parameter authResponse: Optional authentication response from previous step + /// - Returns: Prep upload request object + public func createPrepUploadRequest(authResponse _: AuthenticationResponse? = nil) -> PrepUploadRequest { + fatalError("Must be implemented by subclass") + } + + /// Creates the upload request for the job submission + /// - Parameter authResponse: Optional authentication response from previous step + /// - Returns: Upload request object + public func createUploadRequest(authResponse _: AuthenticationResponse?) -> UploadRequest { + fatalError("Must be implemented by subclass") + } + + /// Creates the success result for the job submission + /// - Parameter didSubmit: Whether the job was submitted to the backend + /// - Returns: Success result object + public func createSuccessResult(didSubmit _: Bool) async throws -> SmileIDResult.Success { + fatalError("Must be implemented by subclass") + } + + // MARK: - Public Methods + + /// Executes the job submission process + /// - Parameters: + /// - skipApiSubmission: If true, skips the API submission + /// - offlineMode: If true, performs offline preparation + /// - Returns: Result of the job submission + public func executeSubmission( + skipApiSubmission: Bool = false, + offlineMode: Bool = false + ) async -> SmileIDResult { + do { + if skipApiSubmission { + let successResult = try await createSuccessResult(didSubmit: false) + return .success(successResult) + } else { + return try await executeApiSubmission(offlineMode: offlineMode) + } + } catch { + return .error(error) + } + } + + /// Handles offline preparation logic + /// Override this method to implement custom offline preparation + public func handleOfflinePreparation() async throws { + let authRequest = createAuthRequest() + try LocalStorage.createAuthenticationRequestFile(jobId: jobId, authentationRequest: authRequest) + try LocalStorage.createPrepUploadFile( + jobId: jobId, + prepUpload: createPrepUploadRequest() + ) + } + + // MARK: - Private Methods + + func executeApiSubmission(offlineMode: Bool) async throws -> SmileIDResult { + if offlineMode { + try await handleOfflinePreparation() + } + + do { + let authResponse = try await executeAuthentication() + let prepUploadResponse = try await executePrepUpload(authResponse: authResponse) + try await executeUpload(authResponse: authResponse, prepUploadResponse: prepUploadResponse) + let successResult = try await createSuccessResult(didSubmit: true) + return .success(successResult) + } catch { + return .error(error) + } + } + + func executeAuthentication() async throws -> AuthenticationResponse { + do { + return try await SmileID.api.authenticate(request: createAuthRequest()) + } catch let error as SmileIDError { + throw error + } catch { + throw error + } + } + + private func executePrepUpload( + authResponse: AuthenticationResponse? + ) async throws -> PrepUploadResponse { + let prepUploadRequest = createPrepUploadRequest(authResponse: authResponse) + return try await executePrepUploadWithRetry(prepUploadRequest: prepUploadRequest) + } + + private func executePrepUploadWithRetry( + prepUploadRequest: PrepUploadRequest, + isRetry: Bool = false + ) async throws -> PrepUploadResponse { + do { + return try await SmileID.api.prepUpload(request: prepUploadRequest) + } catch let error as SmileIDError { + if !isRetry && error.localizedDescription == SmileErrorConstants.RETRY { + var retryRequest = prepUploadRequest + retryRequest.retry = "true" + return try await executePrepUploadWithRetry(prepUploadRequest: retryRequest, isRetry: true) + } else { + throw error + } + } + } + + private func executeUpload( + authResponse: AuthenticationResponse?, + prepUploadResponse: PrepUploadResponse + ) async throws { + do { + let uploadRequest = createUploadRequest(authResponse: authResponse) + let allFiles: [URL] + do { + let livenessFiles = try LocalStorage.getFilesByType(jobId: jobId, fileType: .liveness) ?? [] + let additionalFiles = try [ + LocalStorage.getFileByType(jobId: jobId, fileType: .selfie), + LocalStorage.getFileByType(jobId: jobId, fileType: .documentFront), + LocalStorage.getFileByType(jobId: jobId, fileType: .documentBack), + LocalStorage.getInfoJsonFile(jobId: jobId), + ].compactMap { $0 } + allFiles = livenessFiles + additionalFiles + } catch { + throw error + } + let zipData = try LocalStorage.zipFiles(at: allFiles) + try await SmileID.api.upload(zip: zipData, to: prepUploadResponse.uploadUrl) + } catch { + throw error + } + } + + // MARK: - Constants +} + +private enum SmileErrorConstants { + static let RETRY = "2215" +} diff --git a/Sources/SmileID/Classes/JobSubmission/Base/BaseSubmissionViewModel.swift b/Sources/SmileID/Classes/JobSubmission/Base/BaseSubmissionViewModel.swift new file mode 100644 index 00000000..59260c91 --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/Base/BaseSubmissionViewModel.swift @@ -0,0 +1,120 @@ +// +// BaseSubmissionViewModel.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// +open class BaseSubmissionViewModel: ObservableObject { + // MARK: - Internal Properties + + var result: SmileIDResult? + + // MARK: - Methods to Override + + /// Creates the submission object for the job + /// - Returns: A BaseJobSubmission instance + open func createSubmission() throws -> BaseJobSubmission { + fatalError("Must be implemented by subclass") + } + + /// Called when job is in processing state + open func triggerProcessingState() { + fatalError("Must be implemented by subclass") + } + + /// Handles successful job submission + /// - Parameter data: The result data + open func handleSuccess(data _: ResultType) { + fatalError("Must be implemented by subclass") + } + + /// Handles job submission error + /// - Parameter error: The error that occurred + open func handleError(error _: Error) { + fatalError("Must be implemented by subclass") + } + + /// Handles submission files for the given job ID + /// - Parameter jobId: The job ID + open func handleSubmissionFiles(jobId _: String) throws { + fatalError("Must be implemented by subclass") + } + + /// Handles offline success scenario + open func handleOfflineSuccess() { + fatalError("Must be implemented by subclass") + } + + // MARK: - Internal Methods + + /// Handles proxy error scenarios + /// - Parameters: + /// - jobId: The job ID + /// - error: The error that occurred + func proxyErrorHandler(jobId: String, error: Error) { + // First handle SmileIDError specific cases + if let smileError = error as? SmileIDError { + do { + let didMoveToSubmitted = try LocalStorage.handleOfflineJobFailure(jobId: jobId, error: smileError) + + if didMoveToSubmitted { + try handleSubmissionFiles(jobId: jobId) + } + + // Check if we should handle this as an offline success case + if SmileID.allowOfflineMode && SmileIDError.isNetworkFailure(error: smileError) { + handleOfflineSuccess() + return + } + + // If not a network failure or offline mode isn't allowed, handle as regular error + handleError(error: smileError) + } catch { + // If handling offline failure throws, pass through the original error + handleError(error: smileError) + } + return + } + + // Handle non-SmileIDError cases + handleError(error: error) + } + + /// Submit job with given parameters + /// - Parameters: + /// - jobId: The job ID + /// - skipApiSubmission: If true, skips API submission + /// - offlineMode: If true, runs in offline mode + func submitJob( + jobId: String, + skipApiSubmission: Bool = false, + offlineMode: Bool = SmileID.allowOfflineMode + ) { + triggerProcessingState() + + Task { + do { + let submission = try createSubmission() + let submissionResult = try await submission.executeSubmission( + skipApiSubmission: skipApiSubmission, + offlineMode: offlineMode + ) + + await MainActor.run { + self.result = submissionResult + + switch submissionResult { + case let .success(success): + handleSuccess(data: success.result) + case let .error(error): + proxyErrorHandler(jobId: jobId, error: error) + } + } + } catch { + await MainActor.run { + proxyErrorHandler(jobId: jobId, error: error) + } + } + } + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/Base/BaseSynchronousJobSubmission.swift b/Sources/SmileID/Classes/JobSubmission/Base/BaseSynchronousJobSubmission.swift new file mode 100644 index 00000000..e4319d37 --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/Base/BaseSynchronousJobSubmission.swift @@ -0,0 +1,61 @@ +// +// BaseSynchronousJobSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public class BaseSynchronousJobSubmission: BaseJobSubmission { + // MARK: - Initialization + + override public init(jobId: String) { + super.init(jobId: jobId) + } + + // MARK: - Methods to Override + + /// Gets the API response for the job submission + /// - Returns: Optional API response + public func getApiResponse(authResponse _: AuthenticationResponse) async throws -> ApiResponse? { + fatalError("Must be implemented by subclass") + } + + /// Creates the synchronous result for the job submission + /// - Parameter result: Optional API response + /// - Returns: Success result object + public func createSynchronousResult(result _: ApiResponse?) async throws -> SmileIDResult.Success { + fatalError("Must be implemented by subclass") + } + + // MARK: - Overridden Methods + + /// Executes the API submission process + /// - Parameter offlineMode: If true, performs offline preparation + /// - Returns: Result of the job submission + override public func executeApiSubmission(offlineMode: Bool) async throws -> SmileIDResult { + if offlineMode { + try await handleOfflinePreparation() + } + + do { + let authResponse = try await executeAuthentication() + let apiResponse = try await getApiResponse(authResponse: authResponse) + let successResult = try await createSynchronousResult(result: apiResponse) + return .success(successResult) + } catch { + return .error(error) + } + } + + /// Creates the success result for the job submission + /// - Parameter didSubmit: Whether the job was submitted to the backend + /// - Returns: Success result object + override public func createSuccessResult(didSubmit: Bool) async throws -> SmileIDResult.Success { + var apiResponse: ApiResponse? = nil + if didSubmit { + let authResponse = try await executeAuthentication() + apiResponse = try await getApiResponse(authResponse: authResponse) + } + return try await createSynchronousResult(result: apiResponse) + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/BiometricKYCSubmission.swift b/Sources/SmileID/Classes/JobSubmission/BiometricKYCSubmission.swift new file mode 100644 index 00000000..9a45c810 --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/BiometricKYCSubmission.swift @@ -0,0 +1,93 @@ +// +// BiometricKYCSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public class BiometricKYCSubmission: BaseJobSubmission { + // MARK: - Properties + + private let userId: String + private let allowNewEnroll: Bool + private let livenessFiles: [URL]? + private let selfieFile: URL + private let idInfo: IdInfo + private let extraPartnerParams: [String: String] + private let metadata: Metadata + + // MARK: - Initialization + + public init( + userId: String, + jobId: String, + allowNewEnroll: Bool, + livenessFiles: [URL]?, + selfieFile: URL, + idInfo: IdInfo, + extraPartnerParams: [String: String], + metadata: Metadata + ) { + self.userId = userId + self.allowNewEnroll = allowNewEnroll + self.livenessFiles = livenessFiles + self.selfieFile = selfieFile + self.idInfo = idInfo + self.extraPartnerParams = extraPartnerParams + self.metadata = metadata + super.init(jobId: jobId) + } + + // MARK: - BaseJobSubmission Overrides + + override public func createAuthRequest() -> AuthenticationRequest { + return AuthenticationRequest( + jobType: .biometricKyc, + enrollment: false, + jobId: jobId, + userId: userId, + country: idInfo.country, + idType: idInfo.idType + ) + } + + override public func createPrepUploadRequest(authResponse: AuthenticationResponse? = nil) -> PrepUploadRequest { + let partnerParams = authResponse?.partnerParams.copy(extras: extraPartnerParams) ?? + PartnerParams( + jobId: jobId, + userId: userId, + jobType: .biometricKyc, + extras: extraPartnerParams + ) + + return PrepUploadRequest( + partnerParams: partnerParams, + allowNewEnroll: String(allowNewEnroll), + metadata: metadata.items, + timestamp: authResponse?.timestamp ?? "", + signature: authResponse?.signature ?? "" + ) + } + + override public func createUploadRequest(authResponse _: AuthenticationResponse?) -> UploadRequest { + let selfieImageInfo = selfieFile.asSelfieImage() + let livenessImageInfo = livenessFiles?.map { $0.asLivenessImage() } ?? [] + + return UploadRequest( + images: [selfieImageInfo] + livenessImageInfo, + idInfo: idInfo.copy(entered: true) + ) + } + + override public func createSuccessResult(didSubmit: Bool) async throws -> SmileIDResult.Success { + let result = BiometricKycResult( + captureData: SelfieCaptureResult( + selfieImage: selfieFile, + livenessImages: livenessFiles + ), + didSubmitJob: didSubmit + ) + + return SmileIDResult.Success(result: result) + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/DocumentVerificationSubmission.swift b/Sources/SmileID/Classes/JobSubmission/DocumentVerificationSubmission.swift new file mode 100644 index 00000000..01748a0b --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/DocumentVerificationSubmission.swift @@ -0,0 +1,55 @@ +// +// DocumentVerificationSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +class DocumentVerificationSubmission: BaseDocumentVerificationSubmission { + public init( + jobId: String, + userId: String, + countryCode: String, + allowNewEnroll: Bool, + documentFrontFile: URL, + selfieFile: URL, + documentType: String? = nil, + documentBackFile: URL? = nil, + livenessFiles: [URL]? = nil, + extraPartnerParams: [String: String], + metadata: [Metadatum]? = nil + ) { + super.init( + jobId: jobId, + userId: userId, + jobType: .documentVerification, + countryCode: countryCode, + documentType: documentType, + allowNewEnroll: allowNewEnroll, + documentFrontFile: documentFrontFile, + selfieFile: selfieFile, + documentBackFile: documentBackFile, + livenessFiles: livenessFiles, + extraPartnerParams: extraPartnerParams, + metadata: metadata + ) + } + + override func createResultInstance( + selfieFile: URL, + documentFrontFile: URL, + livenessFiles: [URL]?, + documentBackFile: URL?, + didSubmitJob: Bool + ) -> DocumentVerificationResult { + return DocumentVerificationResult( + captureData: DocumentCaptureResult( + selfieImage: selfieFile, + livenessImages: livenessFiles, + frontImage: documentFrontFile, + backImage: documentBackFile + ), + didSubmitJob: didSubmitJob + ) + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/EnhancedDocumentVerificationSubmission.swift b/Sources/SmileID/Classes/JobSubmission/EnhancedDocumentVerificationSubmission.swift new file mode 100644 index 00000000..bc8a98d7 --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/EnhancedDocumentVerificationSubmission.swift @@ -0,0 +1,57 @@ +// +// EnhancedDocumentVerificationSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public class EnhancedDocumentVerificationSubmission: BaseDocumentVerificationSubmission { + // MARK: - Initialization + + public init( + jobId: String, + userId: String, + countryCode: String, + documentType: String?, + allowNewEnroll: Bool, + documentFrontFile: URL, + selfieFile: URL, + documentBackFile: URL? = nil, + livenessFiles: [URL]? = nil, + extraPartnerParams: [String: String], + metadata: [Metadatum]? = nil + ) { + super.init( + jobId: jobId, + userId: userId, + jobType: .enhancedDocumentVerification, + countryCode: countryCode, + documentType: documentType, + allowNewEnroll: allowNewEnroll, + documentFrontFile: documentFrontFile, + selfieFile: selfieFile, + documentBackFile: documentBackFile, + livenessFiles: livenessFiles, + extraPartnerParams: extraPartnerParams, + metadata: metadata + ) + } + + override public func createResultInstance( + selfieFile: URL, + documentFrontFile: URL, + livenessFiles: [URL]?, + documentBackFile: URL?, + didSubmitJob: Bool + ) -> EnhancedDocumentVerificationResult { + return EnhancedDocumentVerificationResult( + captureData: DocumentCaptureResult( + selfieImage: selfieFile, + livenessImages: livenessFiles, + frontImage: documentFrontFile, + backImage: documentBackFile + ), + didSubmitJob: didSubmitJob + ) + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/Results/CaptureResult.swift b/Sources/SmileID/Classes/JobSubmission/Results/CaptureResult.swift new file mode 100644 index 00000000..750644a7 --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/Results/CaptureResult.swift @@ -0,0 +1,45 @@ +// +// CaptureResult.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public protocol CommonCaptureData { + var selfieImage: URL { get } + var livenessImages: [URL]? { get } +} + +// MARK: - Base Capture Results + +public struct SelfieCaptureResult: CommonCaptureData { + public let selfieImage: URL + public let livenessImages: [URL]? + + public init( + selfieImage: URL, + livenessImages: [URL]? = nil + ) { + self.selfieImage = selfieImage + self.livenessImages = livenessImages + } +} + +public struct DocumentCaptureResult: CommonCaptureData { + public let selfieImage: URL + public let livenessImages: [URL]? + public let frontImage: URL + public let backImage: URL? + + public init( + selfieImage: URL, + livenessImages: [URL]? = nil, + frontImage: URL, + backImage: URL? = nil + ) { + self.selfieImage = selfieImage + self.livenessImages = livenessImages + self.frontImage = frontImage + self.backImage = backImage + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/Results/SmileIDResults.swift b/Sources/SmileID/Classes/JobSubmission/Results/SmileIDResults.swift new file mode 100644 index 00000000..b250a4bb --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/Results/SmileIDResults.swift @@ -0,0 +1,81 @@ +// +// SmileIDResults.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +// Specific result types conforming to CaptureResult +public protocol CaptureResult { + var didSubmitJob: Bool { get } +} + +public struct SmartSelfieResult: CaptureResult { + public let captureData: SelfieCaptureResult + public let didSubmitJob: Bool + public let apiResponse: SmartSelfieResponse? + + public init( + captureData: SelfieCaptureResult, + didSubmitJob: Bool, + apiResponse: SmartSelfieResponse? + ) { + self.captureData = captureData + self.didSubmitJob = didSubmitJob + self.apiResponse = apiResponse + } +} + +public struct DocumentVerificationResult: CaptureResult { + public let captureData: DocumentCaptureResult + public let didSubmitJob: Bool + + public init( + captureData: DocumentCaptureResult, + didSubmitJob: Bool + ) { + self.captureData = captureData + self.didSubmitJob = didSubmitJob + } +} + +public struct EnhancedDocumentVerificationResult: CaptureResult { + public let captureData: DocumentCaptureResult + public let didSubmitJob: Bool + + public init( + captureData: DocumentCaptureResult, + didSubmitJob: Bool + ) { + self.captureData = captureData + self.didSubmitJob = didSubmitJob + } +} + +public struct BiometricKycResult: CaptureResult { + public let captureData: SelfieCaptureResult + public let didSubmitJob: Bool + + public init( + captureData: SelfieCaptureResult, + didSubmitJob: Bool + ) { + self.captureData = captureData + self.didSubmitJob = didSubmitJob + } +} + +// MARK: - Generic Result Type + +public enum SmileIDResult { + case success(Success) + case error(Error) + + public struct Success { + public let result: ResultType + + public init(result: ResultType) { + self.result = result + } + } +} diff --git a/Sources/SmileID/Classes/JobSubmission/SelfieSubmission.swift b/Sources/SmileID/Classes/JobSubmission/SelfieSubmission.swift new file mode 100644 index 00000000..f53a179c --- /dev/null +++ b/Sources/SmileID/Classes/JobSubmission/SelfieSubmission.swift @@ -0,0 +1,173 @@ +// +// SelfieSubmission.swift +// Pods +// +// Created by Japhet Ndhlovu on 12/5/24. +// + +public class SelfieSubmission: BaseSynchronousJobSubmission { + // MARK: - Properties + + private let isEnroll: Bool + private let userId: String + private let allowNewEnroll: Bool + private let selfieFile: URL + private let livenessFiles: [URL] + private let extraPartnerParams: [String: String] + private let metadata: Metadata + + // MARK: - Initialization + + public init( + isEnroll: Bool, + userId: String, + jobId: String, + allowNewEnroll: Bool, + selfieFile: URL, + livenessFiles: [URL], + extraPartnerParams: [String: String], + metadata: Metadata + ) { + self.isEnroll = isEnroll + self.userId = userId + self.allowNewEnroll = allowNewEnroll + self.selfieFile = selfieFile + self.livenessFiles = livenessFiles + self.extraPartnerParams = extraPartnerParams + self.metadata = metadata + super.init(jobId: jobId) + } + + // MARK: - Overridden Methods + + override public func createAuthRequest() -> AuthenticationRequest { + return AuthenticationRequest( + jobType: isEnroll ? .smartSelfieEnrollment : .smartSelfieAuthentication, + enrollment: isEnroll, + jobId: jobId, + userId: userId + ) + } + + override public func createPrepUploadRequest(authResponse: AuthenticationResponse? = nil) -> PrepUploadRequest { + return PrepUploadRequest( + partnerParams: PartnerParams( + jobId: jobId, + userId: userId, + jobType: isEnroll ? .smartSelfieEnrollment : .smartSelfieAuthentication, + extras: extraPartnerParams + ), + allowNewEnroll: String(allowNewEnroll), + metadata: metadata.items, + timestamp: authResponse?.timestamp ?? "", + signature: authResponse?.signature ?? "" + ) + } + + override public func createUploadRequest(authResponse _: AuthenticationResponse?) -> UploadRequest { + return UploadRequest( + images: [selfieFile.asSelfieImage()] + livenessFiles.map { url in + url.asLivenessImage() + } + ) + } + + override public func getApiResponse(authResponse: AuthenticationResponse) async throws -> SmartSelfieResponse? { + var smartSelfieImage: MultipartBody? + var smartSelfieLivenessImages = [MultipartBody]() + if let selfie = try? Data(contentsOf: selfieFile), let media = MultipartBody( + withImage: selfie, + forKey: selfieFile.lastPathComponent, + forName: selfieFile.lastPathComponent + ) { + smartSelfieImage = media + } + if !livenessFiles.isEmpty { + let livenessImageInfos = livenessFiles.compactMap { liveness -> MultipartBody? in + if let data = try? Data(contentsOf: liveness) { + return MultipartBody( + withImage: data, + forKey: liveness.lastPathComponent, + forName: liveness.lastPathComponent + ) + } + return nil + } + + smartSelfieLivenessImages.append(contentsOf: livenessImageInfos.compactMap { $0 }) + } + guard let smartSelfieImage = smartSelfieImage, + !smartSelfieLivenessImages.isEmpty + else { + throw SmileIDError.unknown("Selfie submission failed") + } + + let response = if isEnroll { + try await SmileID.api.doSmartSelfieEnrollment( + signature: authResponse.signature, + timestamp: authResponse.timestamp, + selfieImage: smartSelfieImage, + livenessImages: smartSelfieLivenessImages, + userId: userId, + partnerParams: extraPartnerParams, + callbackUrl: SmileID.callbackUrl, + sandboxResult: nil, + allowNewEnroll: allowNewEnroll, + metadata: metadata + ) + } else { + try await SmileID.api.doSmartSelfieAuthentication( + signature: authResponse.signature, + timestamp: authResponse.timestamp, + userId: userId, + selfieImage: smartSelfieImage, + livenessImages: smartSelfieLivenessImages, + partnerParams: extraPartnerParams, + callbackUrl: SmileID.callbackUrl, + sandboxResult: nil, + metadata: metadata + ) + } + return response + } + + override public func createSynchronousResult(result: SmartSelfieResponse?) async throws -> SmileIDResult.Success { + // Move files from unsubmitted to submitted directories + var selfieFileResult = selfieFile + var livenessImagesResult = livenessFiles + + do { + if let result = result { + try LocalStorage.moveToSubmittedJobs(jobId: jobId) + guard let selfieFileResult = try LocalStorage.getFileByType( + jobId: jobId, + fileType: FileType.selfie, + submitted: true + ) else { + throw SmileIDError.unknown("Selfie file not found") + } + livenessImagesResult = try LocalStorage.getFilesByType( + jobId: jobId, + fileType: FileType.liveness, + submitted: true + ) ?? [] + } + } catch { + print("Error moving job to submitted directory: \(error)") + throw error + } + + let captureResult = SelfieCaptureResult( + selfieImage: selfieFileResult, + livenessImages: livenessImagesResult + ) + + let finalResult = SmartSelfieResult( + captureData: captureResult, + didSubmitJob: true, + apiResponse: result + ) + + return SmileIDResult.Success(result: finalResult) + } +} diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift index cd245169..322ff319 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift @@ -3,7 +3,7 @@ import Combine import Foundation // swiftlint:disable opening_brace -public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { +public class SelfieViewModel: BaseSubmissionViewModel, ARKitSmileDelegate { // Constants private let intraImageMinDelay: TimeInterval = 0.35 private let noFaceResetDelay: TimeInterval = 3 @@ -13,9 +13,9 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { private let minFaceAreaThreshold = 0.125 private let maxFaceAreaThreshold = 0.25 private let faceRotationThreshold = 0.03 - private let faceRollThreshold = 0.025 // roll has a smaller range than yaw + private let faceRollThreshold = 0.025 // roll has a smaller range than yaw private let numLivenessImages = 7 - private let numTotalSteps = 8 // numLivenessImages + 1 selfie image + private let numTotalSteps = 8 // numLivenessImages + 1 selfie image private let livenessImageSize = 320 private let selfieImageSize = 640 @@ -105,7 +105,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { self?.analyzeImage(image: imageBuffer) } .store(in: &subscribers) - + cleanUpSelfieCapture() localMetadata.addMetadata( useBackCamera ? Metadatum.SelfieImageOrigin(cameraFacing: .backCamera) @@ -156,7 +156,6 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { } selfieImage = nil livenessImages = [] - cleanUpSelfieCapture() } return } @@ -209,7 +208,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { } if let quality = face.faceCaptureQuality, - quality < faceCaptureQualityThreshold + quality < faceCaptureQualityThreshold { DispatchQueue.main.async { self.directive = "Instructions.Quality" @@ -223,7 +222,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { DispatchQueue.main.async { self.directive = userNeedsToSmile - ? "Instructions.Smile" : "Instructions.Capturing" + ? "Instructions.Smile" : "Instructions.Capturing" } // TODO: Use mouth deformation as an alternate signal for non-ARKit capture @@ -246,38 +245,40 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { if livenessImages.count < numLivenessImages { guard let imageData = - ImageUtils.resizePixelBufferToHeight( - image, - height: livenessImageSize, - orientation: orientation - ) + ImageUtils.resizePixelBufferToHeight( + image, + height: livenessImageSize, + orientation: orientation + ) else { throw SmileIDError.unknown( "Error resizing liveness image") } let imageUrl = try LocalStorage.createLivenessFile( - jobId: jobId, livenessFile: imageData) + jobId: jobId, livenessFile: imageData + ) livenessImages.append(imageUrl) DispatchQueue.main.async { self.captureProgress = Double(self.livenessImages.count) - / Double(self.numTotalSteps) + / Double(self.numTotalSteps) } } else { shouldAnalyzeImages = false guard let imageData = - ImageUtils.resizePixelBufferToHeight( - image, - height: selfieImageSize, - orientation: orientation - ) + ImageUtils.resizePixelBufferToHeight( + image, + height: selfieImageSize, + orientation: orientation + ) else { throw SmileIDError.unknown( "Error resizing selfie image") } let selfieImage = try LocalStorage.createSelfieFile( - jobId: jobId, selfieFile: imageData) + jobId: jobId, selfieFile: imageData + ) self.selfieImage = selfieImage DispatchQueue.main.async { self.captureProgress = 1 @@ -385,171 +386,15 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { DispatchQueue.main.async { self.processingState = .success } return } - DispatchQueue.main.async { self.processingState = .inProgress } - Task { - do { - guard let selfieImage, livenessImages.count == numLivenessImages - else { - throw SmileIDError.unknown("Selfie capture failed") - } - let jobType = - isEnroll - ? JobType.smartSelfieEnrollment - : JobType.smartSelfieAuthentication - let authRequest = AuthenticationRequest( - jobType: jobType, - enrollment: isEnroll, - jobId: jobId, - userId: userId - ) - if SmileID.allowOfflineMode { - try LocalStorage.saveOfflineJob( - jobId: jobId, - userId: userId, - jobType: jobType, - enrollment: isEnroll, - allowNewEnroll: allowNewEnroll, - localMetadata: localMetadata, - partnerParams: extraPartnerParams - ) - } - let authResponse = try await SmileID.api.authenticate( - request: authRequest) - - var smartSelfieLivenessImages = [MultipartBody]() - var smartSelfieImage: MultipartBody? - if let selfie = try? Data(contentsOf: selfieImage), - let media = MultipartBody( - withImage: selfie, - forKey: selfieImage.lastPathComponent, - forName: selfieImage.lastPathComponent - ) - { - smartSelfieImage = media - } - if !livenessImages.isEmpty { - let livenessImageInfos = livenessImages.compactMap { liveness -> MultipartBody? in - if let data = try? Data(contentsOf: liveness) { - return MultipartBody( - withImage: data, - forKey: liveness.lastPathComponent, - forName: liveness.lastPathComponent - ) - } - return nil - } - - smartSelfieLivenessImages.append( - contentsOf: livenessImageInfos.compactMap { $0 }) - } - guard let smartSelfieImage = smartSelfieImage, - smartSelfieLivenessImages.count == numLivenessImages - else { - throw SmileIDError.unknown("Selfie capture failed") - } - - let response = - if isEnroll { - try await SmileID.api.doSmartSelfieEnrollment( - signature: authResponse.signature, - timestamp: authResponse.timestamp, - selfieImage: smartSelfieImage, - livenessImages: smartSelfieLivenessImages, - userId: userId, - partnerParams: extraPartnerParams, - callbackUrl: SmileID.callbackUrl, - sandboxResult: nil, - allowNewEnroll: allowNewEnroll, - failureReason: nil, - metadata: localMetadata.metadata - ) - } else { - try await SmileID.api.doSmartSelfieAuthentication( - signature: authResponse.signature, - timestamp: authResponse.timestamp, - userId: userId, - selfieImage: smartSelfieImage, - livenessImages: smartSelfieLivenessImages, - partnerParams: extraPartnerParams, - callbackUrl: SmileID.callbackUrl, - sandboxResult: nil, - failureReason: nil, - metadata: localMetadata.metadata - ) - } - apiResponse = response - do { - try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) - self.selfieImage = try LocalStorage.getFileByType( - jobId: jobId, - fileType: FileType.selfie, - submitted: true - ) - self.livenessImages = - try LocalStorage.getFilesByType( - jobId: jobId, - fileType: FileType.liveness, - submitted: true - ) ?? [] - } catch { - print("Error moving job to submitted directory: \(error)") - self.error = error - } - DispatchQueue.main.async { self.processingState = .success } - } catch let error as SmileIDError { - do { - let didMove = try LocalStorage.handleOfflineJobFailure( - jobId: self.jobId, - error: error - ) - if didMove { - self.selfieImage = try LocalStorage.getFileByType( - jobId: jobId, - fileType: FileType.selfie, - submitted: true - ) - self.livenessImages = - try LocalStorage.getFilesByType( - jobId: jobId, - fileType: FileType.liveness, - submitted: true - ) ?? [] - } - } catch { - print("Error moving job to submitted directory: \(error)") - self.error = error - return - } - if SmileID.allowOfflineMode, - SmileIDError.isNetworkFailure(error: error) - { - DispatchQueue.main.async { - self.errorMessageRes = "Offline.Message" - self.processingState = .success - } - } else { - print("Error submitting job: \(error)") - let (errorMessageRes, errorMessage) = toErrorMessage( - error: error) - self.error = error - self.errorMessageRes = errorMessageRes - self.errorMessage = errorMessage - DispatchQueue.main.async { self.processingState = .error } - } - } catch { - print("Error submitting job: \(error)") - self.error = error - DispatchQueue.main.async { self.processingState = .error } - } - } + submitJob(jobId: jobId, skipApiSubmission: skipApiSubmission, offlineMode: SmileID.allowOfflineMode) } public func onFinished(callback: SmartSelfieResultDelegate) { if let selfieImage = selfieImage, - let selfiePath = getRelativePath(from: selfieImage), - livenessImages.count == numLivenessImages, - !livenessImages.contains(where: { getRelativePath(from: $0) == nil } - ) + let selfiePath = getRelativePath(from: selfieImage), + livenessImages.count == numLivenessImages, + !livenessImages.contains(where: { getRelativePath(from: $0) == nil } + ) { let livenessImagesPaths = livenessImages.compactMap { getRelativePath(from: $0) @@ -572,4 +417,66 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { else { return } UIApplication.shared.open(settingsURL) } + + override public func createSubmission() throws -> BaseJobSubmission { + guard let selfieImage = selfieImage, + livenessImages.count == numLivenessImages + else { + throw SmileIDError.unknown("Selfie images missing") + } + return SelfieSubmission( + isEnroll: isEnroll, + userId: userId, + jobId: jobId, + allowNewEnroll: allowNewEnroll, + selfieFile: selfieImage, + livenessFiles: livenessImages, + extraPartnerParams: extraPartnerParams, + metadata: localMetadata.metadata + ) + } + + override public func triggerProcessingState() { + DispatchQueue.main.async { self.processingState = .inProgress } + } + + override public func handleSuccess(data: SmartSelfieResult) { + apiResponse = data.apiResponse + DispatchQueue.main.async { self.processingState = .success } + } + + override public func handleError(error: Error) { + if let smileError = error as? SmileIDError { + print("Error submitting job: \(error)") + let (errorMessageRes, errorMessage) = toErrorMessage(error: smileError) + self.error = error + self.errorMessageRes = errorMessageRes + self.errorMessage = errorMessage + DispatchQueue.main.async { self.processingState = .error } + } else { + print("Error submitting job: \(error)") + self.error = error + DispatchQueue.main.async { self.processingState = .error } + } + } + + override public func handleSubmissionFiles(jobId: String) throws { + selfieImage = try LocalStorage.getFileByType( + jobId: jobId, + fileType: FileType.selfie, + submitted: true + ) + livenessImages = try LocalStorage.getFilesByType( + jobId: jobId, + fileType: FileType.liveness, + submitted: true + ) ?? [] + } + + override public func handleOfflineSuccess() { + DispatchQueue.main.async { + self.errorMessageRes = "Offline.Message" + self.processingState = .success + } + } }