From eb0dd55ab59197d53e2c99a18b7f015ff7b38b7d Mon Sep 17 00:00:00 2001 From: Tuan Pham <103537251+phantumcode@users.noreply.github.com> Date: Tue, 11 Jul 2023 10:49:08 -0500 Subject: [PATCH 1/5] chore: remove countdown from liveness session check (#36) * chore: remove countdown from liveness session check * update state logic * remove unused constant * add back missing instructional text --- .../LocalizedStringKey+Liveness.swift | 5 -- .../CountdownInstructionContainerView.swift | 52 -------------- .../Countdown/CountdownView+ViewModel.swift | 67 ------------------- .../Views/Countdown/CountdownView.swift | 64 ------------------ .../InstructionContainerView.swift | 20 ++++++ ...ViewModel+FaceDetectionResultHandler.swift | 20 ++---- .../Views/Liveness/LivenessStateMachine.swift | 34 ++-------- .../Liveness/_FaceLivenessDetectionView.swift | 8 --- Tests/FaceLivenessTests/LivenessTests.swift | 31 +++++++++ 9 files changed, 64 insertions(+), 237 deletions(-) delete mode 100644 Sources/FaceLiveness/Views/Countdown/CountdownInstructionContainerView.swift delete mode 100644 Sources/FaceLiveness/Views/Countdown/CountdownView+ViewModel.swift delete mode 100644 Sources/FaceLiveness/Views/Countdown/CountdownView.swift diff --git a/Sources/FaceLiveness/Utilities/LocalizedStringKey+Liveness.swift b/Sources/FaceLiveness/Utilities/LocalizedStringKey+Liveness.swift index f6cec1d9..2b864bb0 100644 --- a/Sources/FaceLiveness/Utilities/LocalizedStringKey+Liveness.swift +++ b/Sources/FaceLiveness/Utilities/LocalizedStringKey+Liveness.swift @@ -83,11 +83,6 @@ extension LocalizedStringKey { "amplify_ui_liveness_challenge_recording_indicator_label" ) - /// en = "Hold face position during countdown." - static let challenge_instruction_hold_face_during_countdown = LocalizedStringKey( - "amplify_ui_liveness_challenge_instruction_hold_face_during_countdown" - ) - /// en = "Hold face in oval for colored lights." static let challenge_instruction_hold_face_during_freshness = LocalizedStringKey( "amplify_ui_liveness_challenge_instruction_hold_face_during_freshness" diff --git a/Sources/FaceLiveness/Views/Countdown/CountdownInstructionContainerView.swift b/Sources/FaceLiveness/Views/Countdown/CountdownInstructionContainerView.swift deleted file mode 100644 index 8e639ca5..00000000 --- a/Sources/FaceLiveness/Views/Countdown/CountdownInstructionContainerView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import SwiftUI - -struct CountdownInstructionContainerView: View { - var viewModel: FaceLivenessDetectionViewModel - let onCountdownComplete: () -> Void - @State var duration: Double = 3 - - var body: some View { - switch viewModel.livenessState.state { - case .pendingFacePreparedConfirmation(let reason): - InstructionView( - text: .init(reason.rawValue), - backgroundColor: .livenessBackground - ) - case .countingDown: - InstructionView( - text: .challenge_instruction_hold_face_during_countdown, - backgroundColor: .livenessBackground - ) - - CountdownView( - duration: duration, - onComplete: onCountdownComplete - ) - .padding(.top) - case .completedDisplayingFreshness: - InstructionView( - text: .challenge_verifying, - backgroundColor: .livenessBackground - ) - .onAppear { - UIAccessibility.post( - notification: .announcement, - argument: NSLocalizedString( - "amplify_ui_liveness_challenge_verifying", - bundle: .module, - comment: "" - ) - ) - } - default: - EmptyView() - } - } -} diff --git a/Sources/FaceLiveness/Views/Countdown/CountdownView+ViewModel.swift b/Sources/FaceLiveness/Views/Countdown/CountdownView+ViewModel.swift deleted file mode 100644 index 333baba6..00000000 --- a/Sources/FaceLiveness/Views/Countdown/CountdownView+ViewModel.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import SwiftUI -import Combine - -extension CountdownView { - class ViewModel: ObservableObject { - let initialDuration: Double - let rate: Double - let timer: Timer.TimerPublisher - let tickRate: Double - let timerCancellable: AnyCancellable? - let onComplete: () -> Void - - @Published var timerAccessibilityValue: String - @Published var remaining: Double - @Published var percentage: Double = 1.0 - - init( - duration: Double, - tickRate: Double = 0.2, - onComplete: @escaping () -> Void - ) { - self.initialDuration = duration - self.remaining = duration - self.rate = 100 / (initialDuration / tickRate) / 100 - self.tickRate = tickRate - self.onComplete = onComplete - self.timer = .init( - interval: tickRate, - runLoop: .main, - mode: .default - ) - self.timerCancellable = timer.connect() as? AnyCancellable - self.timerAccessibilityValue = String(Int(duration)) - } - - deinit { timerCancellable?.cancel() } - - private func setAccessibilityValue() { - guard remaining >= 1 else { return } - if remaining != initialDuration - && Int(floor(remaining)) != Int(floor(remaining - tickRate)) { - timerAccessibilityValue = String(Int(remaining)) - } - } - - func timerInvoked() { - setAccessibilityValue() - remaining -= tickRate - percentage -= rate - if remaining <= 0 { - timer.connect().cancel() - onComplete() - } - } - - func formatted(remaining: Double) -> String { - String(Int(ceil(remaining))) - } - } -} diff --git a/Sources/FaceLiveness/Views/Countdown/CountdownView.swift b/Sources/FaceLiveness/Views/Countdown/CountdownView.swift deleted file mode 100644 index 8bf6f958..00000000 --- a/Sources/FaceLiveness/Views/Countdown/CountdownView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import SwiftUI -import Combine - -struct CountdownView: View { - @ObservedObject var viewModel: CountdownView.ViewModel - - init( - duration: Double, - tickRate: Double = 0.02, - onComplete: @escaping () -> Void - ) { - viewModel = .init( - duration: duration, - tickRate: tickRate, - onComplete: onComplete - ) - } - - var body: some View { - ZStack { - Circle() - .foregroundColor(.livenessBackground) - .frame(width: 68, height: 68) - - Circle() - .trim(from: 0, to: min(viewModel.percentage, 1.0)) - .stroke(Color.livenessPrimaryBackground, lineWidth: 4) - .padding(6) - .frame(width: 68, height: 68) - .rotationEffect(Angle.init(degrees: 270)) - .animation(.linear, value: viewModel.percentage) - - Text(viewModel.formatted(remaining: viewModel.remaining)) - .accessibilityHidden(true) - .font(.system(size: 24, weight: .semibold)) - } - .onReceive(viewModel.timer) { _ in - viewModel.timerInvoked() - } - .onReceive(viewModel.$timerAccessibilityValue) { value in - UIAccessibility.post(notification: .announcement, argument: value) - } - } -} - -struct CountdownView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.gray - CountdownView( - duration: 3, - tickRate: 0.05, - onComplete: { print("donesies!") } - ) - } - } -} diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift index 50620d17..028eb386 100644 --- a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift +++ b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift @@ -91,6 +91,26 @@ struct InstructionContainerView: View { percentage: 0.2 ) .frame(width: 200, height: 30) + case .pendingFacePreparedConfirmation(let reason): + InstructionView( + text: .init(reason.rawValue), + backgroundColor: .livenessBackground + ) + case .completedDisplayingFreshness: + InstructionView( + text: .challenge_verifying, + backgroundColor: .livenessBackground + ) + .onAppear { + UIAccessibility.post( + notification: .announcement, + argument: NSLocalizedString( + "amplify_ui_liveness_challenge_verifying", + bundle: .module, + comment: "" + ) + ) + } default: EmptyView() } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift index 04b193de..cdeec32a 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift @@ -10,7 +10,6 @@ import SwiftUI @_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin fileprivate let initialFaceDistanceThreshold: CGFloat = 0.32 -fileprivate let countdownFaceDistanceThreshold: CGFloat = 0.37 extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { func process(newResult: FaceDetectionResult) { @@ -34,10 +33,13 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { switch livenessState.state { case .pendingFacePreparedConfirmation: if face.faceDistance <= initialFaceDistanceThreshold { - DispatchQueue.main.async { - self.livenessState.startCountdown() - self.initializeLivenessStream() - } + DispatchQueue.main.async { + self.livenessState.awaitingRecording() + self.initializeLivenessStream() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.livenessState.beginRecording() + } return } else { DispatchQueue.main.async { @@ -45,14 +47,6 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { } return } - case .countingDown: - if face.faceDistance >= countdownFaceDistanceThreshold { - DispatchQueue.main.async { - self.livenessState.unrecoverableStateEncountered( - .invalidFaceMovementDuringCountdown - ) - } - } case .recording(ovalDisplayed: false): drawOval() sendInitialFaceDetectedEvent( diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift b/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift index af9dc8d8..df68a67a 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift @@ -25,15 +25,6 @@ struct LivenessStateMachine { state = .pendingFacePreparedConfirmation(reason) } - mutating func openSocket() { - switch state { - case .pendingFacePreparedConfirmation, .countingDown: - state = .socketOpened - default: - break - } - } - mutating func awaitingFaceMatch(with instruction: Instructor.Instruction, nearnessPercentage: Double) { let reason: FaceNotPreparedReason let percentage: Double @@ -56,16 +47,11 @@ struct LivenessStateMachine { state = .awaitingFaceInOvalMatch(reason, percentage) } - mutating func awaitingServerInfoEvent() { - guard case .socketOpened = state else { return } - state = .awaitingServerInfoEvent - } - - mutating func receivedServerInfoEvent() throws { - guard case .awaitingServerInfoEvent = state else { return } - state = .serverInfoEventReceived + mutating func awaitingRecording() { + guard case .pendingFacePreparedConfirmation = state else { return } + state = .waitForRecording } - + mutating func unrecoverableStateEncountered(_ error: LivenessError) { switch state { case .encounteredUnrecoverableError, .completed: @@ -83,11 +69,6 @@ struct LivenessStateMachine { state = .recording(ovalDisplayed: true) } - mutating func startCountdown() { - guard case .pendingFacePreparedConfirmation = state else { return } - state = .countingDown - } - mutating func faceMatched() { state = .faceMatched } @@ -106,7 +87,7 @@ struct LivenessStateMachine { var shouldDisplayRecordingIcon: Bool { switch state { - case .initial, .pendingFacePreparedConfirmation, .encounteredUnrecoverableError, .countingDown: + case .initial, .pendingFacePreparedConfirmation, .encounteredUnrecoverableError: return false default: return true } @@ -115,10 +96,6 @@ struct LivenessStateMachine { enum State: Equatable { case initial case pendingFacePreparedConfirmation(FaceNotPreparedReason) - case socketOpened - case awaitingServerInfoEvent - case serverInfoEventReceived - case countingDown case recording(ovalDisplayed: Bool) case awaitingFaceInOvalMatch(FaceNotPreparedReason, Double) case faceMatched @@ -129,6 +106,7 @@ struct LivenessStateMachine { case awaitingDisconnectEvent case disconnectEventReceived case encounteredUnrecoverableError(LivenessError) + case waitForRecording } enum FaceNotPreparedReason: String, Equatable { diff --git a/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift index da6fea2f..5113bf54 100644 --- a/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift @@ -50,14 +50,6 @@ struct _FaceLivenessDetectionView: View { ) Spacer() - - CountdownInstructionContainerView( - viewModel: viewModel, - onCountdownComplete: { - viewModel.livenessState.beginRecording() - } - ) - .padding(.bottom) } .padding([.leading, .trailing]) .aspectRatio(3/4, contentMode: .fit) diff --git a/Tests/FaceLivenessTests/LivenessTests.swift b/Tests/FaceLivenessTests/LivenessTests.swift index c04bf9dc..a764aad4 100644 --- a/Tests/FaceLivenessTests/LivenessTests.swift +++ b/Tests/FaceLivenessTests/LivenessTests.swift @@ -100,4 +100,35 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { "initializeLivenessStream(withSessionID:userAgent:)" ]) } + + /// 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 + func testTransitionToRecordingState() async throws { + viewModel.livenessService = self.livenessService + + viewModel.livenessState.checkIsFacePrepared() + XCTAssertEqual(viewModel.livenessState.state, .pendingFacePreparedConfirmation(.pendingCheck)) + XCTAssertEqual(faceDetector.interactions, [ + "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" + ]) + XCTAssertEqual(livenessService.interactions, []) + + let boundingBox = CGRect(x: 0.26788579725878847, y: 0.40317180752754211, width: 0.45549795395626447, height: 0.34162446856498718) + let leftEye = CGPoint(x: 0.61124476128552629, y: 0.4918237030506134) + let rightEye = CGPoint(x: 0.38036393762719456, y: 0.48050540685653687) + let nose = CGPoint(x: 0.48489856674964926, y: 0.54713362455368042) + let mouth = CGPoint(x: 0.47411978167652435, y: 0.63170802593231201) + let detectedFace = DetectedFace(boundingBox: boundingBox, leftEye: leftEye, rightEye: rightEye, nose: nose, mouth: mouth, confidence: 0.971859633) + viewModel.process(newResult: .singleFace(detectedFace)) + try await Task.sleep(seconds: 1) + + XCTAssertEqual(viewModel.livenessState.state, .recording(ovalDisplayed: false)) + XCTAssertEqual(faceDetector.interactions, [ + "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" + ]) + XCTAssertEqual(livenessService.interactions, [ + "initializeLivenessStream(withSessionID:userAgent:)" + ]) + } } From 18c23bf34aa0d93309986e8665198d4919db249a Mon Sep 17 00:00:00 2001 From: Ian Saultz <52051793+atierian@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:42:27 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20data=20race=20causing=20multiple=20i?= =?UTF-8?q?nitial=20events=20and=20crash=20with=20low=20net=E2=80=A6=20(#4?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: data race causing multiple initial events and crash with low network throughput * invoke oncomplete --- Sources/FaceLiveness/AV/VideoChunker.swift | 1 + ...etectionViewModel+FaceDetectionResultHandler.swift | 11 ++++++----- .../Liveness/FaceLivenessDetectionViewModel.swift | 5 +++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/FaceLiveness/AV/VideoChunker.swift b/Sources/FaceLiveness/AV/VideoChunker.swift index 31b8adc0..326e2bc1 100644 --- a/Sources/FaceLiveness/AV/VideoChunker.swift +++ b/Sources/FaceLiveness/AV/VideoChunker.swift @@ -33,6 +33,7 @@ final class VideoChunker { } func start() { + guard state == .pending else { return } state = .writing assetWriter.startWriting() assetWriter.startSession(atSourceTime: .zero) diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift index cdeec32a..fb6fee00 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift @@ -48,11 +48,12 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { return } case .recording(ovalDisplayed: false): - drawOval() - sendInitialFaceDetectedEvent( - initialFace: normalizedFace.boundingBox, - videoStartTime: Date().timestampMilliseconds - ) + drawOval(onComplete: { + self.sendInitialFaceDetectedEvent( + initialFace: normalizedFace.boundingBox, + videoStartTime: Date().timestampMilliseconds + ) + }) case .recording(ovalDisplayed: true): guard let sessionConfiguration = sessionConfiguration else { return } let instruction = faceInOvalMatching.faceMatchState( diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift index e21d261c..5b8380fa 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift @@ -134,8 +134,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { } } - - func drawOval() { + func drawOval(onComplete: @escaping () -> Void) { guard livenessState.state == .recording(ovalDisplayed: false), let ovalParameters = sessionConfiguration?.ovalMatchChallenge.oval else { return } @@ -158,6 +157,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { livenessViewControllerDelegate?.drawOvalInCanvas(normalizedOvalRect) DispatchQueue.main.async { self.livenessState.ovalDisplayed() + onComplete() } ovalRect = normalizedOvalRect } @@ -212,6 +212,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { initialFace: CGRect, videoStartTime: UInt64 ) { + guard initialClientEvent == nil else { return } videoChunker.start() let initialFace = FaceDetection( From 0c0873aa4bd2f2c7722557ab60b9de00a8ee419c Mon Sep 17 00:00:00 2001 From: Ian Saultz <52051793+atierian@users.noreply.github.com> Date: Thu, 13 Jul 2023 17:03:30 -0400 Subject: [PATCH 3/5] fix: send client close frame (#39) * fix: send client close frame * tmp: update amplify swift dependency to fix branch * dont send close frame on successful completion * add new close method in test mock * update amplify swift dep version --- Package.resolved | 8 ++++---- Package.swift | 2 +- .../Views/Liveness/FaceLivenessDetectionView.swift | 1 + Tests/FaceLivenessTests/MockLivenessService.swift | 6 ++++++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Package.resolved b/Package.resolved index 8874564c..595e3b9b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/aws-amplify/amplify-swift", "state" : { - "revision" : "4241439bae1662134f0a73bd87c84f7384ffabb6", - "version" : "2.11.0" + "revision" : "bb69fc1febc23dfc539531ce7dd3b51cdf97d813", + "version" : "2.14.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/aws-amplify/amplify-swift-utils-notifications.git", "state" : { - "revision" : "d4fd3c17e8d40efc821f448d3d6cff75b8f3b0dd", - "version" : "1.0.0" + "revision" : "f970384ad1035732f99259255cd2f97564807e41", + "version" : "1.1.0" } }, { diff --git a/Package.swift b/Package.swift index 5475087d..dd738e4d 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", from: "2.11.0") + .package(url: "https://github.com/aws-amplify/amplify-swift", from: "2.14.1") ], targets: [ .target( diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index 415a59e5..a5ad18d3 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift @@ -170,6 +170,7 @@ public struct FaceLivenessDetectorView: View { isPresented = false onCompletion(.success(())) case .encounteredUnrecoverableError(let error): + viewModel.livenessService.closeSocket(with: .normalClosure) isPresented = false onCompletion(.failure(mapError(error))) default: diff --git a/Tests/FaceLivenessTests/MockLivenessService.swift b/Tests/FaceLivenessTests/MockLivenessService.swift index 9753a477..2b4633d1 100644 --- a/Tests/FaceLivenessTests/MockLivenessService.swift +++ b/Tests/FaceLivenessTests/MockLivenessService.swift @@ -20,6 +20,7 @@ class MockLivenessService { var onVideoEvent: (LivenessEvent, Date) -> Void = { _, _ in } var onInitializeLivenessStream: (String, String) -> Void = { _, _ in } var onServiceException: (FaceLivenessSessionError) -> Void = { _ in } + var onCloseSocket: (URLSessionWebSocketTask.CloseCode) -> Void = { _ in } } extension MockLivenessService: LivenessService { @@ -61,4 +62,9 @@ extension MockLivenessService: LivenessService { ) { interactions.append(#function) } + + func closeSocket(with code: URLSessionWebSocketTask.CloseCode) { + interactions.append(#function) + onCloseSocket(code) + } } From 421480f37650ecb811ce2d3b15d380225c62147e Mon Sep 17 00:00:00 2001 From: Tuan Pham <103537251+phantumcode@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:09:24 -0500 Subject: [PATCH 4/5] fix: update no match timeout check from frame count to elapsed time (#41) * fix: update no match timeout check from frame count to elapsed time * chore: fix spacing * fix: resolve build errors in latest fix * add unit test * fix failed unit test --- ...ViewModel+FaceDetectionResultHandler.swift | 9 ++++--- .../FaceLivenessDetectionViewModel.swift | 4 +-- Tests/FaceLivenessTests/LivenessTests.swift | 25 ++++++++++++++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift index fb6fee00..8acc47d0 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift @@ -84,9 +84,12 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { } func handleNoMatch(instruction: Instructor.Instruction, percentage: Double) { + let noMatchTimeoutInterval: TimeInterval = 7 self.livenessState.awaitingFaceMatch(with: instruction, nearnessPercentage: percentage) - noMatchCount += 1 - if noMatchCount >= 210 { + if noMatchStartTime == nil { + noMatchStartTime = Date() + } + if let elapsedTime = noMatchStartTime?.timeIntervalSinceNow, abs(elapsedTime) >= noMatchTimeoutInterval { self.livenessState .unrecoverableStateEncountered(.timedOut) self.captureSession.stopRunning() @@ -106,7 +109,7 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { self.livenessViewControllerDelegate?.displayFreshness(colorSequences: colorSequences) let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(.success) - self.noMatchCount = 0 + self.noMatchStartTime = nil case .tooClose(_, let percentage), .tooFar(_, let percentage), diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift index 5b8380fa..71434c99 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift @@ -39,8 +39,8 @@ class FaceLivenessDetectionViewModel: ObservableObject { var faceGuideRect: CGRect! var initialClientEvent: InitialClientEvent? var faceMatchedTimestamp: UInt64? - var noMatchCount = 0 - + var noMatchStartTime: Date? + init( faceDetector: FaceDetector, faceInOvalMatching: FaceInOvalMatching, diff --git a/Tests/FaceLivenessTests/LivenessTests.swift b/Tests/FaceLivenessTests/LivenessTests.swift index a764aad4..e3620968 100644 --- a/Tests/FaceLivenessTests/LivenessTests.swift +++ b/Tests/FaceLivenessTests/LivenessTests.swift @@ -38,6 +38,13 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { self.videoChunker = videoChunker self.viewModel = viewModel } + + override func tearDown() { + self.faceDetector = nil + self.livenessService = nil + self.videoChunker = nil + self.viewModel = nil + } /// Given: A `FaceLivenessDetectionViewModel` /// When: The viewModel is first initialized @@ -113,7 +120,7 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { "setResultHandler(detectionResultHandler:) (FaceLivenessDetectionViewModel)" ]) XCTAssertEqual(livenessService.interactions, []) - + let boundingBox = CGRect(x: 0.26788579725878847, y: 0.40317180752754211, width: 0.45549795395626447, height: 0.34162446856498718) let leftEye = CGPoint(x: 0.61124476128552629, y: 0.4918237030506134) let rightEye = CGPoint(x: 0.38036393762719456, y: 0.48050540685653687) @@ -131,4 +138,20 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { "initializeLivenessStream(withSessionID:userAgent:)" ]) } + + /// Given: A `FaceLivenessDetectionViewModel` + /// When: The viewModel handles a no match event over a duration of 7 seconds + /// Then: The end state is `.encounteredUnrecoverableError(.timedOut)` + func testNoMatchTimeoutCheck() async throws { + viewModel.livenessService = self.livenessService + self.viewModel.handleNoMatch(instruction: .tooFar(nearnessPercentage: 0.2), percentage: 0.2) + + XCTAssertNotEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) + try await Task.sleep(seconds: 6) + self.viewModel.handleNoMatch(instruction: .tooFar(nearnessPercentage: 0.2), percentage: 0.2) + XCTAssertNotEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) + try await Task.sleep(seconds: 1) + self.viewModel.handleNoMatch(instruction: .tooFar(nearnessPercentage: 0.2), percentage: 0.2) + XCTAssertEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) + } } From adf29ad26323126a8fb0e031555174be2cb70eb6 Mon Sep 17 00:00:00 2001 From: Tuan Pham <103537251+phantumcode@users.noreply.github.com> Date: Thu, 20 Jul 2023 12:09:43 -0500 Subject: [PATCH 5/5] chore: update user agent string with correct library version (#42) --- Sources/FaceLiveness/Utilities/UserAgent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/FaceLiveness/Utilities/UserAgent.swift b/Sources/FaceLiveness/Utilities/UserAgent.swift index 2a33caeb..5858108c 100644 --- a/Sources/FaceLiveness/Utilities/UserAgent.swift +++ b/Sources/FaceLiveness/Utilities/UserAgent.swift @@ -57,7 +57,7 @@ struct UserAgentValues { swiftVersion: Swift().version(), unameMachine: Device.current.machine.replacingOccurrences(of: ",", with: "_"), locale: Locale.current.identifier, - lib: "lib/amplify-ui-swift-face-liveness/1.0.1", + lib: "lib/amplify-ui-swift-face-liveness/1.1.0", additionalMetadata: additionalMetadata ) }