diff --git a/HostApp/HostApp.xcodeproj/project.pbxproj b/HostApp/HostApp.xcodeproj/project.pbxproj index f318bc2d..81ef8e10 100644 --- a/HostApp/HostApp.xcodeproj/project.pbxproj +++ b/HostApp/HostApp.xcodeproj/project.pbxproj @@ -308,8 +308,6 @@ Base, ); mainGroup = 9070FF97285112B4009867D5; - packageReferences = ( - ); productRefGroup = 9070FFA1285112B4009867D5 /* Products */; projectDirPath = ""; projectRoot = ""; diff --git a/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 61218e39..cddb5116 100644 --- a/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/aws-amplify/amplify-swift", "state" : { - "revision" : "7846328106dba471b3fb35170155e92aad50d427", - "version" : "2.33.3" + "branch" : "feat/no-light-support", + "revision" : "7c1fa2f7a766208f5af69ca8dce5fd02e6de4db6" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stephencelis/SQLite.swift.git", "state" : { - "revision" : "e78ae0220e17525a15ac68c697a155eb7a672a8e", - "version" : "0.15.0" + "revision" : "5f5ad81ac0d0a0f3e56e39e646e8423c617df523", + "version" : "0.13.2" } }, { diff --git a/HostApp/HostApp/Views/ExampleLivenessView.swift b/HostApp/HostApp/Views/ExampleLivenessView.swift index 39407c89..85eaf8cc 100644 --- a/HostApp/HostApp/Views/ExampleLivenessView.swift +++ b/HostApp/HostApp/Views/ExampleLivenessView.swift @@ -22,7 +22,8 @@ struct ExampleLivenessView: View { case .liveness: FaceLivenessDetectorView( sessionID: viewModel.sessionID, - region: "us-east-1", + // TODO: Change before merging to main + region: "us-west-2", isPresented: Binding( get: { viewModel.presentationState == .liveness }, set: { _ in } diff --git a/Package.resolved b/Package.resolved index e9515eee..a046c257 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/aws-amplify/amplify-swift", "state" : { - "revision" : "dbc4a0412f4b5cd96f3e756e78bbd1e8e0a35a2f", - "version" : "2.35.4" + "branch" : "feat/no-light-support", + "revision" : "7c1fa2f7a766208f5af69ca8dce5fd02e6de4db6" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stephencelis/SQLite.swift.git", "state" : { - "revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8", - "version" : "0.15.3" + "revision" : "5f5ad81ac0d0a0f3e56e39e646e8423c617df523", + "version" : "0.13.2" } }, { diff --git a/Package.swift b/Package.swift index ee5353c6..446f12c0 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,8 @@ let package = Package( targets: ["FaceLiveness"]), ], dependencies: [ - .package(url: "https://github.com/aws-amplify/amplify-swift", exact: "2.35.4") + // TODO: Change this before merge to main + .package(url: "https://github.com/aws-amplify/amplify-swift", branch: "feat/no-light-support") ], targets: [ .target( diff --git a/Sources/FaceLiveness/FaceDetection/BlazeFace/DetectedFace.swift b/Sources/FaceLiveness/FaceDetection/BlazeFace/DetectedFace.swift index d6879848..1d62b263 100644 --- a/Sources/FaceLiveness/FaceDetection/BlazeFace/DetectedFace.swift +++ b/Sources/FaceLiveness/FaceDetection/BlazeFace/DetectedFace.swift @@ -6,6 +6,7 @@ // import Foundation +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin struct DetectedFace { var boundingBox: CGRect @@ -19,7 +20,8 @@ struct DetectedFace { let confidence: Float - func boundingBoxFromLandmarks(ovalRect: CGRect) -> CGRect { + func boundingBoxFromLandmarks(ovalRect: CGRect, + ovalMatchChallenge: FaceLivenessSession.OvalMatchChallenge) -> CGRect { let alpha = 2.0 let gamma = 1.8 let ow = (alpha * pupilDistance + gamma * faceHeight) / 2 @@ -34,7 +36,7 @@ struct DetectedFace { } let faceWidth = ow - let faceHeight = 1.618 * faceWidth + let faceHeight = ovalMatchChallenge.oval.heightWidthRatio * faceWidth let faceBoxBottom = boundingBox.maxY let faceBoxTop = faceBoxBottom - faceHeight let faceBoxLeft = min(cx - ow / 2, rightEar.x) diff --git a/Sources/FaceLiveness/FaceDetection/BlazeFace/FaceDetectorShortRange+Model.swift b/Sources/FaceLiveness/FaceDetection/BlazeFace/FaceDetectorShortRange+Model.swift index d9430720..100f0418 100644 --- a/Sources/FaceLiveness/FaceDetection/BlazeFace/FaceDetectorShortRange+Model.swift +++ b/Sources/FaceLiveness/FaceDetection/BlazeFace/FaceDetectorShortRange+Model.swift @@ -12,6 +12,7 @@ import Accelerate import CoreGraphics import CoreImage import VideoToolbox +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin enum FaceDetectorShortRange {} @@ -33,11 +34,16 @@ extension FaceDetectorShortRange { ) } + weak var faceDetectionSessionConfiguration: FaceDetectionSessionConfigurationWrapper? weak var detectionResultHandler: FaceDetectionResultHandler? func setResultHandler(detectionResultHandler: FaceDetectionResultHandler) { self.detectionResultHandler = detectionResultHandler } + + func setFaceDetectionSessionConfigurationWrapper(configuration: FaceDetectionSessionConfigurationWrapper) { + self.faceDetectionSessionConfiguration = configuration + } func detectFaces(from buffer: CVPixelBuffer) { let faces = prediction(for: buffer) @@ -105,10 +111,17 @@ extension FaceDetectorShortRange { count: confidenceScoresCapacity ) ) + + let blazeFaceDetectionThreshold: Float + if let sessionConfiguration = faceDetectionSessionConfiguration?.sessionConfiguration { + blazeFaceDetectionThreshold = Float(sessionConfiguration.ovalMatchChallenge.faceDetectionThreshold) + } else { + blazeFaceDetectionThreshold = confidenceScoreThreshold + } var passingConfidenceScoresIndices = confidenceScores .enumerated() - .filter { $0.element >= confidenceScoreThreshold } + .filter { $0.element >= blazeFaceDetectionThreshold} .sorted(by: { $0.element > $1.element }) diff --git a/Sources/FaceLiveness/FaceDetection/FaceDetector.swift b/Sources/FaceLiveness/FaceDetection/FaceDetector.swift index 3801eeab..1afb90c1 100644 --- a/Sources/FaceLiveness/FaceDetection/FaceDetector.swift +++ b/Sources/FaceLiveness/FaceDetection/FaceDetector.swift @@ -6,6 +6,7 @@ // import AVFoundation +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin protocol FaceDetector { func detectFaces(from buffer: CVPixelBuffer) @@ -16,6 +17,10 @@ protocol FaceDetectionResultHandler: AnyObject { func process(newResult: FaceDetectionResult) } +protocol FaceDetectionSessionConfigurationWrapper: AnyObject { + var sessionConfiguration: FaceLivenessSession.SessionConfiguration? { get } +} + enum FaceDetectionResult { case noFace case singleFace(DetectedFace) diff --git a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift index 00ecb9b7..dadb2076 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift @@ -6,35 +6,49 @@ // import SwiftUI +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin struct GetReadyPageView: View { let beginCheckButtonDisabled: Bool let onBegin: () -> Void - + let challenge: Challenge + init( onBegin: @escaping () -> Void, - beginCheckButtonDisabled: Bool = false + beginCheckButtonDisabled: Bool = false, + challenge: Challenge ) { self.onBegin = onBegin self.beginCheckButtonDisabled = beginCheckButtonDisabled + self.challenge = challenge } var body: some View { VStack { ZStack { CameraPreviewView() - VStack { - WarningBox( - titleText: LocalizedStrings.get_ready_photosensitivity_title, - bodyText: LocalizedStrings.get_ready_photosensitivity_description, - popoverContent: { photosensitivityWarningPopoverContent } - ) - .accessibilityElement(children: .combine) - Text(LocalizedStrings.preview_center_your_face_text) - .font(.title) - .multilineTextAlignment(.center) - Spacer() - }.padding() + switch self.challenge.type { + case .faceMovementChallenge: + VStack { + Text(LocalizedStrings.preview_center_your_face_text) + .font(.title) + .multilineTextAlignment(.center) + Spacer() + }.padding() + case . faceMovementAndLightChallenge: + VStack { + WarningBox( + titleText: LocalizedStrings.get_ready_photosensitivity_title, + bodyText: LocalizedStrings.get_ready_photosensitivity_description, + popoverContent: { photosensitivityWarningPopoverContent } + ) + .accessibilityElement(children: .combine) + Text(LocalizedStrings.preview_center_your_face_text) + .font(.title) + .multilineTextAlignment(.center) + Spacer() + }.padding() + } } beginCheckButton } @@ -72,6 +86,8 @@ struct GetReadyPageView: View { struct GetReadyPageView_Previews: PreviewProvider { static var previews: some View { - GetReadyPageView(onBegin: {}) + GetReadyPageView(onBegin: {}, + challenge: .init(version: "2.0.0", + type: .faceMovementAndLightChallenge)) } } diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift index ff02a3d6..5ed45ae7 100644 --- a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift +++ b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin struct InstructionContainerView: View { @ObservedObject var viewModel: FaceLivenessDetectionViewModel @@ -97,13 +98,29 @@ struct InstructionContainerView: View { argument: LocalizedStrings.challenge_verifying ) } - case .faceMatched: + case .completedNoLightCheck: InstructionView( - text: LocalizedStrings.challenge_instruction_hold_still, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + text: LocalizedStrings.challenge_verifying, + backgroundColor: .livenessBackground ) + .onAppear { + UIAccessibility.post( + notification: .announcement, + argument: LocalizedStrings.challenge_verifying + ) + } + case .faceMatched: + if let challenge = viewModel.challenge, + case .faceMovementAndLightChallenge = challenge.type { + InstructionView( + text: LocalizedStrings.challenge_instruction_hold_still, + backgroundColor: .livenessPrimaryBackground, + textColor: .livenessPrimaryLabel, + font: .title + ) + } else { + EmptyView() + } default: EmptyView() } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index bc4e47e8..eff46ec7 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift @@ -16,10 +16,11 @@ import Amplify public struct FaceLivenessDetectorView: View { @StateObject var viewModel: FaceLivenessDetectionViewModel @Binding var isPresented: Bool - @State var displayState: DisplayState = .awaitingCameraPermission + @State var displayState: DisplayState = .awaitingChallengeType @State var displayingCameraPermissionsNeededAlert = false let disableStartView: Bool + let facelivenessDetectorViewId: String let onCompletion: (Result) -> Void let sessionTask: Task @@ -32,6 +33,8 @@ public struct FaceLivenessDetectorView: View { isPresented: Binding, onCompletion: @escaping (Result) -> Void ) { + let viewId = UUID().uuidString + self.facelivenessDetectorViewId = viewId self.disableStartView = disableStartView self._isPresented = isPresented self.onCompletion = onCompletion @@ -41,7 +44,8 @@ public struct FaceLivenessDetectorView: View { withID: sessionID, credentialsProvider: credentialsProvider, region: region, - options: .init(), + options: .init(faceLivenessDetectorViewId: viewId, + preCheckViewEnabled: !disableStartView), completion: map(detectionCompletion: onCompletion) ) return session @@ -82,6 +86,8 @@ public struct FaceLivenessDetectorView: View { sessionID: sessionID ) ) + + faceDetector.setFaceDetectionSessionConfigurationWrapper(configuration: viewModel) } init( @@ -93,6 +99,8 @@ public struct FaceLivenessDetectorView: View { onCompletion: @escaping (Result) -> Void, captureSession: LivenessCaptureSession ) { + let viewId = UUID().uuidString + self.facelivenessDetectorViewId = viewId self.disableStartView = disableStartView self._isPresented = isPresented self.onCompletion = onCompletion @@ -102,7 +110,8 @@ public struct FaceLivenessDetectorView: View { withID: sessionID, credentialsProvider: credentialsProvider, region: region, - options: .init(), + options: .init(faceLivenessDetectorViewId: viewId, + preCheckViewEnabled: !disableStartView), completion: map(detectionCompletion: onCompletion) ) return session @@ -126,32 +135,44 @@ public struct FaceLivenessDetectorView: View { public var body: some View { switch displayState { - case .awaitingLivenessSession: + case .awaitingChallengeType: + LoadingPageView() + .onAppear { + Task { + do { + let session = try await sessionTask.value + viewModel.livenessService = session + viewModel.registerServiceEvents(onChallengeTypeReceived: { challenge in + self.displayState = DisplayState.awaitingLivenessSession(challenge) + }) + viewModel.initializeLivenessStream() + } catch { + throw FaceLivenessDetectionError.accessDenied + } + } + } + case .awaitingLivenessSession(let challenge): Color.clear .onAppear { Task { do { let newState = disableStartView ? DisplayState.displayingLiveness - : DisplayState.displayingGetReadyView + : DisplayState.displayingGetReadyView(challenge) guard self.displayState != newState else { return } - let session = try await sessionTask.value - viewModel.livenessService = session - viewModel.registerServiceEvents() self.displayState = newState - } catch { - throw FaceLivenessDetectionError.accessDenied } } } - case .displayingGetReadyView: + case .displayingGetReadyView(let challenge): GetReadyPageView( onBegin: { guard displayState != .displayingLiveness else { return } displayState = .displayingLiveness }, - beginCheckButtonDisabled: false + beginCheckButtonDisabled: false, + challenge: challenge ) .onAppear { DispatchQueue.main.async { @@ -215,7 +236,8 @@ public struct FaceLivenessDetectorView: View { for: .video, completionHandler: { accessGranted in guard accessGranted == true else { return } - displayState = .awaitingLivenessSession + guard let challenge = viewModel.challenge else { return } + displayState = .awaitingLivenessSession(challenge) } ) @@ -233,18 +255,37 @@ public struct FaceLivenessDetectorView: View { case .restricted, .denied: alertCameraAccessNeeded() case .authorized: - displayState = .awaitingLivenessSession + guard let challenge = viewModel.challenge else { return } + displayState = .awaitingLivenessSession(challenge) @unknown default: break } } } -enum DisplayState { - case awaitingLivenessSession - case displayingGetReadyView +enum DisplayState: Equatable { + case awaitingChallengeType + case awaitingLivenessSession(Challenge) + case displayingGetReadyView(Challenge) case displayingLiveness case awaitingCameraPermission + + static func == (lhs: DisplayState, rhs: DisplayState) -> Bool { + switch (lhs, rhs) { + case (.awaitingChallengeType, .awaitingChallengeType): + return true + case (let .awaitingLivenessSession(c1), let .awaitingLivenessSession(c2)): + return c1.type == c2.type && c1.version == c2.version + case (let .displayingGetReadyView(c1), let .displayingGetReadyView(c2)): + return c1.type == c2.type && c1.version == c2.version + case (.displayingLiveness, .displayingLiveness): + return true + case (.awaitingCameraPermission, .awaitingCameraPermission): + return true + default: + return false + } + } } enum InstructionState { diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift index 99e92ee2..edb96e80 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift @@ -28,14 +28,15 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { } case .singleFace(let face): var normalizedFace = normalizeFace(face) - normalizedFace.boundingBox = normalizedFace.boundingBoxFromLandmarks(ovalRect: ovalRect) + guard let sessionConfiguration = sessionConfiguration else { return } + normalizedFace.boundingBox = normalizedFace.boundingBoxFromLandmarks(ovalRect: ovalRect, + ovalMatchChallenge: sessionConfiguration.ovalMatchChallenge) switch livenessState.state { case .pendingFacePreparedConfirmation: - if face.faceDistance <= initialFaceDistanceThreshold { + if face.faceDistance <= sessionConfiguration.ovalMatchChallenge.face.distanceThreshold { DispatchQueue.main.async { self.livenessState.awaitingRecording() - self.initializeLivenessStream() } DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.livenessState.beginRecording() @@ -55,7 +56,6 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { ) }) case .recording(ovalDisplayed: true): - guard let sessionConfiguration = sessionConfiguration else { return } let instruction = faceInOvalMatching.faceMatchState( for: normalizedFace.boundingBox, in: ovalRect, @@ -64,18 +64,18 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { handleInstruction( instruction, - colorSequences: sessionConfiguration.colorChallenge.colors + colorSequences: sessionConfiguration.colorChallenge?.colors ) case .awaitingFaceInOvalMatch: - guard let sessionConfiguration = sessionConfiguration else { return } let instruction = faceInOvalMatching.faceMatchState( for: normalizedFace.boundingBox, in: ovalRect, challengeConfig: sessionConfiguration.ovalMatchChallenge ) + handleInstruction( instruction, - colorSequences: sessionConfiguration.colorChallenge.colors + colorSequences: sessionConfiguration.colorChallenge?.colors ) default: break @@ -104,16 +104,30 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { func handleInstruction( _ instruction: Instructor.Instruction, - colorSequences: [FaceLivenessSession.DisplayColor] + colorSequences: [FaceLivenessSession.DisplayColor]? ) { DispatchQueue.main.async { switch instruction { case .match: self.livenessState.faceMatched() self.faceMatchedTimestamp = Date().timestampMilliseconds - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.livenessViewControllerDelegate?.displayFreshness(colorSequences: colorSequences) + + // next step after face match + switch self.challenge?.type { + case .faceMovementAndLightChallenge: + if let colorSequences = colorSequences { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.livenessViewControllerDelegate?.displayFreshness(colorSequences: colorSequences) + } + } + case .faceMovementChallenge: + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.livenessViewControllerDelegate?.completeNoLightCheck() + } + default: + break } + let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(.success) self.noFitStartTime = nil diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+VideoSegmentProcessor.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+VideoSegmentProcessor.swift index c2ed2b39..d2f88343 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+VideoSegmentProcessor.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+VideoSegmentProcessor.swift @@ -11,8 +11,8 @@ extension FaceLivenessDetectionViewModel: VideoSegmentProcessor { func process(initalSegment: Data, currentSeparableSegment: Data) { let chunk = chunk(initial: initalSegment, current: currentSeparableSegment) sendVideoEvent(data: chunk, videoEventTime: .zero) - if !hasSentFinalVideoEvent, - case .completedDisplayingFreshness = livenessState.state { + if !hasSentFinalVideoEvent && + (livenessState.state == .completedDisplayingFreshness || livenessState.state == .completedNoLightCheck) { DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 0.9) { self.sendFinalVideoEvent() } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift index b1a95f36..352b1855 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift @@ -33,6 +33,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { var hasSentFirstVideo = false var layerRectConverted: (CGRect) -> CGRect = { $0 } var sessionConfiguration: FaceLivenessSession.SessionConfiguration? + var challenge: Challenge? var normalizeFace: (DetectedFace) -> DetectedFace = { $0 } var provideSingleFrame: ((UIImage) -> Void)? var cameraViewRect = CGRect.zero @@ -89,7 +90,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { NotificationCenter.default.removeObserver(self) } - func registerServiceEvents() { + func registerServiceEvents(onChallengeTypeReceived: @escaping (Challenge) -> Void) { livenessService?.register(onComplete: { [weak self] reason in self?.stopRecording() @@ -112,6 +113,13 @@ class FaceLivenessDetectionViewModel: ObservableObject { }, on: .challenge ) + + livenessService?.register( + listener: { [weak self] _challenge in + self?.challenge = _challenge + onChallengeTypeReceived(_challenge) + }, + on: .challenge) } @objc func willResignActive(_ notification: Notification) { @@ -178,7 +186,11 @@ class FaceLivenessDetectionViewModel: ObservableObject { func initializeLivenessStream() { do { - try livenessService?.initializeLivenessStream( + guard let livenessSession = livenessService as? FaceLivenessSession else { + throw FaceLivenessDetectionError.unknown + } + + try livenessSession.initializeLivenessStream( withSessionID: sessionID, userAgent: UserAgentValues.standard().userAgentString ) @@ -226,6 +238,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { videoStartTime: UInt64 ) { guard initialClientEvent == nil else { return } + guard let challenge else { return } + videoChunker.start() let initialFace = FaceDetection( @@ -243,7 +257,9 @@ class FaceLivenessDetectionViewModel: ObservableObject { do { try livenessService?.send( - .initialFaceDetected(event: _initialClientEvent), + .initialFaceDetected(event: _initialClientEvent, + challenge: .init(version: challenge.version, + type: challenge.type)), eventDate: { .init() } ) } catch { @@ -261,7 +277,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { guard let sessionConfiguration, let initialClientEvent, - let faceMatchedTimestamp + let faceMatchedTimestamp, + let challenge else { return } let finalClientEvent = FinalClientEvent( @@ -275,7 +292,9 @@ class FaceLivenessDetectionViewModel: ObservableObject { do { try livenessService?.send( - .final(event: finalClientEvent), + .final(event: finalClientEvent, + challenge: .init(version: challenge.version, + type: challenge.type)), eventDate: { .init() } ) @@ -310,6 +329,13 @@ class FaceLivenessDetectionViewModel: ObservableObject { self.faceGuideRect = faceGuide } } + + func completeNoLightCheck(faceGuide: CGRect) { + DispatchQueue.main.async { + self.livenessState.completedNoLightCheck() + self.faceGuideRect = faceGuide + } + } func sendVideoEvent(data: Data, videoEventTime: UInt64) { guard !hasSentFinalVideoEvent else { return } @@ -362,3 +388,5 @@ class FaceLivenessDetectionViewModel: ObservableObject { return data } } + +extension FaceLivenessDetectionViewModel: FaceDetectionSessionConfigurationWrapper { } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessViewControllerPresenter.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessViewControllerPresenter.swift index 5786620b..8fff8b9f 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessViewControllerPresenter.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessViewControllerPresenter.swift @@ -12,4 +12,5 @@ protocol FaceLivenessViewControllerPresenter: AnyObject { func drawOvalInCanvas(_ ovalRect: CGRect) func displayFreshness(colorSequences: [FaceLivenessSession.DisplayColor]) func displaySingleFrame(uiImage: UIImage) + func completeNoLightCheck() } diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift b/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift index 872c7ee6..fa66ffb0 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift @@ -76,6 +76,10 @@ struct LivenessStateMachine { mutating func completedDisplayingFreshness() { state = .completedDisplayingFreshness } + + mutating func completedNoLightCheck() { + state = .completedNoLightCheck + } mutating func displayingFreshness() { state = .displayingFreshness @@ -95,6 +99,7 @@ struct LivenessStateMachine { enum State: Equatable { case initial + case awaitingChallengeType case pendingFacePreparedConfirmation(FaceNotPreparedReason) case recording(ovalDisplayed: Bool) case awaitingFaceInOvalMatch(FaceNotPreparedReason, Double) @@ -102,6 +107,7 @@ struct LivenessStateMachine { case initialClientInfoEventSent case displayingFreshness case completedDisplayingFreshness + case completedNoLightCheck case completed case awaitingDisconnectEvent case disconnectEventReceived diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift index 5e698f23..0435a862 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift @@ -169,4 +169,11 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { self.ovalExists = true } } + + func completeNoLightCheck() { + guard let faceGuideRect = self.faceGuideRect else { return } + self.viewModel.completeNoLightCheck( + faceGuide: faceGuideRect + ) + } } diff --git a/Sources/FaceLiveness/Views/LoadingPage/LoadingPageView.swift b/Sources/FaceLiveness/Views/LoadingPage/LoadingPageView.swift new file mode 100644 index 00000000..e02b4e79 --- /dev/null +++ b/Sources/FaceLiveness/Views/LoadingPage/LoadingPageView.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SwiftUI + +struct LoadingPageView: View { + + var body: some View { + VStack { + HStack(spacing: 5) { + ProgressView() + Text(LocalizedStrings.challenge_connecting) + } + + } + } +} + +struct LoadingPageView_Previews: PreviewProvider { + static var previews: some View { + LoadingPageView() + } +} diff --git a/Tests/FaceLivenessTests/DetectedFaceTests.swift b/Tests/FaceLivenessTests/DetectedFaceTests.swift index 4bee8292..6d538e33 100644 --- a/Tests/FaceLivenessTests/DetectedFaceTests.swift +++ b/Tests/FaceLivenessTests/DetectedFaceTests.swift @@ -7,7 +7,7 @@ import XCTest @testable import FaceLiveness - +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin final class DetectedFaceTests: XCTestCase { var detectedFace: DetectedFace! @@ -104,7 +104,29 @@ final class DetectedFaceTests: XCTestCase { width: 0.6240418540649166, height: 0.8144985824018897 ) - let boundingBox = detectedFace.boundingBoxFromLandmarks(ovalRect: ovalRect) + + let face = FaceLivenessSession.OvalMatchChallenge.Face( + distanceThreshold: 0.1, + distanceThresholdMax: 0.1, + distanceThresholdMin: 0.1, + iouWidthThreshold: 0.1, + iouHeightThreshold: 0.1 + ) + + let oval = FaceLivenessSession.OvalMatchChallenge.Oval(boundingBox: .init(x: 0.1, + y: 0.1, + width: 0.1, + height: 0.1), + heightWidthRatio: 1.618, + iouThreshold: 0.1, + iouWidthThreshold: 0.1, + iouHeightThreshold: 0.1, + ovalFitTimeout: 1) + + let boundingBox = detectedFace.boundingBoxFromLandmarks(ovalRect: ovalRect, + ovalMatchChallenge: .init(faceDetectionThreshold: 0.7, + face: face, + oval: oval)) XCTAssertEqual(boundingBox.origin.x, expectedBoundingBox.origin.x) XCTAssertEqual(boundingBox.origin.y, expectedBoundingBox.origin.y) XCTAssertEqual(boundingBox.width, expectedBoundingBox.width) diff --git a/Tests/FaceLivenessTests/LivenessTests.swift b/Tests/FaceLivenessTests/LivenessTests.swift index da063930..c4b95d02 100644 --- a/Tests/FaceLivenessTests/LivenessTests.swift +++ b/Tests/FaceLivenessTests/LivenessTests.swift @@ -69,6 +69,7 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { /// Then: The end state of this flow is `.faceMatched` func testHappyPathToMatchedFace() async throws { viewModel.livenessService = self.livenessService + viewModel.challenge = Challenge(version: "2.0.0", type: .faceMovementAndLightChallenge) viewModel.livenessState.checkIsFacePrepared() XCTAssertEqual(viewModel.livenessState.state, .pendingFacePreparedConfirmation(.pendingCheck)) @@ -103,16 +104,37 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { XCTAssertEqual(faceDetector.interactions, [ "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" ]) - XCTAssertEqual(livenessService.interactions, [ - "initializeLivenessStream(withSessionID:userAgent:)" - ]) + XCTAssertEqual(livenessService.interactions, []) } /// Given: A `FaceLivenessDetectionViewModel` /// When: The viewModel is processes a single face result with a face distance less than the inital face distance - /// Then: The end state of this flow is `.recording(ovalDisplayed: false)` and initializeLivenessStream(withSessionID:userAgent:) is called + /// Then: The end state of this flow is `.recording(ovalDisplayed: false)` func testTransitionToRecordingState() async throws { viewModel.livenessService = self.livenessService + viewModel.challenge = Challenge(version: "2.0.0", type: .faceMovementAndLightChallenge) + + let face = FaceLivenessSession.OvalMatchChallenge.Face( + distanceThreshold: 0.32, + distanceThresholdMax: 0.1, + distanceThresholdMin: 0.1, + iouWidthThreshold: 0.1, + iouHeightThreshold: 0.1 + ) + + let oval = FaceLivenessSession.OvalMatchChallenge.Oval(boundingBox: .init(x: 0.1, + y: 0.1, + width: 0.1, + height: 0.1), + heightWidthRatio: 1.618, + iouThreshold: 0.1, + iouWidthThreshold: 0.1, + iouHeightThreshold: 0.1, + ovalFitTimeout: 1) + + viewModel.sessionConfiguration = .init(ovalMatchChallenge: .init(faceDetectionThreshold: 0.7, + face: face, + oval: oval)) viewModel.livenessState.checkIsFacePrepared() XCTAssertEqual(viewModel.livenessState.state, .pendingFacePreparedConfirmation(.pendingCheck)) @@ -136,9 +158,6 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { XCTAssertEqual(faceDetector.interactions, [ "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" ]) - XCTAssertEqual(livenessService.interactions, [ - "initializeLivenessStream(withSessionID:userAgent:)" - ]) } /// Given: A `FaceLivenessDetectionViewModel` diff --git a/Tests/FaceLivenessTests/MockLivenessService.swift b/Tests/FaceLivenessTests/MockLivenessService.swift index 2b4633d1..942f7488 100644 --- a/Tests/FaceLivenessTests/MockLivenessService.swift +++ b/Tests/FaceLivenessTests/MockLivenessService.swift @@ -18,7 +18,7 @@ class MockLivenessService { var onFinalClientEvent: (LivenessEvent, Date) -> Void = { _, _ in } var onFreshnessEvent: (LivenessEvent, Date) -> Void = { _, _ in } var onVideoEvent: (LivenessEvent, Date) -> Void = { _, _ in } - var onInitializeLivenessStream: (String, String) -> Void = { _, _ in } + var onInitializeLivenessStream: (String, String,[Challenge]?) -> Void = { _, _, _ in } var onServiceException: (FaceLivenessSessionError) -> Void = { _ in } var onCloseSocket: (URLSessionWebSocketTask.CloseCode) -> Void = { _ in } } @@ -44,10 +44,12 @@ extension MockLivenessService: LivenessService { } func initializeLivenessStream( - withSessionID sessionID: String, userAgent: String + withSessionID sessionID: String, + userAgent: String, + challenges: [Challenge] ) throws { interactions.append(#function) - onInitializeLivenessStream(sessionID, userAgent) + onInitializeLivenessStream(sessionID, userAgent, challenges) } func register( @@ -62,6 +64,10 @@ extension MockLivenessService: LivenessService { ) { interactions.append(#function) } + + func register(listener: @escaping (Challenge) -> Void, on event: LivenessEventKind.Server) { + interactions.append(#function) + } func closeSocket(with code: URLSessionWebSocketTask.CloseCode) { interactions.append(#function)