From ce74064e3fd656877ad18abd52354e72b0924e98 Mon Sep 17 00:00:00 2001 From: Tuan Pham <103537251+phantumcode@users.noreply.github.com> Date: Thu, 10 Aug 2023 12:56:28 -0500 Subject: [PATCH] chore: provide more fine-grained websocket close code on errors (#44) * chore: improve websocket close code * chore: update web socket close code * chore: update unit test * chore: move websocket close code to liveness error * chore: code cleanup --- .../Liveness/FaceLivenessDetectionView.swift | 11 ++----- ...ViewModel+FaceDetectionResultHandler.swift | 29 +++++++++++++------ .../FaceLivenessDetectionViewModel.swift | 4 +-- .../Views/Liveness/LivenessStateMachine.swift | 28 ++++++++++++------ Tests/FaceLivenessTests/LivenessTests.swift | 26 +++++++++++++---- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index a5ad18d3..23c03ae0 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift @@ -170,7 +170,8 @@ public struct FaceLivenessDetectorView: View { isPresented = false onCompletion(.success(())) case .encounteredUnrecoverableError(let error): - viewModel.livenessService.closeSocket(with: .normalClosure) + let closeCode = error.webSocketCloseCode ?? .normalClosure + viewModel.livenessService.closeSocket(with: closeCode) isPresented = false onCompletion(.failure(mapError(error))) default: @@ -188,8 +189,6 @@ public struct FaceLivenessDetectorView: View { return .sessionTimedOut case .socketClosed: return .socketClosed - case .invalidFaceMovementDuringCountdown: - return .countdownFaceTooClose default: return .cameraPermissionDenied } @@ -228,12 +227,6 @@ public struct FaceLivenessDetectorView: View { } } -enum CountdownDisplayState { - case waitingToDisplay - case displaying - case finishedDisplaying -} - enum DisplayState { case awaitingLivenessSession case displayingGetReadyView diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift index 8acc47d0..70e47955 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel+FaceDetectionResultHandler.swift @@ -10,6 +10,7 @@ import SwiftUI @_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin fileprivate let initialFaceDistanceThreshold: CGFloat = 0.32 +fileprivate let noFitTimeoutInterval: TimeInterval = 7 extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { func process(newResult: FaceDetectionResult) { @@ -83,17 +84,26 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { } } - func handleNoMatch(instruction: Instructor.Instruction, percentage: Double) { - let noMatchTimeoutInterval: TimeInterval = 7 + func handleNoFaceFit(instruction: Instructor.Instruction, percentage: Double) { self.livenessState.awaitingFaceMatch(with: instruction, nearnessPercentage: percentage) - if noMatchStartTime == nil { - noMatchStartTime = Date() + if noFitStartTime == nil { + noFitStartTime = Date() } - if let elapsedTime = noMatchStartTime?.timeIntervalSinceNow, abs(elapsedTime) >= noMatchTimeoutInterval { + if let elapsedTime = noFitStartTime?.timeIntervalSinceNow, abs(elapsedTime) >= noFitTimeoutInterval { + self.livenessState + .unrecoverableStateEncountered(.timedOut) + self.captureSession.stopRunning() + } + } + + func handleNoFaceDetected() { + if noFitStartTime == nil { + noFitStartTime = Date() + } + if let elapsedTime = noFitStartTime?.timeIntervalSinceNow, abs(elapsedTime) >= noFitTimeoutInterval { self.livenessState .unrecoverableStateEncountered(.timedOut) self.captureSession.stopRunning() - return } } @@ -109,14 +119,15 @@ extension FaceLivenessDetectionViewModel: FaceDetectionResultHandler { self.livenessViewControllerDelegate?.displayFreshness(colorSequences: colorSequences) let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(.success) - self.noMatchStartTime = nil + self.noFitStartTime = nil case .tooClose(_, let percentage), .tooFar(_, let percentage), .tooFarLeft(_, let percentage), .tooFarRight(_, let percentage): - self.handleNoMatch(instruction: instruction, percentage: percentage) - default: break + self.handleNoFaceFit(instruction: instruction, percentage: percentage) + case .none: + self.handleNoFaceDetected() } } } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift index 71434c99..bebeb1e8 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionViewModel.swift @@ -39,7 +39,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { var faceGuideRect: CGRect! var initialClientEvent: InitialClientEvent? var faceMatchedTimestamp: UInt64? - var noMatchStartTime: Date? + var noFitStartTime: Date? init( faceDetector: FaceDetector, @@ -108,7 +108,7 @@ class FaceLivenessDetectionViewModel: ObservableObject { @objc func willResignActive(_ notification: Notification) { DispatchQueue.main.async { self.stopRecording() - self.livenessState.unrecoverableStateEncountered(.socketClosed) + self.livenessState.unrecoverableStateEncountered(.viewResignation) } } diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift b/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift index df68a67a..48dd1efa 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessStateMachine.swift @@ -124,18 +124,28 @@ struct LivenessStateMachine { struct LivenessError: Error, Equatable { let code: UInt8 - - static let unknown = LivenessError(code: 0) - static let missingVideoPermission = LivenessError(code: 1) - static let errorWithUnderlyingOSFramework = LivenessError(code: 2) - static let userCancelled = LivenessError(code: 3) - static let timedOut = LivenessError(code: 4) - static let couldNotOpenStream = LivenessError(code: 5) - static let socketClosed = LivenessError(code: 6) - static let invalidFaceMovementDuringCountdown = LivenessError(code: 7) + let webSocketCloseCode: URLSessionWebSocketTask.CloseCode? + + static let unknown = LivenessError(code: 0, webSocketCloseCode: .unexpectedRuntimeError) + static let missingVideoPermission = LivenessError(code: 1, webSocketCloseCode: .missingVideoPermission) + static let errorWithUnderlyingOSFramework = LivenessError(code: 2, webSocketCloseCode: .unexpectedRuntimeError) + static let userCancelled = LivenessError(code: 3, webSocketCloseCode: .ovalFitUserClosedSession) + static let timedOut = LivenessError(code: 4, webSocketCloseCode: .ovalFitMatchTimeout) + 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 func == (lhs: LivenessError, rhs: LivenessError) -> Bool { lhs.code == rhs.code } } } + +extension URLSessionWebSocketTask.CloseCode { + static let ovalFitMatchTimeout = URLSessionWebSocketTask.CloseCode(rawValue: 4001) + static let ovalFitTimeOutNoFaceDetected = URLSessionWebSocketTask.CloseCode(rawValue: 4002) + static let ovalFitUserClosedSession = URLSessionWebSocketTask.CloseCode(rawValue: 4003) + static let viewClosure = URLSessionWebSocketTask.CloseCode(rawValue: 4004) + static let unexpectedRuntimeError = URLSessionWebSocketTask.CloseCode(rawValue: 4005) + static let missingVideoPermission = URLSessionWebSocketTask.CloseCode(rawValue: 4006) +} diff --git a/Tests/FaceLivenessTests/LivenessTests.swift b/Tests/FaceLivenessTests/LivenessTests.swift index e3620968..69a96c6a 100644 --- a/Tests/FaceLivenessTests/LivenessTests.swift +++ b/Tests/FaceLivenessTests/LivenessTests.swift @@ -140,18 +140,34 @@ final class FaceLivenessDetectionViewModelTestCase: XCTestCase { } /// Given: A `FaceLivenessDetectionViewModel` - /// When: The viewModel handles a no match event over a duration of 7 seconds + /// When: The viewModel handles a no fit event over a duration of 7 seconds /// Then: The end state is `.encounteredUnrecoverableError(.timedOut)` - func testNoMatchTimeoutCheck() async throws { + func testNoFitTimeoutCheck() async throws { viewModel.livenessService = self.livenessService - self.viewModel.handleNoMatch(instruction: .tooFar(nearnessPercentage: 0.2), percentage: 0.2) + self.viewModel.handleNoFaceFit(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) + self.viewModel.handleNoFaceFit(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) + self.viewModel.handleNoFaceFit(instruction: .tooFar(nearnessPercentage: 0.2), percentage: 0.2) + XCTAssertEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) + } + + /// Given: A `FaceLivenessDetectionViewModel` + /// When: The viewModel handles a no face detected event over a duration of 7 seconds + /// Then: The end state is `.encounteredUnrecoverableError(.timedOut)` + func testNoFaceDetectedTimeoutCheck() async throws { + viewModel.livenessService = self.livenessService + self.viewModel.handleNoFaceDetected() + + XCTAssertNotEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) + try await Task.sleep(seconds: 6) + self.viewModel.handleNoFaceFit(instruction: .tooFar(nearnessPercentage: 0.2), percentage: 0.2) + XCTAssertNotEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) + try await Task.sleep(seconds: 1) + self.viewModel.handleNoFaceDetected() XCTAssertEqual(self.viewModel.livenessState.state, .encounteredUnrecoverableError(.timedOut)) } }