diff --git a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/SPI/AWSPredictionsPlugin+Liveness.swift index 9919b1a6bd..4191d76b85 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 d9c21ecf88..15720105b9 100644 --- a/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift +++ b/AmplifyPlugins/Predictions/AWSPredictionsPlugin/Liveness/Service/FaceLivenessSession.swift @@ -18,7 +18,6 @@ public final class FaceLivenessSession: LivenessService { var serverEventListeners: [LivenessEventKind.Server: (FaceLivenessSession.SessionConfiguration) -> Void] = [:] var challengeTypeListeners: [LivenessEventKind.Server: (Challenge) -> Void] = [:] var onComplete: (ServerDisconnection) -> Void = { _ in } - let options: FaceLivenessSession.Options private let livenessServiceDispatchQueue = DispatchQueue( label: "com.amazon.aws.amplify.liveness.service", @@ -28,14 +27,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 @@ -73,14 +70,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) + } + } +}