Skip to content

feat: add no-light/facemovementonly challenge and back camera support #131

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions HostApp/HostApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
9070FFBD285112B5009867D5 /* HostAppUITests */,
9070FFA1285112B4009867D5 /* Products */,
90215EED291E9FB60050F2AD /* Frameworks */,
A5A9AF5054D0FF13505B212A /* AmplifyConfig */,
);
sourceTree = "<group>";
};
Expand Down Expand Up @@ -213,6 +214,15 @@
path = Model;
sourceTree = "<group>";
};
A5A9AF5054D0FF13505B212A /* AmplifyConfig */ = {
isa = PBXGroup;
children = (
973619242BA378690003A590 /* awsconfiguration.json */,
973619232BA378690003A590 /* amplifyconfiguration.json */,
);
name = AmplifyConfig;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions HostApp/HostApp/Model/LivenessResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
//

import Foundation
@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin

struct LivenessResult: Codable {
let auditImageBytes: String?
let confidenceScore: Double
let isLive: Bool
let challenge: Event?
}

extension LivenessResult: CustomDebugStringConvertible {
Expand All @@ -20,6 +22,17 @@ extension LivenessResult: CustomDebugStringConvertible {
- confidenceScore: \(confidenceScore)
- isLive: \(isLive)
- auditImageBytes: \(auditImageBytes == nil ? "nil" : "<placeholder>")
- challenge: type: \(String(describing: challenge?.type)) + " version: " + \(String(describing: challenge?.version))
"""
}
}

struct Event: Codable {
let version: String
let type: ChallengeType

enum CodingKeys: String, CodingKey {
case version = "Version"
case type = "Type"
}
}
35 changes: 22 additions & 13 deletions HostApp/HostApp/Views/ExampleLivenessView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,27 @@ import SwiftUI
import FaceLiveness

struct ExampleLivenessView: View {
@Binding var isPresented: Bool
@Binding var containerViewState: ContainerViewState
@ObservedObject var viewModel: ExampleLivenessViewModel

init(sessionID: String, isPresented: Binding<Bool>) {
self.viewModel = .init(sessionID: sessionID)
self._isPresented = isPresented
init(sessionID: String, containerViewState: Binding<ContainerViewState>) {
self._containerViewState = containerViewState
if case let .liveness(selectedCamera) = _containerViewState.wrappedValue {
self.viewModel = .init(sessionID: sessionID, presentationState: .liveness(selectedCamera))
} else {
self.viewModel = .init(sessionID: sessionID)
}
}

var body: some View {
switch viewModel.presentationState {
case .liveness:
case .liveness(let camera):
FaceLivenessDetectorView(
sessionID: viewModel.sessionID,
region: "us-east-1",
challengeOptions: .init(faceMovementChallengeOption: FaceMovementChallengeOption(camera: camera)),
isPresented: Binding(
get: { viewModel.presentationState == .liveness },
get: { viewModel.presentationState == .liveness(camera) },
set: { _ in }
),
onCompletion: { result in
Expand All @@ -33,11 +38,11 @@ struct ExampleLivenessView: View {
case .success:
withAnimation { viewModel.presentationState = .result }
case .failure(.sessionNotFound), .failure(.cameraPermissionDenied), .failure(.accessDenied):
viewModel.presentationState = .liveness
isPresented = false
viewModel.presentationState = .liveness(camera)
containerViewState = .startSession
case .failure(.userCancelled):
viewModel.presentationState = .liveness
isPresented = false
viewModel.presentationState = .liveness(camera)
containerViewState = .startSession
case .failure(.sessionTimedOut):
viewModel.presentationState = .error(.sessionTimedOut)
case .failure(.socketClosed):
Expand All @@ -46,6 +51,10 @@ struct ExampleLivenessView: View {
viewModel.presentationState = .error(.countdownFaceTooClose)
case .failure(.invalidSignature):
viewModel.presentationState = .error(.invalidSignature)
case .failure(.faceInOvalMatchExceededTimeLimitError):
viewModel.presentationState = .error(.faceInOvalMatchExceededTimeLimitError)
case .failure(.internalServer):
viewModel.presentationState = .error(.internalServer)
case .failure(.cameraNotAvailable):
viewModel.presentationState = .error(.cameraNotAvailable)
case .failure(.validation):
Expand All @@ -58,11 +67,11 @@ struct ExampleLivenessView: View {
}
}
)
.id(isPresented)
.id(containerViewState)
case .result:
LivenessResultView(
sessionID: viewModel.sessionID,
onTryAgain: { isPresented = false },
onTryAgain: { containerViewState = .startSession },
content: {
LivenessResultContentView(fetchResults: viewModel.fetchLivenessResult)
}
Expand All @@ -71,7 +80,7 @@ struct ExampleLivenessView: View {
case .error(let detectionError):
LivenessResultView(
sessionID: viewModel.sessionID,
onTryAgain: { isPresented = false },
onTryAgain: { containerViewState = .startSession },
content: {
switch detectionError {
case .socketClosed:
Expand Down
7 changes: 4 additions & 3 deletions HostApp/HostApp/Views/ExampleLivenessViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import FaceLiveness
import Amplify

class ExampleLivenessViewModel: ObservableObject {
@Published var presentationState = PresentationState.liveness
@Published var presentationState: PresentationState = .liveness(.front)
let sessionID: String

init(sessionID: String) {
init(sessionID: String, presentationState: PresentationState = .liveness(.front)) {
self.sessionID = sessionID
self.presentationState = presentationState
}

func fetchLivenessResult() async throws -> LivenessResultContentView.Result {
Expand All @@ -30,6 +31,6 @@ class ExampleLivenessViewModel: ObservableObject {
}

enum PresentationState: Equatable {
case liveness, result, error(FaceLivenessDetectionError)
case liveness(LivenessCamera), result, error(FaceLivenessDetectionError)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin

extension LivenessResultContentView {
struct Result {
Expand All @@ -15,6 +16,7 @@ extension LivenessResultContentView {
let valueBackgroundColor: Color
let auditImage: Data?
let isLive: Bool
let challenge: Event?

init(livenessResult: LivenessResult) {
guard livenessResult.confidenceScore > 0 else {
Expand All @@ -24,6 +26,7 @@ extension LivenessResultContentView {
valueBackgroundColor = .clear
auditImage = nil
isLive = false
challenge = nil
return
}
isLive = livenessResult.isLive
Expand All @@ -41,6 +44,7 @@ extension LivenessResultContentView {
auditImage = livenessResult.auditImageBytes.flatMap{
Data(base64Encoded: $0)
}
challenge = livenessResult.challenge
}
}

Expand Down
64 changes: 44 additions & 20 deletions HostApp/HostApp/Views/LivenessResultContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
//

import SwiftUI
@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin

struct LivenessResultContentView: View {
@State var result: Result = .init(livenessResult: .init(auditImageBytes: nil, confidenceScore: -1, isLive: false))
@State var result: Result = .init(livenessResult: .init(auditImageBytes: nil, confidenceScore: -1, isLive: false, challenge: nil))
let fetchResults: () async throws -> Result

var body: some View {
Expand Down Expand Up @@ -67,26 +68,48 @@ struct LivenessResultContentView: View {
}
}

func step(number: Int, text: String) -> some View {
HStack(alignment: .top) {
Text("\(number).")
Text(text)
}
}

@ViewBuilder
private func steps() -> some View {
func step(number: Int, text: String) -> some View {
HStack(alignment: .top) {
Text("\(number).")
Text(text)
switch result.challenge?.type {
case .faceMovementChallenge:
VStack(
alignment: .leading,
spacing: 8
) {
Text("Tips to pass the video check:")
.fontWeight(.semibold)

Text("Remove sunglasses, mask, hat, or anything blocking your face.")
.accessibilityElement(children: .combine)
}
case .faceMovementAndLightChallenge:
VStack(
alignment: .leading,
spacing: 8
) {
Text("Tips to pass the video check:")
.fontWeight(.semibold)

step(number: 1, text: "Avoid very bright lighting conditions, such as direct sunlight.")
.accessibilityElement(children: .combine)

step(number: 2, text: "Remove sunglasses, mask, hat, or anything blocking your face.")
.accessibilityElement(children: .combine)
}
case .none:
VStack(
alignment: .leading,
spacing: 8
) {
EmptyView()
}
}

return VStack(
alignment: .leading,
spacing: 8
) {
Text("Tips to pass the video check:")
.fontWeight(.semibold)

step(number: 1, text: "Avoid very bright lighting conditions, such as direct sunlight.")
.accessibilityElement(children: .combine)

step(number: 2, text: "Remove sunglasses, mask, hat, or anything blocking your face.")
.accessibilityElement(children: .combine)
}
}
}
Expand All @@ -99,7 +122,8 @@ extension LivenessResultContentView {
livenessResult: .init(
auditImageBytes: nil,
confidenceScore: 99.8329,
isLive: true
isLive: true,
challenge: nil
)
)
}
Expand Down
17 changes: 12 additions & 5 deletions HostApp/HostApp/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,32 @@
//

import SwiftUI
import FaceLiveness

struct RootView: View {
@EnvironmentObject var sceneDelegate: SceneDelegate
@State var sessionID = ""
@State var isPresentingContainerView = false
@State var containerViewState = ContainerViewState.startSession

var body: some View {
if isPresentingContainerView {
switch containerViewState {
case .liveness:
ExampleLivenessView(
sessionID: sessionID,
isPresented: $isPresentingContainerView
containerViewState: $containerViewState
)
} else {
case .startSession:
StartSessionView(
sessionID: $sessionID,
isPresentingContainerView: $isPresentingContainerView
containerViewState: $containerViewState
)
.background(Color.dynamicColors(light: .white, dark: .secondarySystemBackground))
.edgesIgnoringSafeArea(.all)
}
}
}

enum ContainerViewState: Hashable {
case liveness(LivenessCamera)
case startSession
}
7 changes: 4 additions & 3 deletions HostApp/HostApp/Views/StartSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct StartSessionView: View {
@EnvironmentObject var sceneDelegate: SceneDelegate
@ObservedObject var viewModel = StartSessionViewModel()
@Binding var sessionID: String
@Binding var isPresentingContainerView: Bool
@Binding var containerViewState: ContainerViewState
@State private var showAlert = false

var body: some View {
Expand All @@ -35,7 +35,8 @@ struct StartSessionView: View {
viewModel.createSession { sessionId, err in
if let sessionId = sessionId {
sessionID = sessionId
isPresentingContainerView = true
// modify camera preference for `FaceMovementChallenge`
containerViewState = .liveness(.front)
}

showAlert = err != nil
Expand All @@ -50,7 +51,7 @@ struct StartSessionView: View {
dismissButton: .default(
Text("OK"),
action: {
isPresentingContainerView = false
containerViewState = .startSession
}
)
)
Expand Down
Loading