@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.
Files changed (53) hide show
  1. package/README.md +84 -2
  2. package/android/build.gradle +51 -0
  3. package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.aar +0 -0
  4. package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.pom +52 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/id/co/onepass/expo/OnePassSignatureExpoView.kt +25 -0
  7. package/android/src/main/java/id/co/onepass/expo/OnepassExpoModule.kt +134 -0
  8. package/app.plugin.js +1 -0
  9. package/build/OnePassSignatureView.d.ts +19 -0
  10. package/build/OnePassSignatureView.d.ts.map +1 -0
  11. package/build/OnePassSignatureView.js +15 -0
  12. package/build/OnePassSignatureView.js.map +1 -0
  13. package/build/index.d.ts +21 -0
  14. package/build/index.d.ts.map +1 -0
  15. package/build/index.js +10 -0
  16. package/build/index.js.map +1 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/OnePassSDK/DesignSystem.swift +139 -0
  19. package/ios/OnePassSDK/IconView.swift +54 -0
  20. package/ios/OnePassSDK/IconsaxPath.swift +216 -0
  21. package/ios/OnePassSDK/Liveness/FaceAnalyzer.swift +114 -0
  22. package/ios/OnePassSDK/Liveness/FaceLandmarkMetrics.swift +28 -0
  23. package/ios/OnePassSDK/Liveness/FaceSnapshotMapper.swift +37 -0
  24. package/ios/OnePassSDK/Liveness/LivenessGate.swift +155 -0
  25. package/ios/OnePassSDK/Liveness/LivenessImagePreparer.swift +44 -0
  26. package/ios/OnePassSDK/Liveness/LivenessViewController.swift +724 -0
  27. package/ios/OnePassSDK/Ocr/CoverFitCrop.swift +37 -0
  28. package/ios/OnePassSDK/Ocr/KtpFrameOverlayView.swift +98 -0
  29. package/ios/OnePassSDK/Ocr/KtpImagePreparer.swift +90 -0
  30. package/ios/OnePassSDK/Ocr/KtpOcrViewController.swift +703 -0
  31. package/ios/OnePassSDK/Ocr/KtpPhotoInspector.swift +93 -0
  32. package/ios/OnePassSDK/Ocr/KtpQualityGate.swift +185 -0
  33. package/ios/OnePassSDK/OnePass.swift +28 -0
  34. package/ios/OnePassSDK/Resources/hindari_pemakaian.png +0 -0
  35. package/ios/OnePassSDK/Resources/inter_bold.ttf +0 -0
  36. package/ios/OnePassSDK/Resources/inter_medium.ttf +0 -0
  37. package/ios/OnePassSDK/Resources/inter_regular.ttf +0 -0
  38. package/ios/OnePassSDK/Resources/inter_semibold.ttf +0 -0
  39. package/ios/OnePassSDK/Resources/ktp_illustration.webp +0 -0
  40. package/ios/OnePassSDK/Resources/logo_smmf.png +0 -0
  41. package/ios/OnePassSDK/Resources/placeholder_head.png +0 -0
  42. package/ios/OnePassSDK/Signature/SignaturePadView.swift +144 -0
  43. package/ios/OnePassSignatureExpoView.swift +22 -0
  44. package/ios/OnepassExpo.podspec +23 -0
  45. package/ios/OnepassExpoModule.swift +77 -0
  46. package/licenses/Iconsax-MIT.txt +26 -0
  47. package/licenses/Inter-OFL-1.1.txt +93 -0
  48. package/package.json +49 -4
  49. package/plugin/src/inject.js +31 -0
  50. package/plugin/withOnePass.js +32 -0
  51. package/scripts/sync-native.js +84 -0
  52. package/src/OnePassSignatureView.tsx +34 -0
  53. 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
+ }