From a8eea411da2afd792f2d0a6afc9ebf18c4532c53 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Wed, 17 Apr 2024 15:46:17 -0700 Subject: [PATCH 1/4] feat(predictions): add public definitions for no light challenge support (#3617) * feat(predictions): add public definitions for no light challenge support * Add missing files * fix swiftlint issue * feat(predictions): add implementation for no light support (#3618) * feat(predictions): add implementation for no light support * fix swiftlint issue * address review comments * address review comments * address review comments --- .../Liveness/Events/LivenessEvent.swift | 4 +- ...t.swift => LivenessFinalClientEvent.swift} | 33 +++++-- .../Events/LivenessFreshnessEvent.swift | 26 +++--- .../Events/LivenessInitialClientEvent.swift | 28 +++++- .../Liveness/Model/DTOMapping.swift | 93 +++++++++++-------- .../Model/FaceLivenessSession+Challenge.swift | 15 +++ ...LivenessSession+SessionConfiguration.swift | 7 +- .../SPI/AWSPredictionsPlugin+Liveness.swift | 14 ++- .../Liveness/Service/Challenge.swift | 29 ++++++ .../Service/FaceLivenessSession.swift | 32 ++++++- .../FaceLivenessSessionRepresentable.swift | 9 +- .../ServiceModel/ChallengeEvent.swift | 18 ++++ .../ServiceModel/ClientChallenge.swift | 42 ++++++++- .../FaceMovementClientChallenge.swift | 24 +++++ .../FaceMovementServerChallenge.swift | 18 ++++ .../ServiceModel/ServerChallenge.swift | 36 ++++++- 16 files changed, 356 insertions(+), 72 deletions(-) rename AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/{LivenessFinalCientEvent.swift => LivenessFinalClientEvent.swift} (55%) create mode 100644 AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift create mode 100644 AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift create mode 100644 AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ChallengeEvent.swift create mode 100644 AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementClientChallenge.swift create mode 100644 AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementServerChallenge.swift diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessEvent.swift index 822373e296..4692da0270 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessEvent.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessEvent.swift @@ -23,8 +23,9 @@ public enum LivenessEventKind { self.rawValue = rawValue } - public static let challenge = Self(rawValue: "ServerSessionInformationEvent") + public static let sessionInformation = Self(rawValue: "ServerSessionInformationEvent") public static let disconnect = Self(rawValue: "DisconnectionEvent") + public static let challenge = Self(rawValue: "ChallengeEvent") } case server(Server) @@ -60,6 +61,7 @@ extension LivenessEventKind: CustomDebugStringConvertible { public var debugDescription: String { switch self { case .server(.challenge): return ".server(.challenge)" + case .server(.sessionInformation): return ".server(.sessionInformation)" case .server(.disconnect): return ".server(.disconnect)" case .client(.initialFaceDetected): return ".client(.initialFaceDetected)" case .client(.video): return ".client(.video)" diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalCientEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift similarity index 55% rename from AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalCientEvent.swift rename to AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift index 13f7ba1221..749df67a24 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalCientEvent.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift @@ -26,11 +26,13 @@ public struct FinalClientEvent { extension LivenessEvent where T == FinalClientEvent { @_spi(PredictionsFaceLiveness) - public static func final(event: FinalClientEvent) throws -> Self { - - let clientEvent = ClientSessionInformationEvent( - challenge: .init( - faceMovementAndLightChallenge: .init( + public static func final(event: FinalClientEvent, + challenge: Challenge) throws -> Self { + let clientChallengeType: ClientChallenge.ChallengeType + switch challenge.type { + case .faceMovementAndLightChallenge: + clientChallengeType = .faceMovementAndLightChallenge( + challenge: .init( challengeID: event.initialClientEvent.challengeID, targetFace: .init( boundingBox: .init(boundingBox: event.targetFace.initialEvent.boundingBox), @@ -46,7 +48,26 @@ extension LivenessEvent where T == FinalClientEvent { videoEndTimeStamp: Date().epochMilliseconds ) ) - ) + case .faceMovementChallenge: + clientChallengeType = .faceMovementChallenge( + challenge: .init( + challengeID: event.initialClientEvent.challengeID, + targetFace: .init( + boundingBox: .init(boundingBox: event.targetFace.initialEvent.boundingBox), + faceDetectedInTargetPositionStartTimestamp: event.targetFace.initialEvent.startTimestamp, + faceDetectedInTargetPositionEndTimestamp: event.targetFace.endTimestamp + ), + initialFace: .init( + boundingBox: .init(boundingBox: event.initialClientEvent.initialFaceLocation.boundingBox), + initialFaceDetectedTimeStamp: event.initialClientEvent.initialFaceLocation.startTimestamp + ), + videoStartTimestamp: nil, + videoEndTimeStamp: Date().epochMilliseconds + ) + ) + } + + let clientEvent = ClientSessionInformationEvent(challenge: .init(clientChallengeType: clientChallengeType)) let payload = try JSONEncoder().encode(clientEvent) return .init( payload: payload, diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFreshnessEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFreshnessEvent.swift index 7bd2d22887..39a1fb8121 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFreshnessEvent.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFreshnessEvent.swift @@ -29,18 +29,20 @@ extension LivenessEvent where T == FreshnessEvent { public static func freshness(event: FreshnessEvent) throws -> Self { let clientEvent = ClientSessionInformationEvent( challenge: .init( - faceMovementAndLightChallenge: .init( - challengeID: event.challengeID, - targetFace: nil, - initialFace: nil, - videoStartTimestamp: nil, - colorDisplayed: .init( - currentColor: .init(rgb: event.color), - sequenceNumber: event.sequenceNumber, - currentColorStartTimeStamp: event.timestamp, - previousColor: .init(rgb: event.previousColor) - ), - videoEndTimeStamp: nil + clientChallengeType: .faceMovementAndLightChallenge( + challenge: .init( + challengeID: event.challengeID, + targetFace: nil, + initialFace: nil, + videoStartTimestamp: nil, + colorDisplayed: .init( + currentColor: .init(rgb: event.color), + sequenceNumber: event.sequenceNumber, + currentColorStartTimeStamp: event.timestamp, + previousColor: .init(rgb: event.previousColor) + ), + videoEndTimeStamp: nil + ) ) ) ) diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift index 9b522f9680..b586992171 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift @@ -26,15 +26,20 @@ public struct InitialClientEvent { extension LivenessEvent where T == InitialClientEvent { @_spi(PredictionsFaceLiveness) - public static func initialFaceDetected(event: InitialClientEvent) throws -> Self { + public static func initialFaceDetected( + event: InitialClientEvent, + challenge: Challenge + ) throws -> Self { let initialFace = InitialFace( boundingBox: .init(boundingBox: event.initialFaceLocation.boundingBox), initialFaceDetectedTimeStamp: event.initialFaceLocation.startTimestamp ) - let clientSessionInformationEvent = ClientSessionInformationEvent( - challenge: .init( - faceMovementAndLightChallenge: .init( + let clientChallengeType: ClientChallenge.ChallengeType + switch challenge.type { + case .faceMovementAndLightChallenge: + clientChallengeType = .faceMovementAndLightChallenge( + challenge: .init( challengeID: event.challengeID, targetFace: nil, initialFace: initialFace, @@ -43,8 +48,21 @@ extension LivenessEvent where T == InitialClientEvent { videoEndTimeStamp: nil ) ) + case .faceMovementChallenge: + clientChallengeType = .faceMovementChallenge( + challenge: .init( + challengeID: event.challengeID, + targetFace: nil, + initialFace: initialFace, + videoStartTimestamp: event.videoStartTimestamp, + videoEndTimeStamp: nil + ) + ) + } + + let clientSessionInformationEvent = ClientSessionInformationEvent( + challenge: .init(clientChallengeType: clientChallengeType) ) - let payload = try JSONEncoder().encode(clientSessionInformationEvent) return .init( payload: payload, diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift index 9fc9cca08a..64d52ebfc6 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift @@ -8,8 +8,18 @@ import Foundation func ovalChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSession.OvalMatchChallenge { - let challengeConfig = event.sessionInformation.challenge.faceMovementAndLightChallenge.challengeConfig - let ovalParameters = event.sessionInformation.challenge.faceMovementAndLightChallenge.ovalParameters + let challengeConfig: ChallengeConfig + let ovalParameters: OvalParameters + + switch event.sessionInformation.challenge.type { + case .faceMovementAndLightChallenge(let challenge): + challengeConfig = challenge.challengeConfig + ovalParameters = challenge.ovalParameters + case .faceMovementChallenge(let challenge): + challengeConfig = challenge.challengeConfig + ovalParameters = challenge.ovalParameters + } + let ovalBoundingBox = FaceLivenessSession.BoundingBox.init( x: Double(ovalParameters.centerX - ovalParameters.width / 2), y: Double(ovalParameters.centerY - ovalParameters.height / 2), @@ -37,44 +47,46 @@ func ovalChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSes ) } -func colorChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSession.ColorChallenge { - let displayColors = event.sessionInformation.challenge - .faceMovementAndLightChallenge.colorSequences - .map({ color -> FaceLivenessSession.DisplayColor in +func colorChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSession.ColorChallenge? { + switch event.sessionInformation.challenge.type { + case .faceMovementAndLightChallenge(let challenge): + let displayColors = challenge.colorSequences + .map({ color -> FaceLivenessSession.DisplayColor in - let duration: Double - let shouldScroll: Bool - switch (color.downscrollDuration, color.flatDisplayDuration) { - case (...0, 0...): - duration = Double(color.flatDisplayDuration) - shouldScroll = false - default: - duration = Double(color.downscrollDuration) - shouldScroll = true - } + let duration: Double + let shouldScroll: Bool + switch (color.downscrollDuration, color.flatDisplayDuration) { + case (...0, 0...): + duration = Double(color.flatDisplayDuration) + shouldScroll = false + default: + duration = Double(color.downscrollDuration) + shouldScroll = true + } - precondition( - color.freshnessColor.rgb.count == 3, - """ - Received invalid freshness colors. - Expected 3 values (r, g, b), received: \(color.freshnessColor.rgb.count) - """ - ) + precondition( + color.freshnessColor.rgb.count == 3, + """ + Received invalid freshness colors. + Expected 3 values (r, g, b), received: \(color.freshnessColor.rgb.count) + """ + ) - return .init( - rgb: .init( - red: Double(color.freshnessColor.rgb[0]) / 255, - green: Double(color.freshnessColor.rgb[1]) / 255, - blue: Double(color.freshnessColor.rgb[2]) / 255, - _values: color.freshnessColor.rgb - ), - duration: duration, - shouldScroll: shouldScroll - ) - }) - return .init( - colors: displayColors - ) + return .init( + rgb: .init( + red: Double(color.freshnessColor.rgb[0]) / 255, + green: Double(color.freshnessColor.rgb[1]) / 255, + blue: Double(color.freshnessColor.rgb[2]) / 255, + _values: color.freshnessColor.rgb + ), + duration: duration, + shouldScroll: shouldScroll + ) + }) + return .init(colors: displayColors) + case .faceMovementChallenge: + return nil + } } func sessionConfiguration(from event: ServerSessionInformationEvent) -> FaceLivenessSession.SessionConfiguration { @@ -83,3 +95,10 @@ func sessionConfiguration(from event: ServerSessionInformationEvent) -> FaceLive ovalMatchChallenge: ovalChallenge(from: event) ) } + +func challengeType(from event: ChallengeEvent) -> Challenge { + .init( + version: event.version, + type: event.type + ) +} diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift new file mode 100644 index 0000000000..d399f166d7 --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension FaceLivenessSession { + public static let supportedChallenges: [Challenge] = [ + Challenge(version: "2.0.0", type: .faceMovementAndLightChallenge), + Challenge(version: "1.0.0", type: .faceMovementChallenge) + ] +} diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift index 139e28f0ed..7b9b6040eb 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift @@ -10,10 +10,13 @@ import Foundation extension FaceLivenessSession { @_spi(PredictionsFaceLiveness) public struct SessionConfiguration { - public let colorChallenge: ColorChallenge + public let colorChallenge: ColorChallenge? public let ovalMatchChallenge: OvalMatchChallenge - public init(colorChallenge: ColorChallenge, ovalMatchChallenge: OvalMatchChallenge) { + public init( + colorChallenge: ColorChallenge? = nil, + ovalMatchChallenge: OvalMatchChallenge + ) { self.colorChallenge = colorChallenge self.ovalMatchChallenge = ovalMatchChallenge } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift index d2eec8d96e..736f5541ee 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift @@ -36,7 +36,8 @@ extension AWSPredictionsPlugin { let session = FaceLivenessSession( websocket: WebSocketSession(), signer: signer, - baseURL: url + baseURL: url, + options: options ) session.onServiceException = { completion(.failure($0)) } @@ -48,7 +49,16 @@ extension AWSPredictionsPlugin { extension FaceLivenessSession { @_spi(PredictionsFaceLiveness) public struct Options { - public init() {} + public let viewId: String + public let preCheckViewEnabled: Bool + + public init( + faceLivenessDetectorViewId: String, + preCheckViewEnabled: Bool + ) { + self.viewId = faceLivenessDetectorViewId + self.preCheckViewEnabled = preCheckViewEnabled + } } } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift new file mode 100644 index 0000000000..d98fccae0e --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +@_spi(PredictionsFaceLiveness) +public struct Challenge { + public let version: String + public let type: ChallengeType + + public init(version: String, type: ChallengeType) { + self.version = version + self.type = type + } + + public func queryParameterString() -> String { + return self.type.rawValue + "_" + self.version + } +} + +@_spi(PredictionsFaceLiveness) +public enum ChallengeType: String, Codable { + case faceMovementChallenge = "FaceMovementChallenge" + case faceMovementAndLightChallenge = "FaceMovementAndLightChallenge" +} diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift index 1723f0c688..575db2a72f 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift @@ -16,6 +16,7 @@ public final class FaceLivenessSession: LivenessService { let signer: SigV4Signer let baseURL: URL var serverEventListeners: [LivenessEventKind.Server: (FaceLivenessSession.SessionConfiguration) -> Void] = [:] + var challengeTypeListeners: [LivenessEventKind.Server: (Challenge) -> Void] = [:] var onComplete: (ServerDisconnection) -> Void = { _ in } var serverDate: Date? var savedURLForReconnect: URL? @@ -25,6 +26,7 @@ public final class FaceLivenessSession: LivenessService { case normal case reconnect } + let options: FaceLivenessSession.Options private let livenessServiceDispatchQueue = DispatchQueue( label: "com.amazon.aws.amplify.liveness.service", @@ -33,12 +35,14 @@ public final class FaceLivenessSession: LivenessService { init( websocket: WebSocketSession, signer: SigV4Signer, - baseURL: URL + baseURL: URL, + options: FaceLivenessSession.Options ) { self.eventStreamEncoder = EventStream.Encoder() self.eventStreamDecoder = EventStream.Decoder() self.signer = signer self.baseURL = baseURL + self.options = options self.websocket = websocket @@ -69,6 +73,10 @@ public final class FaceLivenessSession: LivenessService { ) { serverEventListeners[event] = listener } + + public func register(listener: @escaping (Challenge) -> Void, on event: LivenessEventKind.Server) { + challengeTypeListeners[event] = listener + } public func closeSocket(with code: URLSessionWebSocketTask.CloseCode) { livenessServiceDispatchQueue.async { @@ -76,11 +84,18 @@ public final class FaceLivenessSession: LivenessService { } } - public func initializeLivenessStream(withSessionID sessionID: String, userAgent: String = "") throws { + public func initializeLivenessStream(withSessionID sessionID: String, + userAgent: String = "", + challenges: [Challenge] = FaceLivenessSession.supportedChallenges) throws { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + components?.queryItems = [ URLQueryItem(name: "session-id", value: sessionID), - URLQueryItem(name: "challenge-versions", value: "FaceMovementAndLightChallenge_1.0.0"), + URLQueryItem(name: "precheck-view-enabled", value: options.preCheckViewEnabled ? "1":"0"), + // TODO: Change this after confirmation + URLQueryItem(name: "attempt-id", value: options.viewId), + URLQueryItem(name: "challenge-versions", + value: challenges.map({$0.queryParameterString()}).joined(separator: ",")), URLQueryItem(name: "video-width", value: "480"), URLQueryItem(name: "video-height", value: "640"), URLQueryItem(name: "x-amz-user-agent", value: userAgent) @@ -144,6 +159,9 @@ public final class FaceLivenessSession: LivenessService { if let payload = try? JSONDecoder().decode(ServerSessionInformationEvent.self, from: message.payload) { let sessionConfiguration = sessionConfiguration(from: payload) self.serverEventListeners[.challenge]?(sessionConfiguration) + } else if let payload = try? JSONDecoder().decode(ChallengeEvent.self, from: message.payload) { + let challengeType = challengeType(from: payload) + self.challengeTypeListeners[.challenge]?(challengeType) } else if (try? JSONDecoder().decode(DisconnectEvent.self, from: message.payload)) != nil { onComplete(.disconnectionEvent) return .stopAndInvalidateSession @@ -161,6 +179,14 @@ public final class FaceLivenessSession: LivenessService { let serverEvent = LivenessEventKind.Server(rawValue: eventType.value) switch serverEvent { case .challenge: + // :event-type ChallengeEvent + let payload = try JSONDecoder().decode( + ChallengeEvent.self, from: message.payload + ) + let challengeType = challengeType(from: payload) + challengeTypeListeners[.challenge]?(challengeType) + return true + case .sessionInformation: // :event-type ServerSessionInformationEvent let payload = try JSONDecoder().decode( ServerSessionInformationEvent.self, from: message.payload diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift index 896ef5769b..49f2dbe295 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift @@ -19,12 +19,19 @@ public protocol LivenessService { func register(onComplete: @escaping (ServerDisconnection) -> Void) - func initializeLivenessStream(withSessionID sessionID: String, userAgent: String) throws + func initializeLivenessStream(withSessionID sessionID: String, + userAgent: String, + challenges: [Challenge]) throws func register( listener: @escaping (FaceLivenessSession.SessionConfiguration) -> Void, on event: LivenessEventKind.Server ) + + func register( + listener: @escaping (Challenge) -> Void, + on event: LivenessEventKind.Server + ) func closeSocket(with code: URLSessionWebSocketTask.CloseCode) } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ChallengeEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ChallengeEvent.swift new file mode 100644 index 0000000000..8401c8ec20 --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ChallengeEvent.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct ChallengeEvent: Codable { + let version: String + let type: ChallengeType + + enum CodingKeys: String, CodingKey { + case version = "Version" + case type = "Type" + } +} diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ClientChallenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ClientChallenge.swift index 97b3067010..af2e4157d5 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ClientChallenge.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ClientChallenge.swift @@ -8,9 +8,47 @@ import Foundation struct ClientChallenge: Codable { - let faceMovementAndLightChallenge: FaceMovementAndLightClientChallenge? - + let type: ChallengeType + + init(clientChallengeType: ChallengeType) { + self.type = clientChallengeType + } + enum CodingKeys: String, CodingKey { case faceMovementAndLightChallenge = "FaceMovementAndLightChallenge" + case faceMovementChallenge = "FaceMovementChallenge" + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self.type { + case .faceMovementChallenge(let faceMovementServerChallenge): + try container.encode(faceMovementServerChallenge, forKey: .faceMovementChallenge) + case .faceMovementAndLightChallenge(let faceMovementAndLightServerChallenge): + try container.encode(faceMovementAndLightServerChallenge, forKey: .faceMovementAndLightChallenge) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let value = try? container.decode(FaceMovementClientChallenge.self, forKey: .faceMovementChallenge) { + self.type = .faceMovementChallenge(challenge: value) + } else if let value = try? container.decode(FaceMovementAndLightClientChallenge.self, forKey: .faceMovementAndLightChallenge) { + self.type = .faceMovementAndLightChallenge(challenge: value) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Unexpected data format" + ) + ) + } + } +} + +extension ClientChallenge { + enum ChallengeType: Codable { + case faceMovementChallenge(challenge: FaceMovementClientChallenge) + case faceMovementAndLightChallenge(challenge: FaceMovementAndLightClientChallenge) } } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementClientChallenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementClientChallenge.swift new file mode 100644 index 0000000000..9ab47ccf47 --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementClientChallenge.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct FaceMovementClientChallenge: Codable { + let challengeID: String + let targetFace: TargetFace? + let initialFace: InitialFace? + let videoStartTimestamp: UInt64? + let videoEndTimeStamp: UInt64? + + enum CodingKeys: String, CodingKey { + case challengeID = "ChallengeId" + case targetFace = "TargetFace" + case initialFace = "InitialFace" + case videoStartTimestamp = "VideoStartTimestamp" + case videoEndTimeStamp = "VideoEndTimestamp" + } +} diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementServerChallenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementServerChallenge.swift new file mode 100644 index 0000000000..0c3101c533 --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/FaceMovementServerChallenge.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct FaceMovementServerChallenge: Codable { + let ovalParameters: OvalParameters + let challengeConfig: ChallengeConfig + + enum CodingKeys: String, CodingKey { + case challengeConfig = "ChallengeConfig" + case ovalParameters = "OvalParameters" + } +} diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ServerChallenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ServerChallenge.swift index d811c2a7f6..49aed90b5b 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ServerChallenge.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/ServiceModel/ServerChallenge.swift @@ -8,9 +8,43 @@ import Foundation struct ServerChallenge: Codable { - let faceMovementAndLightChallenge: FaceMovementAndLightServerChallenge + let type: ChallengeType enum CodingKeys: String, CodingKey { case faceMovementAndLightChallenge = "FaceMovementAndLightChallenge" + case faceMovementChallenge = "FaceMovementChallenge" + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self.type { + case .faceMovementChallenge(let faceMovementServerChallenge): + try container.encode(faceMovementServerChallenge, forKey: .faceMovementChallenge) + case .faceMovementAndLightChallenge(let faceMovementAndLightServerChallenge): + try container.encode(faceMovementAndLightServerChallenge, forKey: .faceMovementAndLightChallenge) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let value = try? container.decode(FaceMovementServerChallenge.self, forKey: .faceMovementChallenge) { + self.type = .faceMovementChallenge(challenge: value) + } else if let value = try? container.decode(FaceMovementAndLightServerChallenge.self, forKey: .faceMovementAndLightChallenge) { + self.type = .faceMovementAndLightChallenge(challenge: value) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Unexpected data format" + ) + ) + } + } +} + +extension ServerChallenge { + enum ChallengeType: Codable { + case faceMovementChallenge(challenge: FaceMovementServerChallenge) + case faceMovementAndLightChallenge(challenge: FaceMovementAndLightServerChallenge) } } From b5f74111548b85292ee245f75b003983124c784f Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Mon, 6 May 2024 08:50:33 -0700 Subject: [PATCH 2/4] chore(predictions): add attempt count changes and unit tests (#3657) * chore(predictions): add attempt count changes and unit tests * remove test url * Add coding keys for Challenge object --- .../SPI/AWSPredictionsPlugin+Liveness.swift | 10 +- .../Liveness/Service/Challenge.swift | 7 +- .../Service/FaceLivenessSession.swift | 11 +- .../FaceLivenessSessionRepresentable.swift | 3 +- .../LivenessChallengeTests.swift | 24 ++ .../LivenessTests/LivenessDecodingTests.swift | 219 ++++++++++++++++++ 6 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift create mode 100644 AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessDecodingTests.swift diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift index 736f5541ee..85a4545e91 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift @@ -15,7 +15,6 @@ extension AWSPredictionsPlugin { withID sessionID: String, credentialsProvider: AWSCredentialsProvider? = nil, region: String, - options: FaceLivenessSession.Options, completion: @escaping (Result) -> Void ) async throws -> FaceLivenessSession { @@ -36,8 +35,7 @@ extension AWSPredictionsPlugin { let session = FaceLivenessSession( websocket: WebSocketSession(), signer: signer, - baseURL: url, - options: options + baseURL: url ) session.onServiceException = { completion(.failure($0)) } @@ -49,14 +47,14 @@ extension AWSPredictionsPlugin { extension FaceLivenessSession { @_spi(PredictionsFaceLiveness) public struct Options { - public let viewId: String + public let attemptCount: Int public let preCheckViewEnabled: Bool public init( - faceLivenessDetectorViewId: String, + attemptCount: Int, preCheckViewEnabled: Bool ) { - self.viewId = faceLivenessDetectorViewId + self.attemptCount = attemptCount self.preCheckViewEnabled = preCheckViewEnabled } } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift index d98fccae0e..9732f82b31 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift @@ -8,7 +8,7 @@ import Foundation @_spi(PredictionsFaceLiveness) -public struct Challenge { +public struct Challenge: Codable { public let version: String public let type: ChallengeType @@ -20,6 +20,11 @@ public struct Challenge { public func queryParameterString() -> String { return self.type.rawValue + "_" + self.version } + + enum CodingKeys: String, CodingKey { + case version = "Version" + case type = "Type" + } } @_spi(PredictionsFaceLiveness) diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift index 575db2a72f..f2b44935b9 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift @@ -35,14 +35,12 @@ public final class FaceLivenessSession: LivenessService { init( websocket: WebSocketSession, signer: SigV4Signer, - baseURL: URL, - options: FaceLivenessSession.Options + baseURL: URL ) { self.eventStreamEncoder = EventStream.Encoder() self.eventStreamDecoder = EventStream.Decoder() self.signer = signer self.baseURL = baseURL - self.options = options self.websocket = websocket @@ -86,14 +84,13 @@ public final class FaceLivenessSession: LivenessService { public func initializeLivenessStream(withSessionID sessionID: String, userAgent: String = "", - challenges: [Challenge] = FaceLivenessSession.supportedChallenges) throws { + challenges: [Challenge] = FaceLivenessSession.supportedChallenges, + options: FaceLivenessSession.Options) throws { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) - components?.queryItems = [ URLQueryItem(name: "session-id", value: sessionID), URLQueryItem(name: "precheck-view-enabled", value: options.preCheckViewEnabled ? "1":"0"), - // TODO: Change this after confirmation - URLQueryItem(name: "attempt-id", value: options.viewId), + URLQueryItem(name: "attempt-count", value: String(options.attemptCount)), URLQueryItem(name: "challenge-versions", value: challenges.map({$0.queryParameterString()}).joined(separator: ",")), URLQueryItem(name: "video-width", value: "480"), diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift index 49f2dbe295..94037317c7 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift @@ -21,7 +21,8 @@ public protocol LivenessService { func initializeLivenessStream(withSessionID sessionID: String, userAgent: String, - challenges: [Challenge]) throws + challenges: [Challenge], + options: FaceLivenessSession.Options) throws func register( listener: @escaping (FaceLivenessSession.SessionConfiguration) -> Void, diff --git a/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift new file mode 100644 index 0000000000..c9d041ca33 --- /dev/null +++ b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +@testable import AWSPredictionsPlugin +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin + +class LivenessChallengeTests: XCTestCase { + + func testFaceMovementChallengeQueryParamterString() { + let challenge = Challenge(version: "1.0.0", type: .faceMovementChallenge) + XCTAssertEqual(challenge.queryParameterString(), "FaceMovementChallenge_1.0.0") + } + + func testFaceMovementAndLightChallengeQueryParamterString() { + let challenge = Challenge(version: "2.0.0", type: .faceMovementAndLightChallenge) + XCTAssertEqual(challenge.queryParameterString(), "FaceMovementAndLightChallenge_2.0.0") + } +} diff --git a/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessDecodingTests.swift b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessDecodingTests.swift new file mode 100644 index 0000000000..385ea59515 --- /dev/null +++ b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessDecodingTests.swift @@ -0,0 +1,219 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +@testable import AWSPredictionsPlugin +@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin + +class LivenessDecodingTests: XCTestCase { + + // MARK: - ChallengeEvent + /// - Given: A valid json payload depicting a FaceMovementChallenge + /// - When: The payload is decoded + /// - Then: The payload is decoded successfully + func testFacemovementChallengeEventDecodeSuccess() { + let jsonString = + """ + {"Type":"FaceMovementChallenge","Version":"1.0.0"} + """ + + do { + let data = jsonString.data(using: .utf8) + guard let data = data else { + XCTFail("Input JSON is invalid") + return + } + let challengeEvent = try JSONDecoder().decode( + ChallengeEvent.self, from: data + ) + + XCTAssertEqual(challengeEvent.type, ChallengeType.faceMovementChallenge) + XCTAssertEqual(challengeEvent.version, "1.0.0") + } catch { + XCTFail("Decoding failed with error: \(error)") + } + } + + /// - Given: A valid json payload depicting a FaceMovementAndLightChallenge + /// - When: The payload is decoded + /// - Then: The payload is decoded successfully + func testFacemovementAndLightChallengeEventDecodeSuccess() { + let jsonString = + """ + {"Type":"FaceMovementAndLightChallenge","Version":"1.0.0"} + """ + + do { + let data = jsonString.data(using: .utf8) + guard let data = data else { + XCTFail("Input JSON is invalid") + return + } + let challengeEvent = try JSONDecoder().decode( + ChallengeEvent.self, from: data + ) + + XCTAssertEqual(challengeEvent.type, ChallengeType.faceMovementAndLightChallenge) + XCTAssertEqual(challengeEvent.version, "1.0.0") + } catch { + XCTFail("Decoding failed with error: \(error)") + } + } + + /// - Given: A valid json payload depicting an unknown challenge + /// - When: The payload is decoded + /// - Then: Error is thrown + func testUnknownChallengeEventDecodeFailure() { + let jsonString = + """ + {"Type":"UnknownChallenge","Version":"1.0.0"} + """ + + do { + let data = jsonString.data(using: .utf8) + guard let data = data else { + XCTFail("Input JSON is invalid") + return + } + _ = try JSONDecoder().decode( + ChallengeEvent.self, from: data + ) + + XCTFail("Decoding should fail for unknown challenge") + } catch { + XCTAssertNotNil(error) + } + } + + // MARK: - ServerSessionInformationEvent + + /// - Given: A valid json payload depicting a ServerSessionInformation + /// containing FaceMovementChallenge + /// - When: The payload is decoded + /// - Then: The payload is decoded successfully + func testFaceMovementChallengeServerSessionInformationEventDecodeSuccess() { + let jsonString = + """ + {\"SessionInformation\":{\"Challenge\":{\"FaceMovementChallenge\":{\"OvalParameters\":{\"Width\":0.1,\"Height\":0.1,\"CenterY\":0.1,\"CenterX\":0.1},\"ChallengeConfig\":{\"BlazeFaceDetectionThreshold\":0.1,\"FaceIouHeightThreshold\":0.1,\"OvalHeightWidthRatio\":0.1,\"OvalIouHeightThreshold\":0.1,\"OvalFitTimeout\":1,\"OvalIouWidthThreshold\":0.1,\"OvalIouThreshold\":0.1,\"FaceDistanceThreshold\":0.1,\"FaceDistanceThresholdMax\":0.1,\"FaceIouWidthThreshold\":0.1,\"FaceDistanceThresholdMin\":0.1}}}}} + """ + + do { + let data = jsonString.data(using: .utf8) + guard let data = data else { + XCTFail("Input JSON is invalid") + return + } + let serverSessionInformationEvent = try JSONDecoder().decode( + ServerSessionInformationEvent.self, from: data + ) + + guard case let .faceMovementChallenge(challenge: recoveredChallenge) = + serverSessionInformationEvent.sessionInformation.challenge.type else { + XCTFail("Cannot decode event from the input JSON") + return + } + + XCTAssertEqual(recoveredChallenge.ovalParameters.height, 0.1) + XCTAssertEqual(recoveredChallenge.ovalParameters.width, 0.1) + XCTAssertEqual(recoveredChallenge.ovalParameters.centerX, 0.1) + XCTAssertEqual(recoveredChallenge.ovalParameters.centerY, 0.1) + + XCTAssertEqual(recoveredChallenge.challengeConfig.blazeFaceDetectionThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceDistanceThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceDistanceThresholdMax, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceDistanceThresholdMin, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceIouHeightThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceIouWidthThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalHeightWidthRatio, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalIouHeightThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalIouThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalIouWidthThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalFitTimeout, 1) + } catch { + XCTFail("Decoding failed with error: \(error)") + } + } + + /// - Given: A valid json payload depicting a ServerSessionInformation + /// containing FaceMovementAndLightChallenge + /// - When: The payload is decoded + /// - Then: The payload is decoded successfully + func testFaceMovementAndLightChallengeServerSessionInformationEventDecodeSuccess() { + let jsonString = + """ + {\"SessionInformation\":{\"Challenge\":{\"FaceMovementAndLightChallenge\":{\"OvalParameters\":{\"Height\":0.1,\"CenterX\":0.1,\"Width\":0.1,\"CenterY\":0.1},\"ColorSequences\":[{\"FreshnessColor\":{\"RGB\":[255,255,255]},\"DownscrollDuration\":0.1,\"FlatDisplayDuration\":0.1}],\"ChallengeConfig\":{\"OvalIouWidthThreshold\":0.1,\"FaceDistanceThreshold\":0.1,\"OvalFitTimeout\":1,\"FaceIouHeightThreshold\":0.1,\"FaceDistanceThresholdMax\":0.1,\"FaceDistanceThresholdMin\":0.1,\"OvalIouHeightThreshold\":0.1,\"FaceIouWidthThreshold\":0.1,\"OvalIouThreshold\":0.1,\"BlazeFaceDetectionThreshold\":0.1,\"OvalHeightWidthRatio\":0.1}}}}} + """ + + do { + let data = jsonString.data(using: .utf8) + guard let data = data else { + XCTFail("Input JSON is invalid") + return + } + let serverSessionInformationEvent = try JSONDecoder().decode( + ServerSessionInformationEvent.self, from: data + ) + + guard case let .faceMovementAndLightChallenge(challenge: recoveredChallenge) = + serverSessionInformationEvent.sessionInformation.challenge.type else { + XCTFail("Cannot decode event from the input JSON") + return + } + + XCTAssertEqual(recoveredChallenge.ovalParameters.height, 0.1) + XCTAssertEqual(recoveredChallenge.ovalParameters.width, 0.1) + XCTAssertEqual(recoveredChallenge.ovalParameters.centerX, 0.1) + XCTAssertEqual(recoveredChallenge.ovalParameters.centerY, 0.1) + + XCTAssertEqual(recoveredChallenge.challengeConfig.blazeFaceDetectionThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceDistanceThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceDistanceThresholdMax, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceDistanceThresholdMin, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceIouHeightThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.faceIouWidthThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalHeightWidthRatio, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalIouHeightThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalIouThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalIouWidthThreshold, 0.1) + XCTAssertEqual(recoveredChallenge.challengeConfig.ovalFitTimeout, 1) + + XCTAssertEqual(recoveredChallenge.colorSequences.count, 1) + XCTAssertEqual(recoveredChallenge.colorSequences.first?.downscrollDuration, 0.1) + XCTAssertEqual(recoveredChallenge.colorSequences.first?.flatDisplayDuration, 0.1) + XCTAssertEqual(recoveredChallenge.colorSequences.first?.freshnessColor.rgb, [255,255,255]) + } catch { + XCTFail("Decoding failed with error: \(error)") + } + } + + /// - Given: A valid json payload depicting a ServerSessionInformation + /// containing unknown challenge + /// - When: The payload is decoded + /// - Then: Error should be thrown + func testUnknownChallengeServerSessionInformationEventDecodeFailure() { + let jsonString = + """ + {\"SessionInformation\":{\"Challenge\":{\"UnknownChallenge\":{\"OvalParameters\":{\"Height\":0.1,\"CenterX\":0.1,\"Width\":0.1,\"CenterY\":0.1},\"ColorSequences\":[{\"FreshnessColor\":{\"RGB\":[255,255,255]},\"DownscrollDuration\":0.1,\"FlatDisplayDuration\":0.1}],\"ChallengeConfig\":{\"OvalIouWidthThreshold\":0.1,\"FaceDistanceThreshold\":0.1,\"OvalFitTimeout\":1,\"FaceIouHeightThreshold\":0.1,\"FaceDistanceThresholdMax\":0.1,\"FaceDistanceThresholdMin\":0.1,\"OvalIouHeightThreshold\":0.1,\"FaceIouWidthThreshold\":0.1,\"OvalIouThreshold\":0.1,\"BlazeFaceDetectionThreshold\":0.1,\"OvalHeightWidthRatio\":0.1}}}}} + """ + + do { + let data = jsonString.data(using: .utf8) + guard let data = data else { + XCTFail("Input JSON is invalid") + return + } + let serverSessionInformationEvent = try JSONDecoder().decode( + ServerSessionInformationEvent.self, from: data + ) + + XCTFail("Decoding should fail for unknown challenge") + } catch { + XCTAssertNotNil(error) + } + } +} From ae1d96a5eb37ab0f2bfcc9e836a2055c88cf8a01 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Thu, 22 Aug 2024 13:00:38 -0700 Subject: [PATCH 3/4] chore: fix build after rebase --- .../Liveness/Service/FaceLivenessSession.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift index f2b44935b9..00fea17129 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift @@ -26,7 +26,6 @@ public final class FaceLivenessSession: LivenessService { case normal case reconnect } - let options: FaceLivenessSession.Options private let livenessServiceDispatchQueue = DispatchQueue( label: "com.amazon.aws.amplify.liveness.service", @@ -182,7 +181,7 @@ public final class FaceLivenessSession: LivenessService { ) let challengeType = challengeType(from: payload) challengeTypeListeners[.challenge]?(challengeType) - return true + return .continueToReceive case .sessionInformation: // :event-type ServerSessionInformationEvent let payload = try JSONDecoder().decode( From 21bc17c0438b8390edcf532e3ef51f1c25c25749 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Mon, 23 Jun 2025 12:03:09 -0700 Subject: [PATCH 4/4] address review comments --- .../Events/LivenessFinalClientEvent.swift | 2 +- .../Events/LivenessInitialClientEvent.swift | 2 +- .../Liveness/Model/DTOMapping.swift | 89 +++++++++---------- .../Model/FaceLivenessSession+Challenge.swift | 4 +- ...LivenessSession+SessionConfiguration.swift | 14 +-- .../Liveness/Service/Challenge.swift | 31 ++++--- .../Service/FaceLivenessSession.swift | 8 +- .../LivenessChallengeTests.swift | 4 +- 8 files changed, 76 insertions(+), 78 deletions(-) diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift index 749df67a24..5baf620dfd 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessFinalClientEvent.swift @@ -29,7 +29,7 @@ extension LivenessEvent where T == FinalClientEvent { public static func final(event: FinalClientEvent, challenge: Challenge) throws -> Self { let clientChallengeType: ClientChallenge.ChallengeType - switch challenge.type { + switch challenge { case .faceMovementAndLightChallenge: clientChallengeType = .faceMovementAndLightChallenge( challenge: .init( diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift index b586992171..533169458c 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Events/LivenessInitialClientEvent.swift @@ -36,7 +36,7 @@ extension LivenessEvent where T == InitialClientEvent { ) let clientChallengeType: ClientChallenge.ChallengeType - switch challenge.type { + switch challenge { case .faceMovementAndLightChallenge: clientChallengeType = .faceMovementAndLightChallenge( challenge: .init( diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift index 64d52ebfc6..a0adf15fc6 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/DTOMapping.swift @@ -47,58 +47,57 @@ func ovalChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSes ) } -func colorChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSession.ColorChallenge? { - switch event.sessionInformation.challenge.type { - case .faceMovementAndLightChallenge(let challenge): - let displayColors = challenge.colorSequences - .map({ color -> FaceLivenessSession.DisplayColor in - - let duration: Double - let shouldScroll: Bool - switch (color.downscrollDuration, color.flatDisplayDuration) { - case (...0, 0...): - duration = Double(color.flatDisplayDuration) - shouldScroll = false - default: - duration = Double(color.downscrollDuration) - shouldScroll = true - } - - precondition( - color.freshnessColor.rgb.count == 3, +func colorChallenge(from challenge: FaceMovementAndLightServerChallenge) -> FaceLivenessSession.ColorChallenge { + let displayColors = challenge.colorSequences + .map({ color -> FaceLivenessSession.DisplayColor in + + let duration: Double + let shouldScroll: Bool + switch (color.downscrollDuration, color.flatDisplayDuration) { + case (...0, 0...): + duration = Double(color.flatDisplayDuration) + shouldScroll = false + default: + duration = Double(color.downscrollDuration) + shouldScroll = true + } + + precondition( + color.freshnessColor.rgb.count == 3, """ Received invalid freshness colors. Expected 3 values (r, g, b), received: \(color.freshnessColor.rgb.count) """ - ) - - return .init( - rgb: .init( - red: Double(color.freshnessColor.rgb[0]) / 255, - green: Double(color.freshnessColor.rgb[1]) / 255, - blue: Double(color.freshnessColor.rgb[2]) / 255, - _values: color.freshnessColor.rgb - ), - duration: duration, - shouldScroll: shouldScroll - ) - }) - return .init(colors: displayColors) - case .faceMovementChallenge: - return nil - } + ) + + return .init( + rgb: .init( + red: Double(color.freshnessColor.rgb[0]) / 255, + green: Double(color.freshnessColor.rgb[1]) / 255, + blue: Double(color.freshnessColor.rgb[2]) / 255, + _values: color.freshnessColor.rgb + ), + duration: duration, + shouldScroll: shouldScroll + ) + }) + return .init(colors: displayColors) } func sessionConfiguration(from event: ServerSessionInformationEvent) -> FaceLivenessSession.SessionConfiguration { - .init( - colorChallenge: colorChallenge(from: event), - ovalMatchChallenge: ovalChallenge(from: event) - ) + switch event.sessionInformation.challenge.type { + case .faceMovementAndLightChallenge(let challenge): + return .faceMovementAndLight(colorChallenge(from: challenge), ovalChallenge(from: event)) + case .faceMovementChallenge: + return .faceMovement(ovalChallenge(from: event)) + } } -func challengeType(from event: ChallengeEvent) -> Challenge { - .init( - version: event.version, - type: event.type - ) +func challenge(from event: ChallengeEvent) -> Challenge { + switch event.type { + case .faceMovementAndLightChallenge: + return .faceMovementAndLightChallenge(event.version) + case .faceMovementChallenge: + return .faceMovementChallenge(event.version) + } } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift index d399f166d7..be4a93a236 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+Challenge.swift @@ -9,7 +9,7 @@ import Foundation extension FaceLivenessSession { public static let supportedChallenges: [Challenge] = [ - Challenge(version: "2.0.0", type: .faceMovementAndLightChallenge), - Challenge(version: "1.0.0", type: .faceMovementChallenge) + .faceMovementAndLightChallenge("2.0.0"), + .faceMovementChallenge("1.0.0") ] } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift index 7b9b6040eb..f1f74144d7 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift @@ -9,16 +9,8 @@ import Foundation extension FaceLivenessSession { @_spi(PredictionsFaceLiveness) - public struct SessionConfiguration { - public let colorChallenge: ColorChallenge? - public let ovalMatchChallenge: OvalMatchChallenge - - public init( - colorChallenge: ColorChallenge? = nil, - ovalMatchChallenge: OvalMatchChallenge - ) { - self.colorChallenge = colorChallenge - self.ovalMatchChallenge = ovalMatchChallenge - } + public enum SessionConfiguration { + case faceMovement(OvalMatchChallenge) + case faceMovementAndLight(ColorChallenge, OvalMatchChallenge) } } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift index 9732f82b31..63d002702b 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift @@ -6,24 +6,31 @@ // import Foundation +public typealias Version = String @_spi(PredictionsFaceLiveness) -public struct Challenge: Codable { - public let version: String - public let type: ChallengeType - - public init(version: String, type: ChallengeType) { - self.version = version - self.type = type - } +public enum Challenge: Equatable { + case faceMovementChallenge(Version) + case faceMovementAndLightChallenge(Version) public func queryParameterString() -> String { - return self.type.rawValue + "_" + self.version + switch(self) { + case .faceMovementChallenge(let version): + return "FaceMovementChallenge" + "_" + version + case .faceMovementAndLightChallenge(let version): + return "FaceMovementAndLightChallenge" + "_" + version + } } - enum CodingKeys: String, CodingKey { - case version = "Version" - case type = "Type" + public static func ==(lhs: Challenge, rhs: Challenge) -> Bool { + switch (lhs, rhs) { + case (.faceMovementChallenge(let lhsVersion), .faceMovementChallenge(let rhsVersion)): + return lhsVersion == rhsVersion + case (.faceMovementAndLightChallenge(let lhsVersion), .faceMovementAndLightChallenge(let rhsVersion)): + return lhsVersion == rhsVersion + default: + return false + } } } diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift index 00fea17129..ee1a2223d3 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift @@ -156,8 +156,8 @@ public final class FaceLivenessSession: LivenessService { let sessionConfiguration = sessionConfiguration(from: payload) self.serverEventListeners[.challenge]?(sessionConfiguration) } else if let payload = try? JSONDecoder().decode(ChallengeEvent.self, from: message.payload) { - let challengeType = challengeType(from: payload) - self.challengeTypeListeners[.challenge]?(challengeType) + let challenge = challenge(from: payload) + self.challengeTypeListeners[.challenge]?(challenge) } else if (try? JSONDecoder().decode(DisconnectEvent.self, from: message.payload)) != nil { onComplete(.disconnectionEvent) return .stopAndInvalidateSession @@ -179,8 +179,8 @@ public final class FaceLivenessSession: LivenessService { let payload = try JSONDecoder().decode( ChallengeEvent.self, from: message.payload ) - let challengeType = challengeType(from: payload) - challengeTypeListeners[.challenge]?(challengeType) + let challenge = challenge(from: payload) + challengeTypeListeners[.challenge]?(challenge) return .continueToReceive case .sessionInformation: // :event-type ServerSessionInformationEvent diff --git a/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift index c9d041ca33..2f646f9fe8 100644 --- a/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift +++ b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift @@ -13,12 +13,12 @@ import Amplify class LivenessChallengeTests: XCTestCase { func testFaceMovementChallengeQueryParamterString() { - let challenge = Challenge(version: "1.0.0", type: .faceMovementChallenge) + let challenge: Challenge = .faceMovementChallenge("1.0.0") XCTAssertEqual(challenge.queryParameterString(), "FaceMovementChallenge_1.0.0") } func testFaceMovementAndLightChallengeQueryParamterString() { - let challenge = Challenge(version: "2.0.0", type: .faceMovementAndLightChallenge) + let challenge: Challenge = .faceMovementAndLightChallenge("2.0.0") XCTAssertEqual(challenge.queryParameterString(), "FaceMovementAndLightChallenge_2.0.0") } }