From e8643b766a386a05f9f0131eeb5f43225ee373ee Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Mon, 6 May 2024 10:14:55 -0700 Subject: [PATCH] chore: Add attempt count changes (#137) * chore: Add attempt count changes * Fix unit tests * add unit tests * Update region for example liveness view * Update amplify-swift dependency --- .../xcshareddata/swiftpm/Package.resolved | 2 +- HostApp/HostApp/Model/LivenessResult.swift | 4 ++ .../HostApp/Views/ExampleLivenessView.swift | 3 +- .../LivenessResultContentView+Result.swift | 4 ++ .../Views/LivenessResultContentView.swift | 64 +++++++++++++------ Package.resolved | 2 +- .../Views/GetReadyPage/GetReadyPageView.swift | 35 ++++------ .../Liveness/FaceLivenessDetectionView.swift | 17 ++--- ...ViewModel+FaceDetectionResultHandler.swift | 4 +- .../FaceLivenessDetectionViewModel.swift | 24 +++++-- .../CredentialsProviderTestCase.swift | 3 +- Tests/FaceLivenessTests/LivenessTests.swift | 58 ++++++++++++++++- .../MockLivenessService.swift | 7 +- 13 files changed, 155 insertions(+), 72 deletions(-) diff --git a/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cddb5116..51a33701 100644 --- a/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HostApp/HostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/aws-amplify/amplify-swift", "state" : { "branch" : "feat/no-light-support", - "revision" : "7c1fa2f7a766208f5af69ca8dce5fd02e6de4db6" + "revision" : "22e02fa21399122aac1d8b4f6ab23c242c79dae6" } }, { diff --git a/HostApp/HostApp/Model/LivenessResult.swift b/HostApp/HostApp/Model/LivenessResult.swift index 226bc30f..3a36f089 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: Challenge? } extension LivenessResult: CustomDebugStringConvertible { @@ -20,6 +22,8 @@ extension LivenessResult: CustomDebugStringConvertible { - confidenceScore: \(confidenceScore) - isLive: \(isLive) - auditImageBytes: \(auditImageBytes == nil ? "nil" : "") + - challengeType: \(String(describing: challenge?.type)) + - challengeVersion: \(String(describing: challenge?.version)) """ } } diff --git a/HostApp/HostApp/Views/ExampleLivenessView.swift b/HostApp/HostApp/Views/ExampleLivenessView.swift index 85eaf8cc..39407c89 100644 --- a/HostApp/HostApp/Views/ExampleLivenessView.swift +++ b/HostApp/HostApp/Views/ExampleLivenessView.swift @@ -22,8 +22,7 @@ struct ExampleLivenessView: View { case .liveness: FaceLivenessDetectorView( sessionID: viewModel.sessionID, - // TODO: Change before merging to main - region: "us-west-2", + region: "us-east-1", isPresented: Binding( get: { viewModel.presentationState == .liveness }, set: { _ in } diff --git a/HostApp/HostApp/Views/LivenessResultContentView+Result.swift b/HostApp/HostApp/Views/LivenessResultContentView+Result.swift index 3f57982f..0b18eaab 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: Challenge? 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/Package.resolved b/Package.resolved index a046c257..a61605c0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/aws-amplify/amplify-swift", "state" : { "branch" : "feat/no-light-support", - "revision" : "7c1fa2f7a766208f5af69ca8dce5fd02e6de4db6" + "revision" : "22e02fa21399122aac1d8b4f6ab23c242c79dae6" } }, { diff --git a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift index dadb2076..0c52ccff 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift @@ -27,28 +27,19 @@ struct GetReadyPageView: View { VStack { ZStack { CameraPreviewView() - 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() - } + VStack { + WarningBox( + titleText: LocalizedStrings.get_ready_photosensitivity_title, + bodyText: LocalizedStrings.get_ready_photosensitivity_description, + popoverContent: { photosensitivityWarningPopoverContent } + ) + .accessibilityElement(children: .combine) + .opacity(challenge.type == .faceMovementAndLightChallenge ? 1.0 : 0.0) + Text(LocalizedStrings.preview_center_your_face_text) + .font(.title) + .multilineTextAlignment(.center) + Spacer() + }.padding() } beginCheckButton } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index eff46ec7..f31d2390 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift @@ -20,7 +20,6 @@ public struct FaceLivenessDetectorView: View { @State var displayingCameraPermissionsNeededAlert = false let disableStartView: Bool - let facelivenessDetectorViewId: String let onCompletion: (Result) -> Void let sessionTask: Task @@ -32,9 +31,7 @@ public struct FaceLivenessDetectorView: View { disableStartView: Bool = false, isPresented: Binding, onCompletion: @escaping (Result) -> Void - ) { - let viewId = UUID().uuidString - self.facelivenessDetectorViewId = viewId + ) { self.disableStartView = disableStartView self._isPresented = isPresented self.onCompletion = onCompletion @@ -44,8 +41,6 @@ public struct FaceLivenessDetectorView: View { withID: sessionID, credentialsProvider: credentialsProvider, region: region, - options: .init(faceLivenessDetectorViewId: viewId, - preCheckViewEnabled: !disableStartView), completion: map(detectionCompletion: onCompletion) ) return session @@ -83,7 +78,8 @@ public struct FaceLivenessDetectorView: View { captureSession: captureSession, videoChunker: videoChunker, closeButtonAction: { onCompletion(.failure(.userCancelled)) }, - sessionID: sessionID + sessionID: sessionID, + isPreviewScreenEnabled: !disableStartView ) ) @@ -99,8 +95,6 @@ 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 @@ -110,8 +104,6 @@ public struct FaceLivenessDetectorView: View { withID: sessionID, credentialsProvider: credentialsProvider, region: region, - options: .init(faceLivenessDetectorViewId: viewId, - preCheckViewEnabled: !disableStartView), completion: map(detectionCompletion: onCompletion) ) return session @@ -128,7 +120,8 @@ public struct FaceLivenessDetectorView: View { captureSession: captureSession, videoChunker: captureSession.outputSampleBufferCapturer!.videoChunker, closeButtonAction: { onCompletion(.failure(.userCancelled)) }, - sessionID: sessionID + sessionID: sessionID, + isPreviewScreenEnabled: !disableStartView ) ) } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift index edb96e80..0e43de2a 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift @@ -121,9 +121,7 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { } } case .faceMovementChallenge: - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.livenessViewControllerDelegate?.completeNoLightCheck() - } + self.livenessViewControllerDelegate?.completeNoLightCheck() default: break } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift index 352b1855..e83c7c22 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift @@ -12,6 +12,7 @@ import AVFoundation fileprivate let videoSize: CGSize = .init(width: 480, height: 640) fileprivate let defaultNoFitTimeoutInterval: TimeInterval = 7 +fileprivate let defaultAttemptCountResetInterval: TimeInterval = 300.0 @MainActor class FaceLivenessDetectionViewModel: ObservableObject { @@ -28,6 +29,7 @@ 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 @@ -43,6 +45,9 @@ class FaceLivenessDetectionViewModel: ObservableObject { var faceMatchedTimestamp: UInt64? var noFitStartTime: Date? + 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) @@ -58,7 +63,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { videoChunker: VideoChunker, stateMachine: LivenessStateMachine = .init(state: .initial), closeButtonAction: @escaping () -> Void, - sessionID: String + sessionID: String, + isPreviewScreenEnabled: Bool ) { self.closeButtonAction = closeButtonAction self.videoChunker = videoChunker @@ -67,6 +73,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { self.captureSession = captureSession self.faceDetector = faceDetector self.faceInOvalMatching = faceInOvalMatching + self.isPreviewScreenEnabled = isPreviewScreenEnabled self.closeButtonAction = { [weak self] in guard let self else { return } @@ -186,13 +193,20 @@ class FaceLivenessDetectionViewModel: ObservableObject { func initializeLivenessStream() { do { - guard let livenessSession = livenessService as? FaceLivenessSession else { - throw FaceLivenessDetectionError.unknown + if (abs(Self.attemptIdTimeStamp.timeIntervalSinceNow) > defaultAttemptCountResetInterval) { + Self.attemptCount = 1 + } else { + Self.attemptCount += 1 } + Self.attemptIdTimeStamp = Date() - try livenessSession.initializeLivenessStream( + try livenessService?.initializeLivenessStream( withSessionID: sessionID, - userAgent: UserAgentValues.standard().userAgentString + userAgent: UserAgentValues.standard().userAgentString, + challenges: FaceLivenessSession.supportedChallenges, + options: .init( + attemptCount: Self.attemptCount, + preCheckViewEnabled: isPreviewScreenEnabled) ) } catch { DispatchQueue.main.async { diff --git a/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift b/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift index 7d69251b..3c1dabbf 100644 --- a/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift +++ b/Tests/FaceLivenessTests/CredentialsProviderTestCase.swift @@ -41,7 +41,8 @@ final class CredentialsProviderTestCase: XCTestCase { captureSession: captureSession, videoChunker: videoChunker, closeButtonAction: {}, - sessionID: UUID().uuidString + sessionID: UUID().uuidString, + isPreviewScreenEnabled: false ) self.videoChunker = videoChunker diff --git a/Tests/FaceLivenessTests/LivenessTests.swift b/Tests/FaceLivenessTests/LivenessTests.swift index c4b95d02..5603914a 100644 --- a/Tests/FaceLivenessTests/LivenessTests.swift +++ b/Tests/FaceLivenessTests/LivenessTests.swift @@ -32,7 +32,8 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { captureSession: captureSession, videoChunker: videoChunker, closeButtonAction: {}, - sessionID: UUID().uuidString + sessionID: UUID().uuidString, + isPreviewScreenEnabled: false ) self.videoChunker = videoChunker @@ -104,7 +105,9 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { XCTAssertEqual(faceDetector.interactions, [ "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" ]) - XCTAssertEqual(livenessService.interactions, []) + XCTAssertEqual(livenessService.interactions, [ + "initializeLivenessStream(withSessionID:userAgent:challenges:options:)" + ]) } /// Given: A `FaceLivenessDetectionViewModel` @@ -193,4 +196,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 942f7488..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,[Challenge]?) -> Void = { _, _, _ in } + var onInitializeLivenessStream: (String, String,[Challenge]?,FaceLivenessSession.Options) -> Void = { _, _, _, _ in } var onServiceException: (FaceLivenessSessionError) -> Void = { _ in } var onCloseSocket: (URLSessionWebSocketTask.CloseCode) -> Void = { _ in } } @@ -46,10 +46,11 @@ extension MockLivenessService: LivenessService { func initializeLivenessStream( withSessionID sessionID: String, userAgent: String, - challenges: [Challenge] + challenges: [Challenge], + options: FaceLivenessSession.Options ) throws { interactions.append(#function) - onInitializeLivenessStream(sessionID, userAgent, challenges) + onInitializeLivenessStream(sessionID, userAgent, challenges, options) } func register(