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..5baf620dfd 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 { + 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..533169458c 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 { + 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..a0adf15fc6 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,11 +47,10 @@ func ovalChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSes ) } -func colorChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSession.ColorChallenge { - let displayColors = event.sessionInformation.challenge - .faceMovementAndLightChallenge.colorSequences +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) { @@ -52,15 +61,15 @@ func colorChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSe 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) - """ + """ + 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, @@ -72,14 +81,23 @@ func colorChallenge(from event: ServerSessionInformationEvent) -> FaceLivenessSe shouldScroll: shouldScroll ) }) - return .init( - colors: displayColors - ) + 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 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 new file mode 100644 index 0000000000..be4a93a236 --- /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] = [ + .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 139e28f0ed..f1f74144d7 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Model/FaceLivenessSession+SessionConfiguration.swift @@ -9,13 +9,8 @@ import Foundation extension FaceLivenessSession { @_spi(PredictionsFaceLiveness) - public struct SessionConfiguration { - public let colorChallenge: ColorChallenge - public let ovalMatchChallenge: OvalMatchChallenge - - public init(colorChallenge: ColorChallenge, 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/SPI/AWSPredictionsPlugin+Liveness.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift index d2eec8d96e..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 { @@ -48,7 +47,16 @@ extension AWSPredictionsPlugin { extension FaceLivenessSession { @_spi(PredictionsFaceLiveness) public struct Options { - public init() {} + public let attemptCount: Int + public let preCheckViewEnabled: Bool + + public init( + attemptCount: Int, + preCheckViewEnabled: Bool + ) { + self.attemptCount = attemptCount + 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..63d002702b --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +public typealias Version = String + +@_spi(PredictionsFaceLiveness) +public enum Challenge: Equatable { + case faceMovementChallenge(Version) + case faceMovementAndLightChallenge(Version) + + public func queryParameterString() -> String { + switch(self) { + case .faceMovementChallenge(let version): + return "FaceMovementChallenge" + "_" + version + case .faceMovementAndLightChallenge(let version): + return "FaceMovementAndLightChallenge" + "_" + version + } + } + + 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 + } + } +} + +@_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..ee1a2223d3 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? @@ -69,6 +70,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 +81,17 @@ 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, + options: FaceLivenessSession.Options) 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"), + 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"), URLQueryItem(name: "video-height", value: "640"), URLQueryItem(name: "x-amz-user-agent", value: userAgent) @@ -144,6 +155,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 challenge = challenge(from: payload) + self.challengeTypeListeners[.challenge]?(challenge) } else if (try? JSONDecoder().decode(DisconnectEvent.self, from: message.payload)) != nil { onComplete(.disconnectionEvent) return .stopAndInvalidateSession @@ -161,6 +175,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 challenge = challenge(from: payload) + challengeTypeListeners[.challenge]?(challenge) + return .continueToReceive + 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..94037317c7 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSessionRepresentable.swift @@ -19,12 +19,20 @@ 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], + options: FaceLivenessSession.Options) 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) } } diff --git a/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift b/AmplifyPlugins/Predictions/Tests/AWSPredictionsPluginUnitTests/LivenessTests/LivenessChallengeTests.swift new file mode 100644 index 0000000000..2f646f9fe8 --- /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 = .faceMovementChallenge("1.0.0") + XCTAssertEqual(challenge.queryParameterString(), "FaceMovementChallenge_1.0.0") + } + + func testFaceMovementAndLightChallengeQueryParamterString() { + let challenge: Challenge = .faceMovementAndLightChallenge("2.0.0") + 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) + } + } +}