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..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..9732f82b31 --- /dev/null +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/Challenge.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +@_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 func queryParameterString() -> String { + return self.type.rawValue + "_" + self.version + } + + enum CodingKeys: String, CodingKey { + case version = "Version" + case type = "Type" + } +} + +@_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 092532d8e3..302df0f30a 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,16 +70,26 @@ 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) { websocket.close(with: code) } - 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) @@ -140,6 +151,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 @@ -157,6 +171,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 .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..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) + } + } +}