Skip to content

Commit 074d564

Browse files
committed
feat(liveness): add session + websocket client (#134)
1 parent b2e1d44 commit 074d564

File tree

4 files changed

+282
-0
lines changed

4 files changed

+282
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Foundation
9+
import Amplify
10+
11+
@_spi(PredictionsFaceLiveness)
12+
public final class FaceLivenessSession: LivenessService {
13+
let websocket: WebSocketSession
14+
let eventStreamEncoder: EventStream.Encoder
15+
let eventStreamDecoder: EventStream.Decoder
16+
let signer: SigV4Signer
17+
let baseURL: URL
18+
var serverEventListeners: [LivenessEventKind.Server: (FaceLivenessSession.SessionConfiguration) -> Void] = [:]
19+
var onComplete: (ServerDisconnection) -> Void = { _ in }
20+
21+
init(
22+
websocket: WebSocketSession,
23+
signer: SigV4Signer,
24+
baseURL: URL
25+
) {
26+
self.eventStreamEncoder = EventStream.Encoder()
27+
self.eventStreamDecoder = EventStream.Decoder()
28+
self.signer = signer
29+
self.baseURL = baseURL
30+
31+
self.websocket = websocket
32+
33+
websocket.onMessageReceived { [weak self] result in
34+
self?.receive(result: result) ?? false
35+
}
36+
37+
websocket.onSocketClosed { [weak self] closeCode in
38+
self?.onComplete(.unexpectedClosure(closeCode))
39+
}
40+
}
41+
42+
public func register(
43+
onComplete: @escaping (ServerDisconnection) -> Void
44+
) {
45+
self.onComplete = onComplete
46+
}
47+
48+
public func register(
49+
listener: @escaping (FaceLivenessSession.SessionConfiguration) -> Void,
50+
on event: LivenessEventKind.Server
51+
) {
52+
serverEventListeners[event] = listener
53+
}
54+
55+
public func initializeLivenessStream(withSessionID sessionID: String, userAgent: String = "") throws {
56+
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
57+
components?.queryItems = [
58+
URLQueryItem(name: "session-id", value: sessionID),
59+
URLQueryItem(name: "challenge-versions", value: "FaceMovementAndLightChallenge_1.0.0"),
60+
URLQueryItem(name: "video-width", value: "480"),
61+
URLQueryItem(name: "video-height", value: "640"),
62+
URLQueryItem(name: "x-amz-user-agent", value: userAgent)
63+
]
64+
65+
guard let url = components?.url
66+
else { throw FaceLivenessSessionError.invalidURL }
67+
68+
let signedConnectionURL = signer.sign(url: url)
69+
websocket.open(url: signedConnectionURL)
70+
}
71+
72+
public func send<T>(
73+
_ event: LivenessEvent<T>,
74+
eventDate: () -> Date = Date.init
75+
) {
76+
let encodedPayload = eventStreamEncoder.encode(
77+
payload: event.payload,
78+
headers: [
79+
":content-type": .string("application/json"),
80+
":event-type": .string(event.eventTypeHeader),
81+
":message-type": .string("event")
82+
]
83+
)
84+
85+
let eventDate = eventDate()
86+
87+
let signedPayload = signer.signWithPreviousSignature(
88+
payload: encodedPayload,
89+
dateHeader: (key: ":date", value: eventDate)
90+
)
91+
92+
let encodedEvent = eventStreamEncoder.encode(
93+
payload: encodedPayload,
94+
headers: [
95+
":date": .timestamp(eventDate),
96+
":chunk-signature": .data(signedPayload)
97+
]
98+
)
99+
100+
websocket.send(
101+
message: .data(encodedEvent),
102+
onError: { error in }
103+
)
104+
}
105+
106+
private func fallbackDecoding(_ message: EventStream.Message) -> Bool {
107+
// We only care about two events above.
108+
// Just in case the header value changes (it shouldn't)
109+
// We'll try to decode each of these events
110+
if let payload = try? JSONDecoder().decode(ServerSessionInformationEvent.self, from: message.payload) {
111+
let sessionConfiguration = sessionConfiguration(from: payload)
112+
self.serverEventListeners[.challenge]?(sessionConfiguration)
113+
} else if (try? JSONDecoder().decode(DisconnectEvent.self, from: message.payload)) != nil {
114+
onComplete(.disconnectionEvent)
115+
return false
116+
}
117+
return true
118+
}
119+
120+
private func receive(result: Result<URLSessionWebSocketTask.Message, Error>) -> Bool {
121+
switch result {
122+
case .success(.data(let data)):
123+
124+
do {
125+
let message = try self.eventStreamDecoder.decode(data: data)
126+
guard let eventType = message.headers.first(where: { $0.name == ":event-type" })
127+
else { return fallbackDecoding(message) }
128+
129+
switch eventType.value {
130+
case "ServerSessionInformationEvent":
131+
// :event-type ServerSessionInformationEvent
132+
let payload = try JSONDecoder().decode(
133+
ServerSessionInformationEvent.self, from: message.payload
134+
)
135+
let sessionConfiguration = sessionConfiguration(from: payload)
136+
serverEventListeners[.challenge]?(sessionConfiguration)
137+
case "DisconnectionEvent":
138+
// :event-type DisconnectionEvent
139+
onComplete(.disconnectionEvent)
140+
return false
141+
default:
142+
return true
143+
}
144+
} catch {}
145+
return true
146+
case .success:
147+
return true
148+
case .failure:
149+
return true
150+
}
151+
}
152+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Foundation
9+
import Amplify
10+
11+
@_spi(PredictionsFaceLiveness)
12+
public protocol LivenessService {
13+
func send<T>(
14+
_ event: LivenessEvent<T>,
15+
eventDate: () -> Date
16+
)
17+
18+
func register(onComplete: @escaping (ServerDisconnection) -> Void)
19+
20+
func initializeLivenessStream(withSessionID sessionID: String, userAgent: String) throws
21+
22+
func register(
23+
listener: @escaping (FaceLivenessSession.SessionConfiguration) -> Void,
24+
on event: LivenessEventKind.Server
25+
)
26+
}
27+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Foundation
9+
10+
@_spi(PredictionsFaceLiveness)
11+
public enum ServerDisconnection {
12+
case disconnectionEvent
13+
case unexpectedClosure(URLSessionWebSocketTask.CloseCode)
14+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Foundation
9+
10+
final class WebSocketSession {
11+
private let urlSessionWebSocketDelegate: Delegate
12+
private let session: URLSession
13+
private var task: URLSessionWebSocketTask?
14+
private var receiveMessage: ((Result<URLSessionWebSocketTask.Message, Error>) -> Bool)?
15+
private var onSocketClosed: ((URLSessionWebSocketTask.CloseCode) -> Void)?
16+
17+
init() {
18+
self.urlSessionWebSocketDelegate = Delegate()
19+
self.session = URLSession(
20+
configuration: .default,
21+
delegate: urlSessionWebSocketDelegate,
22+
delegateQueue: .init()
23+
)
24+
}
25+
26+
func onMessageReceived(_ receive: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Bool) {
27+
self.receiveMessage = receive
28+
}
29+
30+
func onSocketClosed(_ onClose: @escaping (URLSessionWebSocketTask.CloseCode) -> Void) {
31+
urlSessionWebSocketDelegate.onClose = onClose
32+
}
33+
34+
func receive(shouldContinue: Bool) {
35+
guard shouldContinue else { return }
36+
task?.receive(completionHandler: { [weak self] result in
37+
if let shouldContinue = self?.receiveMessage?(result) {
38+
self?.receive(shouldContinue: shouldContinue)
39+
}
40+
})
41+
}
42+
43+
func open(url: URL) {
44+
var request = URLRequest(url: url)
45+
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
46+
task = session.webSocketTask(with: request)
47+
receive(shouldContinue: true)
48+
task?.resume()
49+
}
50+
51+
func close(with code: URLSessionWebSocketTask.CloseCode, reason: Data = .init()) {
52+
task?.cancel(with: code, reason: reason)
53+
}
54+
55+
func send(
56+
message: URLSessionWebSocketTask.Message,
57+
onError: @escaping (Error) -> Void
58+
) {
59+
task?.send(
60+
message,
61+
completionHandler: { error in
62+
guard let error else { return }
63+
onError(error)
64+
}
65+
)
66+
}
67+
68+
final class Delegate: NSObject, URLSessionWebSocketDelegate {
69+
var onClose: (URLSessionWebSocketTask.CloseCode) -> Void = { _ in }
70+
var onOpen: () -> Void = {}
71+
72+
func urlSession(
73+
_ session: URLSession,
74+
webSocketTask: URLSessionWebSocketTask,
75+
didOpenWithProtocol protocol: String?
76+
) {
77+
onOpen()
78+
}
79+
80+
func urlSession(
81+
_ session: URLSession,
82+
webSocketTask: URLSessionWebSocketTask,
83+
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
84+
reason: Data?
85+
) {
86+
onClose(closeCode)
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)