@simasfin/smmf-sdk 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/android/build.gradle +51 -0
- package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.aar +0 -0
- package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.pom +52 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/id/co/onepass/expo/OnePassSignatureExpoView.kt +25 -0
- package/android/src/main/java/id/co/onepass/expo/OnepassExpoModule.kt +134 -0
- package/app.plugin.js +1 -0
- package/build/OnePassSignatureView.d.ts +19 -0
- package/build/OnePassSignatureView.d.ts.map +1 -0
- package/build/OnePassSignatureView.js +15 -0
- package/build/OnePassSignatureView.js.map +1 -0
- package/build/index.d.ts +21 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/OnePassSDK/DesignSystem.swift +139 -0
- package/ios/OnePassSDK/IconView.swift +54 -0
- package/ios/OnePassSDK/IconsaxPath.swift +216 -0
- package/ios/OnePassSDK/Liveness/FaceAnalyzer.swift +114 -0
- package/ios/OnePassSDK/Liveness/FaceLandmarkMetrics.swift +28 -0
- package/ios/OnePassSDK/Liveness/FaceSnapshotMapper.swift +37 -0
- package/ios/OnePassSDK/Liveness/LivenessGate.swift +155 -0
- package/ios/OnePassSDK/Liveness/LivenessImagePreparer.swift +44 -0
- package/ios/OnePassSDK/Liveness/LivenessViewController.swift +724 -0
- package/ios/OnePassSDK/Ocr/CoverFitCrop.swift +37 -0
- package/ios/OnePassSDK/Ocr/KtpFrameOverlayView.swift +98 -0
- package/ios/OnePassSDK/Ocr/KtpImagePreparer.swift +90 -0
- package/ios/OnePassSDK/Ocr/KtpOcrViewController.swift +703 -0
- package/ios/OnePassSDK/Ocr/KtpPhotoInspector.swift +93 -0
- package/ios/OnePassSDK/Ocr/KtpQualityGate.swift +185 -0
- package/ios/OnePassSDK/OnePass.swift +28 -0
- package/ios/OnePassSDK/Resources/hindari_pemakaian.png +0 -0
- package/ios/OnePassSDK/Resources/inter_bold.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_medium.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_regular.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_semibold.ttf +0 -0
- package/ios/OnePassSDK/Resources/ktp_illustration.webp +0 -0
- package/ios/OnePassSDK/Resources/logo_smmf.png +0 -0
- package/ios/OnePassSDK/Resources/placeholder_head.png +0 -0
- package/ios/OnePassSDK/Signature/SignaturePadView.swift +144 -0
- package/ios/OnePassSignatureExpoView.swift +22 -0
- package/ios/OnepassExpo.podspec +23 -0
- package/ios/OnepassExpoModule.swift +77 -0
- package/licenses/Iconsax-MIT.txt +26 -0
- package/licenses/Inter-OFL-1.1.txt +93 -0
- package/package.json +49 -4
- package/plugin/src/inject.js +31 -0
- package/plugin/withOnePass.js +32 -0
- package/scripts/sync-native.js +84 -0
- package/src/OnePassSignatureView.tsx +34 -0
- package/src/index.ts +44 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
public enum LivenessStatus {
|
|
5
|
+
case success
|
|
6
|
+
case failed
|
|
7
|
+
case cancelled
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public struct LivenessResult {
|
|
11
|
+
public let status: LivenessStatus
|
|
12
|
+
public let imageBase64: String?
|
|
13
|
+
public let errorMessage: String?
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public struct LivenessInput {
|
|
17
|
+
public let primaryColorHex: String?
|
|
18
|
+
|
|
19
|
+
public init(primaryColorHex: String? = nil) {
|
|
20
|
+
self.primaryColorHex = primaryColorHex
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public final class LivenessViewController: UIViewController {
|
|
25
|
+
|
|
26
|
+
private enum Metric {
|
|
27
|
+
static let circleSize: CGFloat = 280
|
|
28
|
+
static let shutterSize: CGFloat = 60
|
|
29
|
+
static let buttonHeight: CGFloat = 40
|
|
30
|
+
static let cornerRadius: CGFloat = 6
|
|
31
|
+
static let headImageSize: CGFloat = 280
|
|
32
|
+
static let contentPadding: CGFloat = 24
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private static let holdStillText = "Tahan posisi, lihat ke kamera"
|
|
36
|
+
|
|
37
|
+
private enum Timing {
|
|
38
|
+
static let timerFallbackInterval: TimeInterval = 3
|
|
39
|
+
static let mlGestureTimeout: TimeInterval = 20
|
|
40
|
+
static let captureDelay: TimeInterval = 0.5
|
|
41
|
+
static let photoWindow: TimeInterval = 3
|
|
42
|
+
static let countdownStart = 3
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private let input: LivenessInput
|
|
46
|
+
private let completion: (LivenessResult) -> Void
|
|
47
|
+
private let gate = LivenessGate()
|
|
48
|
+
|
|
49
|
+
private var mlAvailable = true
|
|
50
|
+
private var gateActive = false
|
|
51
|
+
private var captureArmed = false
|
|
52
|
+
private var waitingForOpenEyes = false
|
|
53
|
+
private var captureStarted = false
|
|
54
|
+
|
|
55
|
+
private var pendingWork: [DispatchWorkItem] = []
|
|
56
|
+
|
|
57
|
+
private let session = AVCaptureSession()
|
|
58
|
+
private let photoOutput = AVCapturePhotoOutput()
|
|
59
|
+
private let videoOutput = AVCaptureVideoDataOutput()
|
|
60
|
+
private var analyzer: FaceAnalyzer?
|
|
61
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
62
|
+
|
|
63
|
+
private let contentArea = UIView()
|
|
64
|
+
private let backButton = UIButton(type: .system)
|
|
65
|
+
private let instructionLabel = UILabel()
|
|
66
|
+
private let countdownLabel = UILabel()
|
|
67
|
+
private var circleView: UIView?
|
|
68
|
+
private var shutterButton: UIView?
|
|
69
|
+
private var captureSpinner: UIActivityIndicatorView?
|
|
70
|
+
|
|
71
|
+
private var primaryColor: UIColor {
|
|
72
|
+
if let hex = input.primaryColorHex { return UIColor(hex: hex) }
|
|
73
|
+
return OnePassColor.simascore
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public init(
|
|
77
|
+
input: LivenessInput,
|
|
78
|
+
completion: @escaping (LivenessResult) -> Void
|
|
79
|
+
) {
|
|
80
|
+
self.input = input
|
|
81
|
+
self.completion = completion
|
|
82
|
+
super.init(nibName: nil, bundle: nil)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@available(*, unavailable)
|
|
86
|
+
required init?(coder: NSCoder) { fatalError("tidak dipakai") }
|
|
87
|
+
|
|
88
|
+
public override func viewDidLoad() {
|
|
89
|
+
super.viewDidLoad()
|
|
90
|
+
OnePassFont.register()
|
|
91
|
+
view.backgroundColor = .white
|
|
92
|
+
buildScaffold()
|
|
93
|
+
showIntroScreen()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public override func viewDidLayoutSubviews() {
|
|
97
|
+
super.viewDidLayoutSubviews()
|
|
98
|
+
if let circleView { previewLayer?.frame = circleView.bounds }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public override func viewDidAppear(_ animated: Bool) {
|
|
102
|
+
super.viewDidAppear(animated)
|
|
103
|
+
observeSessionInterruptions()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public override func viewWillDisappear(_ animated: Bool) {
|
|
107
|
+
super.viewWillDisappear(animated)
|
|
108
|
+
NotificationCenter.default.removeObserver(self)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func observeSessionInterruptions() {
|
|
112
|
+
let center = NotificationCenter.default
|
|
113
|
+
center.removeObserver(self)
|
|
114
|
+
center.addObserver(
|
|
115
|
+
self,
|
|
116
|
+
selector: #selector(handleSessionInterruption),
|
|
117
|
+
name: AVCaptureSession.wasInterruptedNotification,
|
|
118
|
+
object: session
|
|
119
|
+
)
|
|
120
|
+
center.addObserver(
|
|
121
|
+
self,
|
|
122
|
+
selector: #selector(handleSessionInterruptionEnded),
|
|
123
|
+
name: AVCaptureSession.interruptionEndedNotification,
|
|
124
|
+
object: session
|
|
125
|
+
)
|
|
126
|
+
center.addObserver(
|
|
127
|
+
self,
|
|
128
|
+
selector: #selector(handleAppWillResignActive),
|
|
129
|
+
name: UIApplication.willResignActiveNotification,
|
|
130
|
+
object: nil
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@objc private func handleSessionInterruption() {
|
|
135
|
+
pauseGesturePass()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@objc private func handleAppWillResignActive() {
|
|
139
|
+
guard circleView != nil else { return }
|
|
140
|
+
pauseGesturePass()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@objc private func handleSessionInterruptionEnded() {
|
|
144
|
+
guard circleView != nil, !captureStarted else { return }
|
|
145
|
+
instructionLabel.text = GateStep.position.instruction
|
|
146
|
+
if !session.isRunning {
|
|
147
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
148
|
+
self?.session.startRunning()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func pauseGesturePass() {
|
|
154
|
+
guard circleView != nil, !captureStarted else { return }
|
|
155
|
+
cancelPendingWork()
|
|
156
|
+
gate.reset()
|
|
157
|
+
gateActive = false
|
|
158
|
+
captureArmed = false
|
|
159
|
+
waitingForOpenEyes = false
|
|
160
|
+
previewLayer?.opacity = 1
|
|
161
|
+
captureSpinner?.isHidden = true
|
|
162
|
+
countdownLabel.isHidden = true
|
|
163
|
+
shutterButton?.isHidden = false
|
|
164
|
+
circleView?.layer.borderColor = UIColor.white.cgColor
|
|
165
|
+
instructionLabel.text = "Verifikasi terhenti, tekan tombol untuk mulai lagi"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func cancelPendingWork() {
|
|
169
|
+
pendingWork.forEach { $0.cancel() }
|
|
170
|
+
pendingWork.removeAll()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private func after(_ delay: TimeInterval, _ block: @escaping () -> Void) {
|
|
174
|
+
let work = DispatchWorkItem(block: block)
|
|
175
|
+
pendingWork.append(work)
|
|
176
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private func finish(_ status: LivenessStatus, imageBase64: String? = nil, message: String? = nil) {
|
|
180
|
+
cancelPendingWork()
|
|
181
|
+
stopCamera()
|
|
182
|
+
completion(LivenessResult(status: status, imageBase64: imageBase64, errorMessage: message))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func buildScaffold() {
|
|
186
|
+
let header = UIView()
|
|
187
|
+
header.backgroundColor = .white
|
|
188
|
+
|
|
189
|
+
let title = UILabel()
|
|
190
|
+
title.text = "Verifikasi Mandiri"
|
|
191
|
+
title.font = OnePassFont.bold(16)
|
|
192
|
+
title.textColor = OnePassColor.red500
|
|
193
|
+
title.translatesAutoresizingMaskIntoConstraints = false
|
|
194
|
+
|
|
195
|
+
backButton.backgroundColor = OnePassColor.gray100
|
|
196
|
+
backButton.layer.cornerRadius = Metric.cornerRadius
|
|
197
|
+
backButton.isHidden = true
|
|
198
|
+
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
199
|
+
backButton.addTarget(self, action: #selector(handleBack), for: .touchUpInside)
|
|
200
|
+
backButton.accessibilityLabel = "Kembali"
|
|
201
|
+
let backIcon = IconView(segments: IconsaxPath.arrowBack, size: 16, color: OnePassColor.gray800)
|
|
202
|
+
backIcon.isUserInteractionEnabled = false
|
|
203
|
+
backButton.addSubview(backIcon)
|
|
204
|
+
|
|
205
|
+
header.addSubview(title)
|
|
206
|
+
header.addSubview(backButton)
|
|
207
|
+
|
|
208
|
+
let topDivider = UIView()
|
|
209
|
+
topDivider.backgroundColor = OnePassColor.gray200
|
|
210
|
+
let bottomDivider = UIView()
|
|
211
|
+
bottomDivider.backgroundColor = OnePassColor.gray200
|
|
212
|
+
|
|
213
|
+
let footer = UIView()
|
|
214
|
+
footer.backgroundColor = OnePassColor.gray50
|
|
215
|
+
let logo = UIImageView(image: .onePass("logo_smmf"))
|
|
216
|
+
logo.contentMode = .scaleAspectFit
|
|
217
|
+
logo.translatesAutoresizingMaskIntoConstraints = false
|
|
218
|
+
footer.addSubview(logo)
|
|
219
|
+
|
|
220
|
+
[header, topDivider, contentArea, bottomDivider, footer].forEach {
|
|
221
|
+
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
222
|
+
view.addSubview($0)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
NSLayoutConstraint.activate([
|
|
226
|
+
header.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
227
|
+
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
228
|
+
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
229
|
+
header.heightAnchor.constraint(equalToConstant: 64),
|
|
230
|
+
|
|
231
|
+
title.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
|
232
|
+
title.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
|
233
|
+
|
|
234
|
+
backButton.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 16),
|
|
235
|
+
backButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
|
236
|
+
backButton.widthAnchor.constraint(equalToConstant: 32),
|
|
237
|
+
backButton.heightAnchor.constraint(equalToConstant: 32),
|
|
238
|
+
backIcon.centerXAnchor.constraint(equalTo: backButton.centerXAnchor),
|
|
239
|
+
backIcon.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
|
|
240
|
+
|
|
241
|
+
topDivider.topAnchor.constraint(equalTo: header.bottomAnchor),
|
|
242
|
+
topDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
243
|
+
topDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
244
|
+
topDivider.heightAnchor.constraint(equalToConstant: 1),
|
|
245
|
+
|
|
246
|
+
contentArea.topAnchor.constraint(equalTo: topDivider.bottomAnchor),
|
|
247
|
+
contentArea.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
248
|
+
contentArea.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
249
|
+
contentArea.bottomAnchor.constraint(equalTo: bottomDivider.topAnchor),
|
|
250
|
+
|
|
251
|
+
bottomDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
252
|
+
bottomDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
253
|
+
bottomDivider.heightAnchor.constraint(equalToConstant: 1),
|
|
254
|
+
bottomDivider.bottomAnchor.constraint(equalTo: footer.topAnchor),
|
|
255
|
+
|
|
256
|
+
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
257
|
+
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
258
|
+
footer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
259
|
+
footer.heightAnchor.constraint(equalToConstant: 54),
|
|
260
|
+
|
|
261
|
+
logo.centerXAnchor.constraint(equalTo: footer.centerXAnchor),
|
|
262
|
+
logo.centerYAnchor.constraint(equalTo: footer.centerYAnchor),
|
|
263
|
+
logo.heightAnchor.constraint(equalToConstant: 30),
|
|
264
|
+
])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@objc private func handleBack() {
|
|
268
|
+
stopCamera()
|
|
269
|
+
showIntroScreen()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func resetContent() -> UIView {
|
|
273
|
+
contentArea.subviews.forEach { $0.removeFromSuperview() }
|
|
274
|
+
let container = UIView()
|
|
275
|
+
container.translatesAutoresizingMaskIntoConstraints = false
|
|
276
|
+
contentArea.addSubview(container)
|
|
277
|
+
NSLayoutConstraint.activate([
|
|
278
|
+
container.topAnchor.constraint(equalTo: contentArea.topAnchor),
|
|
279
|
+
container.leadingAnchor.constraint(equalTo: contentArea.leadingAnchor),
|
|
280
|
+
container.trailingAnchor.constraint(equalTo: contentArea.trailingAnchor),
|
|
281
|
+
container.bottomAnchor.constraint(equalTo: contentArea.bottomAnchor),
|
|
282
|
+
])
|
|
283
|
+
return container
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private func badge(text: String, segments: [IconSegment], iconSize: CGFloat, color: UIColor) -> UIView {
|
|
287
|
+
let container = UIView()
|
|
288
|
+
container.backgroundColor = color
|
|
289
|
+
container.layer.cornerRadius = Metric.cornerRadius
|
|
290
|
+
container.translatesAutoresizingMaskIntoConstraints = false
|
|
291
|
+
|
|
292
|
+
let icon = IconView(segments: segments, size: iconSize, color: .white)
|
|
293
|
+
let label = UILabel()
|
|
294
|
+
label.text = text
|
|
295
|
+
label.font = OnePassFont.regular(12)
|
|
296
|
+
label.textColor = .white
|
|
297
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
298
|
+
|
|
299
|
+
container.addSubview(icon)
|
|
300
|
+
container.addSubview(label)
|
|
301
|
+
NSLayoutConstraint.activate([
|
|
302
|
+
icon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8),
|
|
303
|
+
icon.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
304
|
+
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
|
|
305
|
+
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
|
|
306
|
+
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
|
|
307
|
+
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
|
|
308
|
+
])
|
|
309
|
+
return container
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func showIntroScreen() {
|
|
313
|
+
cancelPendingWork()
|
|
314
|
+
backButton.isHidden = true
|
|
315
|
+
contentArea.backgroundColor = .white
|
|
316
|
+
let container = resetContent()
|
|
317
|
+
|
|
318
|
+
let headContainer = UIView()
|
|
319
|
+
headContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
320
|
+
let headImage = UIImageView(image: .onePass("placeholder_head"))
|
|
321
|
+
headImage.contentMode = .scaleAspectFill
|
|
322
|
+
headImage.clipsToBounds = true
|
|
323
|
+
headImage.layer.cornerRadius = Metric.cornerRadius
|
|
324
|
+
headImage.translatesAutoresizingMaskIntoConstraints = false
|
|
325
|
+
headContainer.addSubview(headImage)
|
|
326
|
+
|
|
327
|
+
let greenBadge = badge(
|
|
328
|
+
text: "Pastikan pencahayaan cukup",
|
|
329
|
+
segments: IconsaxPath.check,
|
|
330
|
+
iconSize: 12,
|
|
331
|
+
color: OnePassColor.greenCheckBadge
|
|
332
|
+
)
|
|
333
|
+
headContainer.addSubview(greenBadge)
|
|
334
|
+
|
|
335
|
+
let warningBadge = badge(
|
|
336
|
+
text: "Hindari penggunaan aksesoris wajah",
|
|
337
|
+
segments: IconsaxPath.close,
|
|
338
|
+
iconSize: 8,
|
|
339
|
+
color: OnePassColor.orangeWarningBadge
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
let warningImage = UIImageView(image: .onePass("hindari_pemakaian"))
|
|
343
|
+
warningImage.contentMode = .scaleAspectFit
|
|
344
|
+
warningImage.translatesAutoresizingMaskIntoConstraints = false
|
|
345
|
+
|
|
346
|
+
let action = OnePassButton(
|
|
347
|
+
title: "Ambil Foto",
|
|
348
|
+
style: .solid(primaryColor),
|
|
349
|
+
cornerRadius: Metric.cornerRadius,
|
|
350
|
+
font: OnePassFont.semibold(16)
|
|
351
|
+
)
|
|
352
|
+
action.translatesAutoresizingMaskIntoConstraints = false
|
|
353
|
+
action.addTarget(self, action: #selector(handleTakePhoto), for: .touchUpInside)
|
|
354
|
+
|
|
355
|
+
let stack = UIStackView(arrangedSubviews: [headContainer, warningBadge, warningImage, action])
|
|
356
|
+
stack.axis = .vertical
|
|
357
|
+
stack.alignment = .center
|
|
358
|
+
stack.spacing = 16
|
|
359
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
360
|
+
container.addSubview(stack)
|
|
361
|
+
|
|
362
|
+
NSLayoutConstraint.activate([
|
|
363
|
+
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
364
|
+
stack.leadingAnchor.constraint(
|
|
365
|
+
equalTo: container.leadingAnchor,
|
|
366
|
+
constant: Metric.contentPadding
|
|
367
|
+
),
|
|
368
|
+
stack.trailingAnchor.constraint(
|
|
369
|
+
equalTo: container.trailingAnchor,
|
|
370
|
+
constant: -Metric.contentPadding
|
|
371
|
+
),
|
|
372
|
+
|
|
373
|
+
headContainer.widthAnchor.constraint(equalToConstant: Metric.headImageSize),
|
|
374
|
+
headContainer.heightAnchor.constraint(equalToConstant: Metric.headImageSize),
|
|
375
|
+
headImage.topAnchor.constraint(equalTo: headContainer.topAnchor),
|
|
376
|
+
headImage.leadingAnchor.constraint(equalTo: headContainer.leadingAnchor),
|
|
377
|
+
headImage.trailingAnchor.constraint(equalTo: headContainer.trailingAnchor),
|
|
378
|
+
headImage.bottomAnchor.constraint(equalTo: headContainer.bottomAnchor),
|
|
379
|
+
greenBadge.topAnchor.constraint(equalTo: headContainer.topAnchor, constant: 16),
|
|
380
|
+
greenBadge.centerXAnchor.constraint(equalTo: headContainer.centerXAnchor),
|
|
381
|
+
greenBadge.widthAnchor.constraint(equalToConstant: 210),
|
|
382
|
+
|
|
383
|
+
warningImage.widthAnchor.constraint(equalToConstant: Metric.headImageSize),
|
|
384
|
+
action.heightAnchor.constraint(equalToConstant: Metric.buttonHeight),
|
|
385
|
+
action.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
|
386
|
+
action.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
|
387
|
+
])
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
@objc private func handleTakePhoto() {
|
|
391
|
+
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
392
|
+
case .authorized:
|
|
393
|
+
showCaptureScreen()
|
|
394
|
+
case .notDetermined:
|
|
395
|
+
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
396
|
+
DispatchQueue.main.async {
|
|
397
|
+
if granted { self?.showCaptureScreen() } else { self?.showPermissionScreen() }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
default:
|
|
401
|
+
showPermissionScreen()
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private func showPermissionScreen() {
|
|
406
|
+
backButton.isHidden = true
|
|
407
|
+
contentArea.backgroundColor = .white
|
|
408
|
+
let container = resetContent()
|
|
409
|
+
|
|
410
|
+
let message = UILabel()
|
|
411
|
+
message.text = "Izin kamera dibutuhkan untuk verifikasi wajah. Aktifkan izin kamera melalui pengaturan aplikasi."
|
|
412
|
+
message.font = OnePassFont.regular(14)
|
|
413
|
+
message.textColor = OnePassColor.gray600
|
|
414
|
+
message.numberOfLines = 0
|
|
415
|
+
message.textAlignment = .center
|
|
416
|
+
|
|
417
|
+
let settings = OnePassButton(
|
|
418
|
+
title: "Buka Pengaturan",
|
|
419
|
+
style: .solid(primaryColor),
|
|
420
|
+
cornerRadius: Metric.cornerRadius,
|
|
421
|
+
font: OnePassFont.semibold(16)
|
|
422
|
+
)
|
|
423
|
+
settings.addTarget(self, action: #selector(openSettings), for: .touchUpInside)
|
|
424
|
+
|
|
425
|
+
let back = OnePassButton(
|
|
426
|
+
title: "Kembali",
|
|
427
|
+
style: .outline(border: OnePassColor.gray200, text: OnePassColor.gray800),
|
|
428
|
+
cornerRadius: Metric.cornerRadius,
|
|
429
|
+
font: OnePassFont.semibold(16)
|
|
430
|
+
)
|
|
431
|
+
back.addTarget(self, action: #selector(handleCancel), for: .touchUpInside)
|
|
432
|
+
|
|
433
|
+
let stack = UIStackView(arrangedSubviews: [message, settings, back])
|
|
434
|
+
stack.axis = .vertical
|
|
435
|
+
stack.spacing = 12
|
|
436
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
437
|
+
container.addSubview(stack)
|
|
438
|
+
NSLayoutConstraint.activate([
|
|
439
|
+
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
440
|
+
stack.leadingAnchor.constraint(
|
|
441
|
+
equalTo: container.leadingAnchor,
|
|
442
|
+
constant: Metric.contentPadding
|
|
443
|
+
),
|
|
444
|
+
stack.trailingAnchor.constraint(
|
|
445
|
+
equalTo: container.trailingAnchor,
|
|
446
|
+
constant: -Metric.contentPadding
|
|
447
|
+
),
|
|
448
|
+
settings.heightAnchor.constraint(equalToConstant: Metric.buttonHeight),
|
|
449
|
+
back.heightAnchor.constraint(equalToConstant: Metric.buttonHeight),
|
|
450
|
+
])
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
@objc private func openSettings() {
|
|
454
|
+
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
|
455
|
+
UIApplication.shared.open(url)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@objc private func handleCancel() {
|
|
459
|
+
finish(.cancelled)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func showCaptureScreen() {
|
|
463
|
+
cancelPendingWork()
|
|
464
|
+
gate.reset()
|
|
465
|
+
gateActive = false
|
|
466
|
+
captureArmed = false
|
|
467
|
+
waitingForOpenEyes = false
|
|
468
|
+
captureStarted = false
|
|
469
|
+
backButton.isHidden = false
|
|
470
|
+
contentArea.backgroundColor = .black
|
|
471
|
+
let container = resetContent()
|
|
472
|
+
|
|
473
|
+
instructionLabel.text = GateStep.position.instruction
|
|
474
|
+
instructionLabel.font = OnePassFont.medium(14)
|
|
475
|
+
instructionLabel.textColor = .white
|
|
476
|
+
instructionLabel.textAlignment = .center
|
|
477
|
+
instructionLabel.numberOfLines = 0
|
|
478
|
+
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
479
|
+
|
|
480
|
+
let circle = UIView()
|
|
481
|
+
circle.backgroundColor = .white
|
|
482
|
+
circle.layer.cornerRadius = Metric.circleSize / 2
|
|
483
|
+
circle.layer.borderWidth = 4
|
|
484
|
+
circle.layer.borderColor = UIColor.white.cgColor
|
|
485
|
+
circle.clipsToBounds = true
|
|
486
|
+
circle.translatesAutoresizingMaskIntoConstraints = false
|
|
487
|
+
circleView = circle
|
|
488
|
+
|
|
489
|
+
countdownLabel.text = nil
|
|
490
|
+
countdownLabel.font = OnePassFont.bold(60)
|
|
491
|
+
countdownLabel.textColor = .white
|
|
492
|
+
countdownLabel.isHidden = true
|
|
493
|
+
countdownLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
494
|
+
|
|
495
|
+
let spinner = UIActivityIndicatorView(style: .large)
|
|
496
|
+
spinner.color = OnePassColor.greenSpinner
|
|
497
|
+
spinner.isHidden = true
|
|
498
|
+
spinner.translatesAutoresizingMaskIntoConstraints = false
|
|
499
|
+
captureSpinner = spinner
|
|
500
|
+
|
|
501
|
+
let shutter = UIView()
|
|
502
|
+
shutter.backgroundColor = .white
|
|
503
|
+
shutter.layer.cornerRadius = Metric.shutterSize / 2
|
|
504
|
+
shutter.layer.borderWidth = 4
|
|
505
|
+
shutter.layer.borderColor = OnePassColor.shutterBorder.cgColor
|
|
506
|
+
shutter.translatesAutoresizingMaskIntoConstraints = false
|
|
507
|
+
shutter.isAccessibilityElement = true
|
|
508
|
+
shutter.accessibilityTraits = .button
|
|
509
|
+
shutter.accessibilityLabel = "Mulai verifikasi wajah"
|
|
510
|
+
shutter.addGestureRecognizer(
|
|
511
|
+
UITapGestureRecognizer(target: self, action: #selector(startCountdown))
|
|
512
|
+
)
|
|
513
|
+
shutterButton = shutter
|
|
514
|
+
|
|
515
|
+
[instructionLabel, circle, countdownLabel, spinner, shutter].forEach(container.addSubview)
|
|
516
|
+
|
|
517
|
+
NSLayoutConstraint.activate([
|
|
518
|
+
instructionLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
|
|
519
|
+
instructionLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
520
|
+
instructionLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
521
|
+
|
|
522
|
+
circle.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
523
|
+
circle.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
524
|
+
circle.widthAnchor.constraint(equalToConstant: Metric.circleSize),
|
|
525
|
+
circle.heightAnchor.constraint(equalToConstant: Metric.circleSize),
|
|
526
|
+
|
|
527
|
+
countdownLabel.centerXAnchor.constraint(equalTo: circle.centerXAnchor),
|
|
528
|
+
countdownLabel.centerYAnchor.constraint(equalTo: circle.centerYAnchor),
|
|
529
|
+
spinner.centerXAnchor.constraint(equalTo: circle.centerXAnchor),
|
|
530
|
+
spinner.centerYAnchor.constraint(equalTo: circle.centerYAnchor),
|
|
531
|
+
|
|
532
|
+
shutter.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
533
|
+
shutter.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -24),
|
|
534
|
+
shutter.widthAnchor.constraint(equalToConstant: Metric.shutterSize),
|
|
535
|
+
shutter.heightAnchor.constraint(equalToConstant: Metric.shutterSize),
|
|
536
|
+
])
|
|
537
|
+
|
|
538
|
+
startCamera(in: circle)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private func startCamera(in circle: UIView) {
|
|
542
|
+
guard
|
|
543
|
+
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front),
|
|
544
|
+
let deviceInput = try? AVCaptureDeviceInput(device: device)
|
|
545
|
+
else {
|
|
546
|
+
finish(.failed, message: "Kamera depan tidak tersedia di perangkat ini")
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
session.beginConfiguration()
|
|
550
|
+
session.inputs.forEach(session.removeInput)
|
|
551
|
+
session.outputs.forEach(session.removeOutput)
|
|
552
|
+
if session.canAddInput(deviceInput) { session.addInput(deviceInput) }
|
|
553
|
+
if session.canAddOutput(photoOutput) { session.addOutput(photoOutput) }
|
|
554
|
+
let faceAnalyzer = FaceAnalyzer { [weak self] snapshot in
|
|
555
|
+
DispatchQueue.main.async { self?.handleGateInput(snapshot) }
|
|
556
|
+
}
|
|
557
|
+
analyzer = faceAnalyzer
|
|
558
|
+
videoOutput.setSampleBufferDelegate(
|
|
559
|
+
faceAnalyzer,
|
|
560
|
+
queue: DispatchQueue(label: "id.co.onepass.face-analyzer")
|
|
561
|
+
)
|
|
562
|
+
videoOutput.alwaysDiscardsLateVideoFrames = true
|
|
563
|
+
if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) }
|
|
564
|
+
session.commitConfiguration()
|
|
565
|
+
|
|
566
|
+
let layer = AVCaptureVideoPreviewLayer(session: session)
|
|
567
|
+
layer.videoGravity = .resizeAspectFill
|
|
568
|
+
layer.frame = circle.bounds
|
|
569
|
+
circle.layer.insertSublayer(layer, at: 0)
|
|
570
|
+
previewLayer = layer
|
|
571
|
+
|
|
572
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
573
|
+
self?.session.startRunning()
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private func stopCamera() {
|
|
578
|
+
if session.isRunning { session.stopRunning() }
|
|
579
|
+
videoOutput.setSampleBufferDelegate(nil, queue: nil)
|
|
580
|
+
analyzer = nil
|
|
581
|
+
previewLayer?.removeFromSuperlayer()
|
|
582
|
+
previewLayer = nil
|
|
583
|
+
circleView = nil
|
|
584
|
+
captureSpinner = nil
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
@objc private func startCountdown() {
|
|
588
|
+
guard !gateActive, !captureStarted else { return }
|
|
589
|
+
shutterButton?.isHidden = true
|
|
590
|
+
countdown(Timing.countdownStart)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private func countdown(_ value: Int) {
|
|
594
|
+
if value == 0 {
|
|
595
|
+
countdownLabel.isHidden = true
|
|
596
|
+
gateActive = true
|
|
597
|
+
scheduleTimerFallback()
|
|
598
|
+
after(Timing.mlGestureTimeout) { [weak self] in
|
|
599
|
+
guard let self, !self.captureStarted, self.gate.step != .ready else { return }
|
|
600
|
+
self.mlAvailable = false
|
|
601
|
+
}
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
countdownLabel.isHidden = false
|
|
605
|
+
countdownLabel.text = "\(value)"
|
|
606
|
+
after(1) { [weak self] in self?.countdown(value - 1) }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private func handleGateInput(_ snapshot: FaceSnapshot?) {
|
|
610
|
+
guard mlAvailable, gateActive, !captureStarted else { return }
|
|
611
|
+
if captureArmed {
|
|
612
|
+
if waitingForOpenEyes, gate.readyForPhoto(snapshot) {
|
|
613
|
+
capturePhoto()
|
|
614
|
+
} else {
|
|
615
|
+
instructionLabel.text = gate.feedback(for: snapshot) ?? Self.holdStillText
|
|
616
|
+
}
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
gate.process(snapshot)
|
|
620
|
+
if gate.step == .ready {
|
|
621
|
+
armCapture()
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
let feedback = gate.feedback(for: snapshot)
|
|
625
|
+
instructionLabel.text = feedback ?? gate.step.instruction
|
|
626
|
+
circleView?.layer.borderColor = (feedback == nil
|
|
627
|
+
? OnePassColor.greenPhotoBorder
|
|
628
|
+
: OnePassColor.orangeWarningBadge).cgColor
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private func scheduleTimerFallback() {
|
|
632
|
+
after(Timing.timerFallbackInterval) { [weak self] in
|
|
633
|
+
guard let self, !self.captureStarted, !self.captureArmed else { return }
|
|
634
|
+
if !self.mlAvailable {
|
|
635
|
+
self.gate.advanceByTimer()
|
|
636
|
+
self.instructionLabel.text = self.gate.step.instruction
|
|
637
|
+
if self.gate.step == .ready { self.armCapture() } else { self.scheduleTimerFallback() }
|
|
638
|
+
} else if self.gate.step != .ready {
|
|
639
|
+
self.scheduleTimerFallback()
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private func armCapture() {
|
|
645
|
+
guard !captureArmed else { return }
|
|
646
|
+
captureArmed = true
|
|
647
|
+
instructionLabel.text = Self.holdStillText
|
|
648
|
+
circleView?.layer.borderColor = OnePassColor.greenPhotoBorder.cgColor
|
|
649
|
+
after(Timing.captureDelay) { [weak self] in
|
|
650
|
+
guard let self else { return }
|
|
651
|
+
if self.mlAvailable { self.waitingForOpenEyes = true } else { self.capturePhoto() }
|
|
652
|
+
}
|
|
653
|
+
after(Timing.captureDelay + Timing.photoWindow) { [weak self] in
|
|
654
|
+
guard let self, !self.captureStarted else { return }
|
|
655
|
+
if self.mlAvailable { self.restartAfterMissedFace() } else { self.capturePhoto() }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private func restartAfterMissedFace() {
|
|
660
|
+
showCaptureScreen()
|
|
661
|
+
showToast("Wajah tidak terdeteksi, silakan ulangi")
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private func capturePhoto() {
|
|
665
|
+
guard !captureStarted else { return }
|
|
666
|
+
captureStarted = true
|
|
667
|
+
waitingForOpenEyes = false
|
|
668
|
+
instructionLabel.text = "Sedang memproses, pastikan wajah terlihat jelas"
|
|
669
|
+
circleView?.layer.borderColor = UIColor.white.cgColor
|
|
670
|
+
previewLayer?.opacity = 0.3
|
|
671
|
+
captureSpinner?.isHidden = false
|
|
672
|
+
captureSpinner?.startAnimating()
|
|
673
|
+
photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
func deliverCapturedImage(_ image: UIImage) {
|
|
677
|
+
let prepared = LivenessImagePreparer.prepareForUpload(image)
|
|
678
|
+
finish(.success, imageBase64: prepared.base64)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private func showToast(_ text: String) {
|
|
682
|
+
let toast = UILabel()
|
|
683
|
+
toast.text = text
|
|
684
|
+
toast.font = OnePassFont.regular(14)
|
|
685
|
+
toast.textColor = .white
|
|
686
|
+
toast.backgroundColor = UIColor.black.withAlphaComponent(0.85)
|
|
687
|
+
toast.textAlignment = .center
|
|
688
|
+
toast.numberOfLines = 0
|
|
689
|
+
toast.layer.cornerRadius = 8
|
|
690
|
+
toast.layer.masksToBounds = true
|
|
691
|
+
toast.translatesAutoresizingMaskIntoConstraints = false
|
|
692
|
+
view.addSubview(toast)
|
|
693
|
+
NSLayoutConstraint.activate([
|
|
694
|
+
toast.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
|
|
695
|
+
toast.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
|
|
696
|
+
toast.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -80),
|
|
697
|
+
toast.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
|
698
|
+
])
|
|
699
|
+
after(3) { toast.removeFromSuperview() }
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
extension LivenessViewController: AVCapturePhotoCaptureDelegate {
|
|
704
|
+
public func photoOutput(
|
|
705
|
+
_ output: AVCapturePhotoOutput,
|
|
706
|
+
didFinishProcessingPhoto photo: AVCapturePhoto,
|
|
707
|
+
error: Error?
|
|
708
|
+
) {
|
|
709
|
+
guard
|
|
710
|
+
error == nil,
|
|
711
|
+
let data = photo.fileDataRepresentation(),
|
|
712
|
+
let image = UIImage(data: data)
|
|
713
|
+
else {
|
|
714
|
+
DispatchQueue.main.async { [weak self] in
|
|
715
|
+
self?.showToast("Gagal mengambil foto, coba lagi")
|
|
716
|
+
self?.showCaptureScreen()
|
|
717
|
+
}
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
DispatchQueue.main.async { [weak self] in
|
|
721
|
+
self?.deliverCapturedImage(image)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|