diff --git a/HostApp/HostApp.xcodeproj/project.pbxproj b/HostApp/HostApp.xcodeproj/project.pbxproj index 7d1314c5..76052c19 100644 --- a/HostApp/HostApp.xcodeproj/project.pbxproj +++ b/HostApp/HostApp.xcodeproj/project.pbxproj @@ -131,6 +131,7 @@ 9070FFBD285112B5009867D5 /* HostAppUITests */, 9070FFA1285112B4009867D5 /* Products */, 90215EED291E9FB60050F2AD /* Frameworks */, + A5A9AF5054D0FF13505B212A /* AmplifyConfig */, ); sourceTree = ""; }; @@ -213,6 +214,15 @@ path = Model; sourceTree = ""; }; + A5A9AF5054D0FF13505B212A /* AmplifyConfig */ = { + isa = PBXGroup; + children = ( + 973619242BA378690003A590 /* awsconfiguration.json */, + 973619232BA378690003A590 /* amplifyconfiguration.json */, + ); + name = AmplifyConfig; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9a475d8f..a47e606b 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" : "5b603ff7cfe1b03d753ae7ff9664316e6447f0ae", - "version" : "2.46.1" + "revision" : "7fa7abdb9daf25bdd97cc4fbcdd0d5a5cc9c4bf1", + "version" : "2.49.0" } }, { @@ -23,17 +23,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/awslabs/aws-crt-swift", "state" : { - "revision" : "dd17a98750b6182edacd6e8f0c30aa289c472b22", - "version" : "0.40.0" + "revision" : "74d970dde8a0d6b2fe1d8374767ca9793088ce2c", + "version" : "0.48.0" } }, { "identity" : "aws-sdk-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-sdk-swift.git", + "location" : "https://github.com/awslabs/aws-sdk-swift", "state" : { - "revision" : "9ad12684f6cb9c9b60e840c051a2bba604024650", - "version" : "1.0.69" + "revision" : "104958a898543582bb01102616bf5d61ed237352", + "version" : "1.2.59" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/smithy-lang/smithy-swift", "state" : { - "revision" : "402f091374dcf72c1e7ed43af10e3ee7e634fad8", - "version" : "0.106.0" + "revision" : "755367ae4e10004f8b5a94fbfdf3f638a1f225bc", + "version" : "0.125.0" } }, { diff --git a/HostApp/HostApp/Model/LivenessResult.swift b/HostApp/HostApp/Model/LivenessResult.swift index 226bc30f..2d76773b 100644 --- a/HostApp/HostApp/Model/LivenessResult.swift +++ b/HostApp/HostApp/Model/LivenessResult.swift @@ -6,11 +6,13 @@ // import Foundation +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin struct LivenessResult: Codable { let auditImageBytes: String? let confidenceScore: Double let isLive: Bool + let challenge: Event? } extension LivenessResult: CustomDebugStringConvertible { @@ -20,6 +22,17 @@ extension LivenessResult: CustomDebugStringConvertible { - confidenceScore: \(confidenceScore) - isLive: \(isLive) - auditImageBytes: \(auditImageBytes == nil ? "nil" : "") + - challenge: type: \(String(describing: challenge?.type)) + " version: " + \(String(describing: challenge?.version)) """ } } + +struct Event: Codable { + let version: String + let type: ChallengeType + + enum CodingKeys: String, CodingKey { + case version = "Version" + case type = "Type" + } +} diff --git a/HostApp/HostApp/Views/ExampleLivenessView.swift b/HostApp/HostApp/Views/ExampleLivenessView.swift index 5f6868b6..03e4dd55 100644 --- a/HostApp/HostApp/Views/ExampleLivenessView.swift +++ b/HostApp/HostApp/Views/ExampleLivenessView.swift @@ -9,22 +9,27 @@ import SwiftUI import FaceLiveness struct ExampleLivenessView: View { - @Binding var isPresented: Bool + @Binding var containerViewState: ContainerViewState @ObservedObject var viewModel: ExampleLivenessViewModel - init(sessionID: String, isPresented: Binding) { - self.viewModel = .init(sessionID: sessionID) - self._isPresented = isPresented + init(sessionID: String, containerViewState: Binding) { + self._containerViewState = containerViewState + if case let .liveness(selectedCamera) = _containerViewState.wrappedValue { + self.viewModel = .init(sessionID: sessionID, presentationState: .liveness(selectedCamera)) + } else { + self.viewModel = .init(sessionID: sessionID) + } } var body: some View { switch viewModel.presentationState { - case .liveness: + case .liveness(let camera): FaceLivenessDetectorView( sessionID: viewModel.sessionID, region: "us-east-1", + challengeOptions: .init(faceMovementChallengeOption: FaceMovementChallengeOption(camera: camera)), isPresented: Binding( - get: { viewModel.presentationState == .liveness }, + get: { viewModel.presentationState == .liveness(camera) }, set: { _ in } ), onCompletion: { result in @@ -33,11 +38,11 @@ struct ExampleLivenessView: View { case .success: withAnimation { viewModel.presentationState = .result } case .failure(.sessionNotFound), .failure(.cameraPermissionDenied), .failure(.accessDenied): - viewModel.presentationState = .liveness - isPresented = false + viewModel.presentationState = .liveness(camera) + containerViewState = .startSession case .failure(.userCancelled): - viewModel.presentationState = .liveness - isPresented = false + viewModel.presentationState = .liveness(camera) + containerViewState = .startSession case .failure(.sessionTimedOut): viewModel.presentationState = .error(.sessionTimedOut) case .failure(.socketClosed): @@ -46,6 +51,10 @@ struct ExampleLivenessView: View { viewModel.presentationState = .error(.countdownFaceTooClose) case .failure(.invalidSignature): viewModel.presentationState = .error(.invalidSignature) + case .failure(.faceInOvalMatchExceededTimeLimitError): + viewModel.presentationState = .error(.faceInOvalMatchExceededTimeLimitError) + case .failure(.internalServer): + viewModel.presentationState = .error(.internalServer) case .failure(.cameraNotAvailable): viewModel.presentationState = .error(.cameraNotAvailable) case .failure(.validation): @@ -58,11 +67,11 @@ struct ExampleLivenessView: View { } } ) - .id(isPresented) + .id(containerViewState) case .result: LivenessResultView( sessionID: viewModel.sessionID, - onTryAgain: { isPresented = false }, + onTryAgain: { containerViewState = .startSession }, content: { LivenessResultContentView(fetchResults: viewModel.fetchLivenessResult) } @@ -71,7 +80,7 @@ struct ExampleLivenessView: View { case .error(let detectionError): LivenessResultView( sessionID: viewModel.sessionID, - onTryAgain: { isPresented = false }, + onTryAgain: { containerViewState = .startSession }, content: { switch detectionError { case .socketClosed: diff --git a/HostApp/HostApp/Views/ExampleLivenessViewModel.swift b/HostApp/HostApp/Views/ExampleLivenessViewModel.swift index a04571bc..7dade2fb 100644 --- a/HostApp/HostApp/Views/ExampleLivenessViewModel.swift +++ b/HostApp/HostApp/Views/ExampleLivenessViewModel.swift @@ -10,11 +10,12 @@ import FaceLiveness import Amplify class ExampleLivenessViewModel: ObservableObject { - @Published var presentationState = PresentationState.liveness + @Published var presentationState: PresentationState = .liveness(.front) let sessionID: String - init(sessionID: String) { + init(sessionID: String, presentationState: PresentationState = .liveness(.front)) { self.sessionID = sessionID + self.presentationState = presentationState } func fetchLivenessResult() async throws -> LivenessResultContentView.Result { @@ -30,6 +31,6 @@ class ExampleLivenessViewModel: ObservableObject { } enum PresentationState: Equatable { - case liveness, result, error(FaceLivenessDetectionError) + case liveness(LivenessCamera), result, error(FaceLivenessDetectionError) } } diff --git a/HostApp/HostApp/Views/LivenessResultContentView+Result.swift b/HostApp/HostApp/Views/LivenessResultContentView+Result.swift index 3f57982f..e41759ad 100644 --- a/HostApp/HostApp/Views/LivenessResultContentView+Result.swift +++ b/HostApp/HostApp/Views/LivenessResultContentView+Result.swift @@ -6,6 +6,7 @@ // import SwiftUI +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin extension LivenessResultContentView { struct Result { @@ -15,6 +16,7 @@ extension LivenessResultContentView { let valueBackgroundColor: Color let auditImage: Data? let isLive: Bool + let challenge: Event? init(livenessResult: LivenessResult) { guard livenessResult.confidenceScore > 0 else { @@ -24,6 +26,7 @@ extension LivenessResultContentView { valueBackgroundColor = .clear auditImage = nil isLive = false + challenge = nil return } isLive = livenessResult.isLive @@ -41,6 +44,7 @@ extension LivenessResultContentView { auditImage = livenessResult.auditImageBytes.flatMap{ Data(base64Encoded: $0) } + challenge = livenessResult.challenge } } diff --git a/HostApp/HostApp/Views/LivenessResultContentView.swift b/HostApp/HostApp/Views/LivenessResultContentView.swift index de2ecff7..51660f55 100644 --- a/HostApp/HostApp/Views/LivenessResultContentView.swift +++ b/HostApp/HostApp/Views/LivenessResultContentView.swift @@ -6,9 +6,10 @@ // import SwiftUI +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin struct LivenessResultContentView: View { - @State var result: Result = .init(livenessResult: .init(auditImageBytes: nil, confidenceScore: -1, isLive: false)) + @State var result: Result = .init(livenessResult: .init(auditImageBytes: nil, confidenceScore: -1, isLive: false, challenge: nil)) let fetchResults: () async throws -> Result var body: some View { @@ -67,26 +68,48 @@ struct LivenessResultContentView: View { } } + func step(number: Int, text: String) -> some View { + HStack(alignment: .top) { + Text("\(number).") + Text(text) + } + } + + @ViewBuilder private func steps() -> some View { - func step(number: Int, text: String) -> some View { - HStack(alignment: .top) { - Text("\(number).") - Text(text) + switch result.challenge?.type { + case .faceMovementChallenge: + VStack( + alignment: .leading, + spacing: 8 + ) { + Text("Tips to pass the video check:") + .fontWeight(.semibold) + + Text("Remove sunglasses, mask, hat, or anything blocking your face.") + .accessibilityElement(children: .combine) + } + case .faceMovementAndLightChallenge: + VStack( + alignment: .leading, + spacing: 8 + ) { + Text("Tips to pass the video check:") + .fontWeight(.semibold) + + step(number: 1, text: "Avoid very bright lighting conditions, such as direct sunlight.") + .accessibilityElement(children: .combine) + + step(number: 2, text: "Remove sunglasses, mask, hat, or anything blocking your face.") + .accessibilityElement(children: .combine) + } + case .none: + VStack( + alignment: .leading, + spacing: 8 + ) { + EmptyView() } - } - - return VStack( - alignment: .leading, - spacing: 8 - ) { - Text("Tips to pass the video check:") - .fontWeight(.semibold) - - step(number: 1, text: "Avoid very bright lighting conditions, such as direct sunlight.") - .accessibilityElement(children: .combine) - - step(number: 2, text: "Remove sunglasses, mask, hat, or anything blocking your face.") - .accessibilityElement(children: .combine) } } } @@ -99,7 +122,8 @@ extension LivenessResultContentView { livenessResult: .init( auditImageBytes: nil, confidenceScore: 99.8329, - isLive: true + isLive: true, + challenge: nil ) ) } diff --git a/HostApp/HostApp/Views/RootView.swift b/HostApp/HostApp/Views/RootView.swift index 7600f1b4..59a3c815 100644 --- a/HostApp/HostApp/Views/RootView.swift +++ b/HostApp/HostApp/Views/RootView.swift @@ -6,25 +6,32 @@ // import SwiftUI +import FaceLiveness struct RootView: View { @EnvironmentObject var sceneDelegate: SceneDelegate @State var sessionID = "" - @State var isPresentingContainerView = false + @State var containerViewState = ContainerViewState.startSession var body: some View { - if isPresentingContainerView { + switch containerViewState { + case .liveness: ExampleLivenessView( sessionID: sessionID, - isPresented: $isPresentingContainerView + containerViewState: $containerViewState ) - } else { + case .startSession: StartSessionView( sessionID: $sessionID, - isPresentingContainerView: $isPresentingContainerView + containerViewState: $containerViewState ) .background(Color.dynamicColors(light: .white, dark: .secondarySystemBackground)) .edgesIgnoringSafeArea(.all) } } } + +enum ContainerViewState: Hashable { + case liveness(LivenessCamera) + case startSession +} diff --git a/HostApp/HostApp/Views/StartSessionView.swift b/HostApp/HostApp/Views/StartSessionView.swift index 42f64401..9e909a6b 100644 --- a/HostApp/HostApp/Views/StartSessionView.swift +++ b/HostApp/HostApp/Views/StartSessionView.swift @@ -12,7 +12,7 @@ struct StartSessionView: View { @EnvironmentObject var sceneDelegate: SceneDelegate @ObservedObject var viewModel = StartSessionViewModel() @Binding var sessionID: String - @Binding var isPresentingContainerView: Bool + @Binding var containerViewState: ContainerViewState @State private var showAlert = false var body: some View { @@ -35,7 +35,8 @@ struct StartSessionView: View { viewModel.createSession { sessionId, err in if let sessionId = sessionId { sessionID = sessionId - isPresentingContainerView = true + // modify camera preference for `FaceMovementChallenge` + containerViewState = .liveness(.front) } showAlert = err != nil @@ -50,7 +51,7 @@ struct StartSessionView: View { dismissButton: .default( Text("OK"), action: { - isPresentingContainerView = false + containerViewState = .startSession } ) ) diff --git a/Package.resolved b/Package.resolved index e4e6e13c..a47e606b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/aws-amplify/amplify-swift", "state" : { - "revision" : "5b603ff7cfe1b03d753ae7ff9664316e6447f0ae", - "version" : "2.46.1" + "revision" : "7fa7abdb9daf25bdd97cc4fbcdd0d5a5cc9c4bf1", + "version" : "2.49.0" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/awslabs/aws-crt-swift", "state" : { - "revision" : "dd17a98750b6182edacd6e8f0c30aa289c472b22", - "version" : "0.40.0" + "revision" : "74d970dde8a0d6b2fe1d8374767ca9793088ce2c", + "version" : "0.48.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/awslabs/aws-sdk-swift", "state" : { - "revision" : "9ad12684f6cb9c9b60e840c051a2bba604024650", - "version" : "1.0.69" + "revision" : "104958a898543582bb01102616bf5d61ed237352", + "version" : "1.2.59" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/smithy-lang/smithy-swift", "state" : { - "revision" : "402f091374dcf72c1e7ed43af10e3ee7e634fad8", - "version" : "0.106.0" + "revision" : "755367ae4e10004f8b5a94fbfdf3f638a1f225bc", + "version" : "0.125.0" } }, { diff --git a/Package.swift b/Package.swift index 4e2d2e80..5eb3fae2 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,7 @@ let package = Package( targets: ["FaceLiveness"]), ], dependencies: [ - .package(url: "https://github.com/aws-amplify/amplify-swift", exact: "2.46.1") + .package(url: "https://github.com/aws-amplify/amplify-swift", exact: "2.49.0") ], 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..2e8a6900 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,22 @@ extension FaceDetectorShortRange { count: confidenceScoresCapacity ) ) + + let blazeFaceDetectionThreshold: Float + if let sessionConfiguration = faceDetectionSessionConfiguration?.sessionConfiguration { + switch sessionConfiguration { + case .faceMovement(let ovalMatchChallenge): + blazeFaceDetectionThreshold = Float(ovalMatchChallenge.faceDetectionThreshold) + case .faceMovementAndLight(_, let ovalMatchChallenge): + blazeFaceDetectionThreshold = Float(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/Utilities/FinalClientEvent+Init.swift b/Sources/FaceLiveness/Utilities/FinalClientEvent+Init.swift index ccdf971f..9e864ce2 100644 --- a/Sources/FaceLiveness/Utilities/FinalClientEvent+Init.swift +++ b/Sources/FaceLiveness/Utilities/FinalClientEvent+Init.swift @@ -17,8 +17,14 @@ extension FinalClientEvent { faceMatchedEnd: UInt64, videoEnd: UInt64 ) { - let normalizedBoundingBox = sessionConfiguration - .ovalMatchChallenge + let ovalMatchChallenge: FaceLivenessSession.OvalMatchChallenge + switch sessionConfiguration { + case .faceMovement(let challenge): + ovalMatchChallenge = challenge + case .faceMovementAndLight(_, let challenge): + ovalMatchChallenge = challenge + } + let normalizedBoundingBox = ovalMatchChallenge .oval.boundingBox .normalize(within: videoSize) diff --git a/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift b/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift index e5edbf3f..ee9f3966 100644 --- a/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift +++ b/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift @@ -17,7 +17,7 @@ struct CameraPermissionView: View { } var body: some View { - VStack(alignment: .leading) { + VStack(alignment: .center) { Spacer() VStack { Text(LocalizedStrings.camera_permission_change_setting_header) diff --git a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift index 2e8530f3..19e2f483 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift @@ -15,7 +15,7 @@ struct CameraPreviewView: View { @StateObject var model: CameraPreviewViewModel - init(model: CameraPreviewViewModel = CameraPreviewViewModel()) { + init(model: CameraPreviewViewModel = CameraPreviewViewModel(cameraPosition: .front)) { self._model = StateObject(wrappedValue: model) } diff --git a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewViewModel.swift b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewViewModel.swift index b50173b0..a46dbaa8 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewViewModel.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewViewModel.swift @@ -16,15 +16,18 @@ class CameraPreviewViewModel: NSObject, ObservableObject { @Published var buffer: CVPixelBuffer? var previewCaptureSession: LivenessCaptureSession? + let cameraPosition: LivenessCamera - override init() { + init(cameraPosition: LivenessCamera) { + self.cameraPosition = cameraPosition + super.init() setupSubscriptions() let avCaptureDevice = AVCaptureDevice.DiscoverySession( deviceTypes: [.builtInWideAngleCamera], mediaType: .video, - position: .front + position: cameraPosition == .front ? .front : .back ).devices.first let outputDelegate = CameraPreviewOutputSampleBufferDelegate { [weak self] buffer in diff --git a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift index 00ecb9b7..5f5279e9 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift @@ -6,23 +6,30 @@ // import SwiftUI +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin struct GetReadyPageView: View { let beginCheckButtonDisabled: Bool let onBegin: () -> Void - + let challenge: Challenge + let cameraPosition: LivenessCamera + init( onBegin: @escaping () -> Void, - beginCheckButtonDisabled: Bool = false + beginCheckButtonDisabled: Bool = false, + challenge: Challenge, + cameraPosition: LivenessCamera ) { self.onBegin = onBegin self.beginCheckButtonDisabled = beginCheckButtonDisabled + self.challenge = challenge + self.cameraPosition = cameraPosition } var body: some View { VStack { ZStack { - CameraPreviewView() + CameraPreviewView(model: CameraPreviewViewModel(cameraPosition: cameraPosition)) VStack { WarningBox( titleText: LocalizedStrings.get_ready_photosensitivity_title, @@ -30,6 +37,7 @@ struct GetReadyPageView: View { popoverContent: { photosensitivityWarningPopoverContent } ) .accessibilityElement(children: .combine) + .opacity(challenge == Challenge.faceMovementAndLightChallenge("2.0.0") ? 1.0 : 0.0) Text(LocalizedStrings.preview_center_your_face_text) .font(.title) .multilineTextAlignment(.center) @@ -72,6 +80,9 @@ struct GetReadyPageView: View { struct GetReadyPageView_Previews: PreviewProvider { static var previews: some View { - GetReadyPageView(onBegin: {}) + GetReadyPageView( + onBegin: {}, + challenge: .faceMovementAndLightChallenge("2.0.0"), + cameraPosition: .front) } } diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift index ff02a3d6..0a4e0367 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.challengeReceived, + case .faceMovementAndLightChallenge = challenge { + InstructionView( + text: LocalizedStrings.challenge_instruction_hold_still, + backgroundColor: .livenessPrimaryBackground, + textColor: .livenessPrimaryLabel, + font: .title + ) + } else { + EmptyView() + } default: EmptyView() } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionError.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionError.swift index e90a6f06..19ced079 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionError.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionError.swift @@ -125,7 +125,7 @@ public struct FaceLivenessDetectionError: Error, Equatable { message: "The signature on the request is invalid.", recoverySuggestion: "Ensure the device time is correct and try again." ) - + public static let cameraNotAvailable = FaceLivenessDetectionError( code: 18, message: "The camera is not available.", diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index 81eacfe9..8f3374d8 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 challengeOptions: ChallengeOptions let onCompletion: (Result) -> Void let sessionTask: Task @@ -29,19 +30,20 @@ public struct FaceLivenessDetectorView: View { credentialsProvider: AWSCredentialsProvider? = nil, region: String, disableStartView: Bool = false, + challengeOptions: ChallengeOptions, isPresented: Binding, onCompletion: @escaping (Result) -> Void - ) { + ) { self.disableStartView = disableStartView self._isPresented = isPresented self.onCompletion = onCompletion + self.challengeOptions = challengeOptions self.sessionTask = Task { let session = try await AWSPredictionsPlugin.startFaceLivenessSession( withID: sessionID, credentialsProvider: credentialsProvider, region: region, - options: .init(), completion: map(detectionCompletion: onCompletion) ) return session @@ -58,28 +60,15 @@ public struct FaceLivenessDetectorView: View { assetWriterInput: LivenessAVAssetWriterInput() ) - let avCpatureDevice = AVCaptureDevice.DiscoverySession( - deviceTypes: [.builtInWideAngleCamera], - mediaType: .video, - position: .front - ).devices.first - - let captureSession = LivenessCaptureSession( - captureDevice: .init(avCaptureDevice: avCpatureDevice), - outputDelegate: OutputSampleBufferCapturer( - faceDetector: faceDetector, - videoChunker: videoChunker - ) - ) - self._viewModel = StateObject( wrappedValue: .init( faceDetector: faceDetector, faceInOvalMatching: faceInOvalStateMatching, - captureSession: captureSession, videoChunker: videoChunker, closeButtonAction: { onCompletion(.failure(.userCancelled)) }, - sessionID: sessionID + sessionID: sessionID, + isPreviewScreenEnabled: !disableStartView, + challengeOptions: challengeOptions ) ) } @@ -89,6 +78,7 @@ public struct FaceLivenessDetectorView: View { credentialsProvider: AWSCredentialsProvider? = nil, region: String, disableStartView: Bool = false, + challengeOptions: ChallengeOptions, isPresented: Binding, onCompletion: @escaping (Result) -> Void, captureSession: LivenessCaptureSession @@ -96,13 +86,13 @@ public struct FaceLivenessDetectorView: View { self.disableStartView = disableStartView self._isPresented = isPresented self.onCompletion = onCompletion + self.challengeOptions = challengeOptions self.sessionTask = Task { let session = try await AWSPredictionsPlugin.startFaceLivenessSession( withID: sessionID, credentialsProvider: credentialsProvider, region: region, - options: .init(), completion: map(detectionCompletion: onCompletion) ) return session @@ -116,42 +106,104 @@ public struct FaceLivenessDetectorView: View { wrappedValue: .init( faceDetector: captureSession.outputSampleBufferCapturer!.faceDetector, faceInOvalMatching: faceInOvalStateMatching, - captureSession: captureSession, videoChunker: captureSession.outputSampleBufferCapturer!.videoChunker, closeButtonAction: { onCompletion(.failure(.userCancelled)) }, - sessionID: sessionID + sessionID: sessionID, + isPreviewScreenEnabled: !disableStartView, + challengeOptions: challengeOptions ) ) } 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.awaitingCameraPermission(challenge) + }) + viewModel.initializeLivenessStream() + } catch let error as FaceLivenessDetectionError { + switch error { + case .unknown: + viewModel.livenessState.unrecoverableStateEncountered(.unknown) + case .sessionTimedOut, + .faceInOvalMatchExceededTimeLimitError, + .countdownFaceTooClose, + .countdownMultipleFaces, + .countdownNoFace: + viewModel.livenessState.unrecoverableStateEncountered(.timedOut) + case .cameraPermissionDenied: + viewModel.livenessState.unrecoverableStateEncountered(.missingVideoPermission) + case .userCancelled: + viewModel.livenessState.unrecoverableStateEncountered(.userCancelled) + case .socketClosed: + viewModel.livenessState.unrecoverableStateEncountered(.socketClosed) + case .cameraNotAvailable: + viewModel.livenessState.unrecoverableStateEncountered(.cameraNotAvailable) + default: + viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) + } + } catch { + viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) + } + + DispatchQueue.main.async { + if let faceDetector = viewModel.faceDetector as? FaceDetectorShortRange.Model { + faceDetector.setFaceDetectionSessionConfigurationWrapper(configuration: viewModel) + } + } + } + } + .onReceive(viewModel.$livenessState) { output in + switch output.state { + case .encounteredUnrecoverableError(let error): + let closeCode = error.webSocketCloseCode ?? .normalClosure + viewModel.livenessService?.closeSocket(with: closeCode) + isPresented = false + onCompletion(.failure(mapError(error))) + default: + break + } + } + case .awaitingCameraPermission(let challenge): + CameraPermissionView(displayingCameraPermissionsNeededAlert: $displayingCameraPermissionsNeededAlert) + .onAppear { + checkCameraPermission(for: challenge) + } + case .awaitingLivenessSession(let challenge): Color.clear .onAppear { Task { - do { - let newState = disableStartView - ? DisplayState.displayingLiveness - : DisplayState.displayingGetReadyView - guard self.displayState != newState else { return } - let session = try await sessionTask.value - viewModel.livenessService = session - viewModel.registerServiceEvents() - self.displayState = newState - } catch { - throw FaceLivenessDetectionError.accessDenied + let cameraPosition: LivenessCamera + switch challenge { + case .faceMovementAndLightChallenge: + cameraPosition = challengeOptions.faceMovementAndLightChallengeOption.camera + case .faceMovementChallenge: + cameraPosition = challengeOptions.faceMovementChallengeOption.camera } + + let newState = disableStartView + ? DisplayState.displayingLiveness + : DisplayState.displayingGetReadyView(challenge, cameraPosition) + guard self.displayState != newState else { return } + self.displayState = newState } } - - case .displayingGetReadyView: + case .displayingGetReadyView(let challenge, let cameraPosition): GetReadyPageView( onBegin: { guard displayState != .displayingLiveness else { return } displayState = .displayingLiveness }, - beginCheckButtonDisabled: false + beginCheckButtonDisabled: false, + challenge: challenge, + cameraPosition: cameraPosition ) .onAppear { DispatchQueue.main.async { @@ -189,11 +241,6 @@ public struct FaceLivenessDetectorView: View { break } } - case .awaitingCameraPermission: - CameraPermissionView(displayingCameraPermissionsNeededAlert: $displayingCameraPermissionsNeededAlert) - .onAppear { - checkCameraPermission() - } } } @@ -203,7 +250,7 @@ public struct FaceLivenessDetectorView: View { return .userCancelled case .timedOut: return .faceInOvalMatchExceededTimeLimitError - case .socketClosed: + case .couldNotOpenStream, .socketClosed: return .socketClosed case .cameraNotAvailable: return .cameraNotAvailable @@ -212,41 +259,58 @@ public struct FaceLivenessDetectorView: View { } } - private func requestCameraPermission() { + private func requestCameraPermission(for challenge: Challenge) { AVCaptureDevice.requestAccess( for: .video, completionHandler: { accessGranted in guard accessGranted == true else { return } - displayState = .awaitingLivenessSession + displayState = .awaitingLivenessSession(challenge) } ) - } private func alertCameraAccessNeeded() { displayingCameraPermissionsNeededAlert = true } - private func checkCameraPermission() { + private func checkCameraPermission(for challenge: Challenge) { let cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) switch cameraAuthorizationStatus { case .notDetermined: - requestCameraPermission() + requestCameraPermission(for: challenge) case .restricted, .denied: alertCameraAccessNeeded() case .authorized: - displayState = .awaitingLivenessSession + displayState = .awaitingLivenessSession(challenge) @unknown default: break } } } -enum DisplayState { - case awaitingLivenessSession - case displayingGetReadyView +enum DisplayState: Equatable { + case awaitingChallengeType + case awaitingCameraPermission(Challenge) + case awaitingLivenessSession(Challenge) + case displayingGetReadyView(Challenge, LivenessCamera) 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 == c2 + case (let .displayingGetReadyView(c1, position1), let .displayingGetReadyView(c2, position2)): + return c1 == c2 && position1 == position2 + case (.displayingLiveness, .displayingLiveness): + return true + case (.awaitingCameraPermission, .awaitingCameraPermission): + return true + default: + return false + } + } } enum InstructionState { @@ -282,3 +346,39 @@ private func map(detectionCompletion: @escaping (Result Void let videoChunker: VideoChunker let sessionID: String @@ -28,44 +29,60 @@ class FaceLivenessDetectionViewModel: ObservableObject { let faceDetector: FaceDetector let faceInOvalMatching: FaceInOvalMatching let challengeID: String = UUID().uuidString + let isPreviewScreenEnabled : Bool var colorSequences: [ColorSequence] = [] var hasSentFinalVideoEvent = false var hasSentFirstVideo = false var layerRectConverted: (CGRect) -> CGRect = { $0 } var sessionConfiguration: FaceLivenessSession.SessionConfiguration? + var challengeReceived: Challenge? var normalizeFace: (DetectedFace) -> DetectedFace = { $0 } var provideSingleFrame: ((UIImage) -> Void)? var cameraViewRect = CGRect.zero var ovalRect = CGRect.zero - var faceGuideRect: CGRect! var initialClientEvent: InitialClientEvent? var faceMatchedTimestamp: UInt64? var noFitStartTime: Date? + let challengeOptions: ChallengeOptions + + static var attemptCount: Int = 0 + static var attemptIdTimeStamp: Date = Date() var noFitTimeoutInterval: TimeInterval { - if let sessionTimeoutMilliSec = sessionConfiguration?.ovalMatchChallenge.oval.ovalFitTimeout { - return TimeInterval(sessionTimeoutMilliSec/1_000) - } else { + guard let sessionConfiguration = sessionConfiguration else { return defaultNoFitTimeoutInterval } + + let ovalMatchChallenge: FaceLivenessSession.OvalMatchChallenge + switch sessionConfiguration{ + case .faceMovement(let challenge): + ovalMatchChallenge = challenge + case .faceMovementAndLight(_, let challenge): + ovalMatchChallenge = challenge + } + + let sessionTimeoutMilliSec = ovalMatchChallenge.oval.ovalFitTimeout + return TimeInterval(sessionTimeoutMilliSec/1_000) } init( faceDetector: FaceDetector, faceInOvalMatching: FaceInOvalMatching, - captureSession: LivenessCaptureSession, videoChunker: VideoChunker, stateMachine: LivenessStateMachine = .init(state: .initial), closeButtonAction: @escaping () -> Void, - sessionID: String + sessionID: String, + isPreviewScreenEnabled: Bool, + challengeOptions: ChallengeOptions ) { self.closeButtonAction = closeButtonAction self.videoChunker = videoChunker self.livenessState = stateMachine self.sessionID = sessionID - self.captureSession = captureSession self.faceDetector = faceDetector self.faceInOvalMatching = faceInOvalMatching + self.isPreviewScreenEnabled = isPreviewScreenEnabled + self.challengeOptions = challengeOptions self.closeButtonAction = { [weak self] in guard let self else { return } @@ -89,7 +106,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 +129,14 @@ class FaceLivenessDetectionViewModel: ObservableObject { }, on: .challenge ) + + livenessService?.register( + listener: { [weak self] _challenge in + self?.challengeReceived = _challenge + self?.configureCaptureSession(challenge: _challenge) + onChallengeTypeReceived(_challenge) + }, + on: .challenge) } @objc func willResignActive(_ notification: Notification) { @@ -123,16 +148,16 @@ class FaceLivenessDetectionViewModel: ObservableObject { } func startSession() { - captureSession.startSession() + captureSession?.startSession() } func stopRecording() { - captureSession.stopRunning() + captureSession?.stopRunning() } func configureCamera(withinFrame frame: CGRect) -> CALayer? { do { - let avLayer = try captureSession.configureCamera(frame: frame) + let avLayer = try captureSession?.configureCamera(frame: frame) DispatchQueue.main.async { self.livenessState.checkIsFacePrepared() } @@ -149,9 +174,17 @@ class FaceLivenessDetectionViewModel: ObservableObject { func drawOval(onComplete: @escaping () -> Void) { guard livenessState.state == .recording(ovalDisplayed: false), - let ovalParameters = sessionConfiguration?.ovalMatchChallenge.oval - else { return } - + let sessionConfiguration = sessionConfiguration else { return } + + let ovalMatchChallenge: FaceLivenessSession.OvalMatchChallenge + switch sessionConfiguration { + case .faceMovement(let challenge): + ovalMatchChallenge = challenge + case .faceMovementAndLight(_, let challenge): + ovalMatchChallenge = challenge + } + + let ovalParameters = ovalMatchChallenge.oval let scaleRatio = cameraViewRect.width / videoSize.width let rect = CGRect( x: ovalParameters.boundingBox.x, @@ -178,9 +211,21 @@ class FaceLivenessDetectionViewModel: ObservableObject { func initializeLivenessStream() { do { + if (abs(Self.attemptIdTimeStamp.timeIntervalSinceNow) > defaultAttemptCountResetInterval) { + Self.attemptCount = 1 + } else { + Self.attemptCount += 1 + } + Self.attemptIdTimeStamp = Date() + try livenessService?.initializeLivenessStream( withSessionID: sessionID, - userAgent: UserAgentValues.standard().userAgentString + userAgent: UserAgentValues.standard().userAgentString, + challenges: [challengeOptions.faceMovementChallengeOption.challenge, + challengeOptions.faceMovementAndLightChallengeOption.challenge], + options: .init( + attemptCount: Self.attemptCount, + preCheckViewEnabled: isPreviewScreenEnabled) ) } catch { DispatchQueue.main.async { @@ -226,6 +271,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { videoStartTime: UInt64 ) { guard initialClientEvent == nil else { return } + guard let challengeReceived else { return } + videoChunker.start() let initialFace = FaceDetection( @@ -243,7 +290,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { do { try livenessService?.send( - .initialFaceDetected(event: _initialClientEvent), + .initialFaceDetected(event: _initialClientEvent, + challenge: challengeReceived), eventDate: { .init() } ) } catch { @@ -254,14 +302,14 @@ class FaceLivenessDetectionViewModel: ObservableObject { } func sendFinalEvent( - targetFaceRect: CGRect, viewSize: CGSize, faceMatchedEnd: UInt64 ) { guard let sessionConfiguration, let initialClientEvent, - let faceMatchedTimestamp + let faceMatchedTimestamp, + let challengeReceived else { return } let finalClientEvent = FinalClientEvent( @@ -275,7 +323,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { do { try livenessService?.send( - .final(event: finalClientEvent), + .final(event: finalClientEvent, + challenge: challengeReceived), eventDate: { .init() } ) @@ -294,7 +343,6 @@ class FaceLivenessDetectionViewModel: ObservableObject { func sendFinalVideoEvent() { sendFinalEvent( - targetFaceRect: faceGuideRect, viewSize: videoSize, faceMatchedEnd: Date().timestampMilliseconds ) @@ -304,10 +352,15 @@ class FaceLivenessDetectionViewModel: ObservableObject { } } - func handleFreshnessComplete(faceGuide: CGRect) { + func handleFreshnessComplete() { DispatchQueue.main.async { self.livenessState.completedDisplayingFreshness() - self.faceGuideRect = faceGuide + } + } + + func completeNoLightCheck() { + DispatchQueue.main.async { + self.livenessState.completedNoLightCheck() } } @@ -361,4 +414,29 @@ class FaceLivenessDetectionViewModel: ObservableObject { } return data } + + func configureCaptureSession(challenge: Challenge) { + let cameraPosition: LivenessCamera + switch challenge { + case .faceMovementChallenge: + cameraPosition = challengeOptions.faceMovementChallengeOption.camera + case .faceMovementAndLightChallenge: + cameraPosition = challengeOptions.faceMovementAndLightChallengeOption.camera + } + + let avCaptureDevice = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: cameraPosition == .front ? .front : .back) + + self.captureSession = LivenessCaptureSession( + captureDevice: .init(avCaptureDevice: avCaptureDevice), + outputDelegate: OutputSampleBufferCapturer( + faceDetector: self.faceDetector, + videoChunker: self.videoChunker + ) + ) + } } + +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 c59629c9..62e563ef 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 @@ -159,7 +165,7 @@ struct LivenessStateMachine { static let couldNotOpenStream = LivenessError(code: 5, webSocketCloseCode: .unexpectedRuntimeError) static let socketClosed = LivenessError(code: 6, webSocketCloseCode: .normalClosure) static let viewResignation = LivenessError(code: 8, webSocketCloseCode: .viewClosure) - static let cameraNotAvailable = LivenessError(code: 9, webSocketCloseCode: .missingVideoPermission) + static let cameraNotAvailable = LivenessError(code: 9, webSocketCloseCode: .unexpectedRuntimeError) static func == (lhs: LivenessError, rhs: LivenessError) -> Bool { lhs.code == rhs.code diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift index c274bde0..35952c12 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift @@ -105,7 +105,6 @@ final class _LivenessViewController: UIViewController { var initialFace: FaceDetection? var videoStartTimeStamp: UInt64? var faceMatchStartTime: UInt64? - var faceGuideRect: CGRect? var freshnessEventsComplete = false var videoSentCount = 0 var hasSentFinalEvent = false @@ -146,9 +145,7 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { guard let self else { return } self.freshnessView.removeFromSuperview() - self.viewModel.handleFreshnessComplete( - faceGuide: self.faceGuideRect! - ) + self.viewModel.handleFreshnessComplete() } ) } @@ -156,7 +153,6 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { func drawOvalInCanvas(_ ovalRect: CGRect) { DispatchQueue.main.async { guard let previewLayer = self.previewLayer else { return } - self.faceGuideRect = ovalRect let ovalView = OvalView( frame: previewLayer.frame, @@ -173,4 +169,8 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { self.ovalExists = true } } + + func completeNoLightCheck() { + self.viewModel.completeNoLightCheck() + } } 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/CredentialsProviderTestCase.swift b/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift index 7d69251b..988fcc4f 100644 --- a/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift +++ b/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift @@ -27,21 +27,16 @@ final class CredentialsProviderTestCase: XCTestCase { assetWriterDelegate: VideoChunker.AssetWriterDelegate(), assetWriterInput: LivenessAVAssetWriterInput() ) - let captureSession = LivenessCaptureSession( - captureDevice: .init(avCaptureDevice: nil), - outputDelegate: OutputSampleBufferCapturer( - faceDetector: faceDetector, - videoChunker: videoChunker - ) - ) let viewModel = FaceLivenessDetectionViewModel( faceDetector: faceDetector, faceInOvalMatching: .init(instructor: .init()), - captureSession: captureSession, videoChunker: videoChunker, closeButtonAction: {}, - sessionID: UUID().uuidString + sessionID: UUID().uuidString, + isPreviewScreenEnabled: false, + challengeOptions: .init(faceMovementChallengeOption: .init(camera: .front), + faceMovementAndLightChallengeOption: .init()) ) self.videoChunker = videoChunker @@ -65,6 +60,8 @@ final class CredentialsProviderTestCase: XCTestCase { sessionID: UUID().uuidString, credentialsProvider: credentialsProvider, region: "us-east-1", + challengeOptions: .init(faceMovementChallengeOption: .init(camera: .front), + faceMovementAndLightChallengeOption: .init()), isPresented: .constant(true), onCompletion: { _ in } ) @@ -101,6 +98,8 @@ final class CredentialsProviderTestCase: XCTestCase { sessionID: UUID().uuidString, credentialsProvider: credentialsProvider, region: "us-east-1", + challengeOptions: .init(faceMovementChallengeOption: .init(camera: .front), + faceMovementAndLightChallengeOption: .init()), isPresented: .constant(true), onCompletion: { _ in } ) 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..9180841f 100644 --- a/Tests/FaceLivenessTests/LivenessTests.swift +++ b/Tests/FaceLivenessTests/LivenessTests.swift @@ -18,21 +18,16 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { assetWriterDelegate: VideoChunker.AssetWriterDelegate(), assetWriterInput: LivenessAVAssetWriterInput() ) - let captureSession = LivenessCaptureSession( - captureDevice: .init(avCaptureDevice: nil), - outputDelegate: OutputSampleBufferCapturer( - faceDetector: faceDetector, - videoChunker: videoChunker - ) - ) let viewModel = FaceLivenessDetectionViewModel( faceDetector: faceDetector, faceInOvalMatching: .init(instructor: .init()), - captureSession: captureSession, videoChunker: videoChunker, closeButtonAction: {}, - sessionID: UUID().uuidString + sessionID: UUID().uuidString, + isPreviewScreenEnabled: false, + challengeOptions: .init(faceMovementChallengeOption: .init(camera: .front), + faceMovementAndLightChallengeOption: .init()) ) self.videoChunker = videoChunker @@ -69,6 +64,7 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { /// Then: The end state of this flow is `.faceMatched` func testHappyPathToMatchedFace() async throws { viewModel.livenessService = self.livenessService + viewModel.challengeReceived = .faceMovementAndLightChallenge("2.0.0") viewModel.livenessState.checkIsFacePrepared() XCTAssertEqual(viewModel.livenessState.state, .pendingFacePreparedConfirmation(.pendingCheck)) @@ -104,15 +100,38 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" ]) XCTAssertEqual(livenessService.interactions, [ - "initializeLivenessStream(withSessionID:userAgent:)" + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)" ]) } /// 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.challengeReceived = .faceMovementChallenge("1.0.0") + + 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 = .faceMovement(.init(faceDetectionThreshold: 0.7, + face: face, + oval: oval)) viewModel.livenessState.checkIsFacePrepared() XCTAssertEqual(viewModel.livenessState.state, .pendingFacePreparedConfirmation(.pendingCheck)) @@ -136,9 +155,6 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { XCTAssertEqual(faceDetector.interactions, [ "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" ]) - XCTAssertEqual(livenessService.interactions, [ - "initializeLivenessStream(withSessionID:userAgent:)" - ]) } /// Given: A `FaceLivenessDetectionViewModel` @@ -174,4 +190,55 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { try await Task.sleep(seconds: 1) XCTAssertEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) } + + /// Given: A `FaceLivenessDetectionViewModel` + /// When: The initializeLivenessStream() is called for the first time and then called again after 3 seconds + /// Then: The attempt count is incremented + func testAttemptCountIncrementFirstTime() async throws { + viewModel.livenessService = self.livenessService + self.viewModel.initializeLivenessStream() + XCTAssertEqual(livenessService.interactions, [ + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)" + ]) + + XCTAssertEqual(FaceLivenessDetectionViewModel.attemptCount, 1) + try await Task.sleep(seconds: 3) + + self.viewModel.initializeLivenessStream() + XCTAssertEqual(livenessService.interactions, [ + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)", + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)" + ]) + XCTAssertEqual(FaceLivenessDetectionViewModel.attemptCount, 2) + } + + /// Given: A `FaceLivenessDetectionViewModel` + /// When: The attempt count is 4, last attempt time was < 5 minutes and initializeLivenessStream() is called + /// Then: The attempt count is incremented + func testAttemptCountIncrement() async throws { + viewModel.livenessService = self.livenessService + FaceLivenessDetectionViewModel.attemptCount = 4 + FaceLivenessDetectionViewModel.attemptIdTimeStamp = Date().addingTimeInterval(-180) + self.viewModel.initializeLivenessStream() + XCTAssertEqual(livenessService.interactions, [ + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)" + ]) + + XCTAssertEqual(FaceLivenessDetectionViewModel.attemptCount, 5) + } + + /// Given: A `FaceLivenessDetectionViewModel` + /// When: The attempt count is 4, last attempt time was > 5 minutes and initializeLivenessStream() is called + /// Then: The attempt count is not incremented and reset to 1 + func testAttemptCountReset() async throws { + viewModel.livenessService = self.livenessService + FaceLivenessDetectionViewModel.attemptCount = 4 + FaceLivenessDetectionViewModel.attemptIdTimeStamp = Date().addingTimeInterval(-305) + self.viewModel.initializeLivenessStream() + XCTAssertEqual(livenessService.interactions, [ + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)" + ]) + + XCTAssertEqual(FaceLivenessDetectionViewModel.attemptCount, 1) + } } diff --git a/Tests/FaceLivenessTests/MockLivenessService.swift b/Tests/FaceLivenessTests/MockLivenessService.swift index 2b4633d1..d3e43a8d 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]?,FaceLivenessSession.Options) -> Void = { _, _, _, _ in } var onServiceException: (FaceLivenessSessionError) -> Void = { _ in } var onCloseSocket: (URLSessionWebSocketTask.CloseCode) -> Void = { _ in } } @@ -44,10 +44,13 @@ extension MockLivenessService: LivenessService { } func initializeLivenessStream( - withSessionID sessionID: String, userAgent: String + withSessionID sessionID: String, + userAgent: String, + challenges: [Challenge], + options: FaceLivenessSession.Options ) throws { interactions.append(#function) - onInitializeLivenessStream(sessionID, userAgent) + onInitializeLivenessStream(sessionID, userAgent, challenges, options) } func register( @@ -62,6 +65,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)