@succinctlabs/react-native-zcam1 0.3.0 → 0.4.0-alpha.1

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 (127) hide show
  1. package/Zcam1Sdk.podspec +2 -2
  2. package/android/CMakeLists.txt +114 -0
  3. package/android/build.gradle +213 -0
  4. package/android/cpp-adapter-proving.cpp +35 -0
  5. package/android/cpp-adapter.cpp +35 -0
  6. package/android/src/main/AndroidManifest.xml +5 -0
  7. package/android/src/main/java/com/succinctlabs/zcam1sdk/Zcam1CaptureModule.kt +156 -0
  8. package/android/src/main/java/com/succinctlabs/zcam1sdk/Zcam1CapturePackage.kt +38 -0
  9. package/android/src/main/java/com/succinctlabs/zcam1sdk/Zcam1ProvingModule.kt +43 -0
  10. package/android/src/main/java/com/succinctlabs/zcam1sdk/Zcam1ProvingPackage.kt +34 -0
  11. package/android/src/main/java/com/succinctlabs/zcam1sdk/Zcam1SdkModule.kt +43 -0
  12. package/android/src/main/java/com/succinctlabs/zcam1sdk/Zcam1SdkPackage.kt +34 -0
  13. package/android/src/main/java/com/succinctlabs/zcam1sdk/camera/CameraUtils.kt +80 -0
  14. package/android/src/main/java/com/succinctlabs/zcam1sdk/camera/Zcam1CameraService.kt +588 -0
  15. package/android/src/main/java/com/succinctlabs/zcam1sdk/camera/Zcam1CameraView.kt +107 -0
  16. package/android/src/main/java/com/succinctlabs/zcam1sdk/camera/Zcam1CameraViewManager.kt +33 -0
  17. package/android/src/main/java/com/succinctlabs/zcam1sdk/camera/Zcam1OrientationManager.kt +73 -0
  18. package/cpp/generated/zcam1_c2pa_utils.cpp +170 -365
  19. package/cpp/generated/zcam1_c2pa_utils.hpp +0 -4
  20. package/cpp/generated/zcam1_certs_utils.cpp +121 -250
  21. package/cpp/generated/zcam1_common.cpp +1871 -0
  22. package/cpp/generated/zcam1_common.hpp +52 -0
  23. package/cpp/generated/zcam1_verify_utils.cpp +138 -265
  24. package/cpp/generated/zcam1_verify_utils.hpp +2 -2
  25. package/cpp/proving/generated/zcam1_common.cpp +1871 -0
  26. package/cpp/proving/generated/zcam1_common.hpp +52 -0
  27. package/cpp/proving/generated/zcam1_proving_utils.cpp +355 -417
  28. package/cpp/proving/generated/zcam1_proving_utils.hpp +13 -17
  29. package/cpp/proving/zcam1-proving.cpp +2 -0
  30. package/cpp/zcam1-sdk.cpp +2 -0
  31. package/lib/module/bindings.js +4 -0
  32. package/lib/module/bindings.js.map +1 -1
  33. package/lib/module/camera.js +71 -13
  34. package/lib/module/camera.js.map +1 -1
  35. package/lib/module/capture.js +115 -38
  36. package/lib/module/capture.js.map +1 -1
  37. package/lib/module/common.js +18 -2
  38. package/lib/module/common.js.map +1 -1
  39. package/lib/module/generated/zcam1_c2pa_utils-ffi.js +4 -0
  40. package/lib/module/generated/zcam1_c2pa_utils-ffi.js.map +1 -1
  41. package/lib/module/generated/zcam1_c2pa_utils.js +117 -9
  42. package/lib/module/generated/zcam1_c2pa_utils.js.map +1 -1
  43. package/lib/module/generated/zcam1_certs_utils-ffi.js +4 -0
  44. package/lib/module/generated/zcam1_certs_utils-ffi.js.map +1 -1
  45. package/lib/module/generated/zcam1_certs_utils.js +6 -2
  46. package/lib/module/generated/zcam1_certs_utils.js.map +1 -1
  47. package/lib/module/generated/zcam1_common-ffi.js +47 -0
  48. package/lib/module/generated/zcam1_common-ffi.js.map +1 -0
  49. package/lib/module/generated/zcam1_common.js +60 -0
  50. package/lib/module/generated/zcam1_common.js.map +1 -0
  51. package/lib/module/generated/zcam1_proving_utils-ffi.js +4 -0
  52. package/lib/module/generated/zcam1_proving_utils-ffi.js.map +1 -1
  53. package/lib/module/generated/zcam1_proving_utils.js +53 -46
  54. package/lib/module/generated/zcam1_proving_utils.js.map +1 -1
  55. package/lib/module/generated/zcam1_verify_utils-ffi.js +4 -0
  56. package/lib/module/generated/zcam1_verify_utils-ffi.js.map +1 -1
  57. package/lib/module/generated/zcam1_verify_utils.js +70 -22
  58. package/lib/module/generated/zcam1_verify_utils.js.map +1 -1
  59. package/lib/module/index.js +1 -1
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/module/proving/NativeZcam1Proving.js +1 -1
  62. package/lib/module/proving/index.js +1 -1
  63. package/lib/module/proving/index.js.map +1 -1
  64. package/lib/module/proving/prove.js +14 -8
  65. package/lib/module/proving/prove.js.map +1 -1
  66. package/lib/module/utils.js +19 -14
  67. package/lib/module/utils.js.map +1 -1
  68. package/lib/module/verify.js +14 -22
  69. package/lib/module/verify.js.map +1 -1
  70. package/lib/typescript/src/bindings.d.ts +3 -0
  71. package/lib/typescript/src/bindings.d.ts.map +1 -1
  72. package/lib/typescript/src/camera.d.ts +15 -0
  73. package/lib/typescript/src/camera.d.ts.map +1 -1
  74. package/lib/typescript/src/capture.d.ts +40 -1
  75. package/lib/typescript/src/capture.d.ts.map +1 -1
  76. package/lib/typescript/src/common.d.ts.map +1 -1
  77. package/lib/typescript/src/generated/zcam1_c2pa_utils-ffi.d.ts +37 -46
  78. package/lib/typescript/src/generated/zcam1_c2pa_utils-ffi.d.ts.map +1 -1
  79. package/lib/typescript/src/generated/zcam1_c2pa_utils.d.ts +110 -8
  80. package/lib/typescript/src/generated/zcam1_c2pa_utils.d.ts.map +1 -1
  81. package/lib/typescript/src/generated/zcam1_certs_utils-ffi.d.ts +27 -32
  82. package/lib/typescript/src/generated/zcam1_certs_utils-ffi.d.ts.map +1 -1
  83. package/lib/typescript/src/generated/zcam1_certs_utils.d.ts.map +1 -1
  84. package/lib/typescript/src/generated/zcam1_common-ffi.d.ts +77 -0
  85. package/lib/typescript/src/generated/zcam1_common-ffi.d.ts.map +1 -0
  86. package/lib/typescript/src/generated/zcam1_common.d.ts +17 -0
  87. package/lib/typescript/src/generated/zcam1_common.d.ts.map +1 -0
  88. package/lib/typescript/src/generated/zcam1_proving_utils-ffi.d.ts +44 -51
  89. package/lib/typescript/src/generated/zcam1_proving_utils-ffi.d.ts.map +1 -1
  90. package/lib/typescript/src/generated/zcam1_proving_utils.d.ts +26 -26
  91. package/lib/typescript/src/generated/zcam1_proving_utils.d.ts.map +1 -1
  92. package/lib/typescript/src/generated/zcam1_verify_utils-ffi.d.ts +29 -34
  93. package/lib/typescript/src/generated/zcam1_verify_utils-ffi.d.ts.map +1 -1
  94. package/lib/typescript/src/generated/zcam1_verify_utils.d.ts +94 -14
  95. package/lib/typescript/src/generated/zcam1_verify_utils.d.ts.map +1 -1
  96. package/lib/typescript/src/index.d.ts +1 -1
  97. package/lib/typescript/src/index.d.ts.map +1 -1
  98. package/lib/typescript/src/proving/NativeZcam1Proving.d.ts +1 -1
  99. package/lib/typescript/src/proving/index.d.ts +1 -1
  100. package/lib/typescript/src/proving/index.d.ts.map +1 -1
  101. package/lib/typescript/src/proving/prove.d.ts +3 -3
  102. package/lib/typescript/src/proving/prove.d.ts.map +1 -1
  103. package/lib/typescript/src/utils.d.ts.map +1 -1
  104. package/lib/typescript/src/verify.d.ts +4 -3
  105. package/lib/typescript/src/verify.d.ts.map +1 -1
  106. package/package.json +13 -6
  107. package/react-native.config.js +11 -0
  108. package/src/bindings.tsx +4 -0
  109. package/src/camera.tsx +116 -11
  110. package/src/capture.tsx +150 -53
  111. package/src/common.tsx +22 -2
  112. package/src/generated/zcam1_c2pa_utils-ffi.ts +42 -56
  113. package/src/generated/zcam1_c2pa_utils.ts +224 -67
  114. package/src/generated/zcam1_certs_utils-ffi.ts +33 -36
  115. package/src/generated/zcam1_certs_utils.ts +27 -24
  116. package/src/generated/zcam1_common-ffi.ts +183 -0
  117. package/src/generated/zcam1_common.ts +116 -0
  118. package/src/generated/zcam1_proving_utils-ffi.ts +54 -67
  119. package/src/generated/zcam1_proving_utils.ts +133 -138
  120. package/src/generated/zcam1_verify_utils-ffi.ts +39 -40
  121. package/src/generated/zcam1_verify_utils.ts +109 -47
  122. package/src/index.ts +1 -1
  123. package/src/proving/NativeZcam1Proving.ts +2 -2
  124. package/src/proving/index.ts +1 -1
  125. package/src/proving/prove.tsx +22 -11
  126. package/src/utils.ts +26 -20
  127. package/src/verify.tsx +25 -42
@@ -0,0 +1,588 @@
1
+ package com.succinctlabs.zcam1sdk.camera
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.graphics.Bitmap
7
+ import android.graphics.BitmapFactory
8
+ import android.graphics.Canvas
9
+ import android.graphics.Color
10
+ import android.graphics.LinearGradient
11
+ import android.graphics.Paint
12
+ import android.graphics.Shader
13
+ import android.graphics.Typeface
14
+ import android.media.MediaMetadataRetriever
15
+ import android.os.Build
16
+ import android.util.Log
17
+ import java.text.SimpleDateFormat
18
+ import java.util.Date
19
+ import java.util.Locale
20
+ import kotlin.random.Random
21
+ import androidx.exifinterface.media.ExifInterface
22
+ import androidx.camera.camera2.interop.Camera2CameraInfo
23
+ import androidx.camera.camera2.interop.ExperimentalCamera2Interop
24
+ import androidx.camera.core.*
25
+ import androidx.camera.lifecycle.ProcessCameraProvider
26
+ import androidx.camera.video.*
27
+ import androidx.camera.video.VideoCapture
28
+ import androidx.core.content.ContextCompat
29
+ import androidx.lifecycle.LifecycleOwner
30
+ import com.facebook.react.bridge.Promise
31
+ import com.facebook.react.bridge.WritableNativeArray
32
+ import com.facebook.react.bridge.WritableNativeMap
33
+ import java.io.File
34
+ import java.util.concurrent.Executor
35
+
36
+ /**
37
+ * Core camera service managing CameraX lifecycle, photo capture, video recording,
38
+ * zoom, flash, focus, and exposure. Created per camera view instance.
39
+ */
40
+ class Zcam1CameraService {
41
+
42
+ companion object {
43
+ private const val TAG = "Zcam1CameraService"
44
+ private const val MAX_ZOOM_CAP = 20.0f
45
+
46
+ /**
47
+ * Active instance used by Zcam1CaptureModule to delegate camera methods.
48
+ * Set when a Zcam1CameraView starts its camera, cleared when it stops.
49
+ */
50
+ @Volatile
51
+ var activeInstance: Zcam1CameraService? = null
52
+ private set
53
+ }
54
+
55
+ private var camera: Camera? = null
56
+ private var cameraProvider: ProcessCameraProvider? = null
57
+ private var imageCapture: ImageCapture? = null
58
+ private var videoCapture: VideoCapture<Recorder>? = null
59
+ private var preview: Preview? = null
60
+
61
+ private var currentLensFacing = CameraUtils.LENS_FACING_BACK
62
+ private var currentFlashMode = CameraUtils.FLASH_OFF
63
+ private var currentZoom = 1.0f
64
+
65
+ private var lifecycleOwner: LifecycleOwner? = null
66
+ private var surfaceProvider: Preview.SurfaceProvider? = null
67
+ private var executor: Executor? = null
68
+ private var context: Context? = null
69
+
70
+ // Video recording state
71
+ private var activeRecording: Recording? = null
72
+ private var recordingOutputFile: File? = null
73
+ private var stopRecordingPromise: Promise? = null
74
+ private var recordingHasAudio: Boolean = false
75
+
76
+ /**
77
+ * Start camera preview and capture pipeline.
78
+ */
79
+ fun startCamera(
80
+ context: Context,
81
+ lifecycleOwner: LifecycleOwner,
82
+ surfaceProvider: Preview.SurfaceProvider,
83
+ lensFacing: String = "back",
84
+ flashMode: String = "off"
85
+ ) {
86
+ this.context = context
87
+ this.lifecycleOwner = lifecycleOwner
88
+ this.surfaceProvider = surfaceProvider
89
+ this.executor = ContextCompat.getMainExecutor(context)
90
+ this.currentLensFacing = CameraUtils.mapLensFacing(lensFacing)
91
+ this.currentFlashMode = CameraUtils.mapFlashMode(flashMode)
92
+
93
+ activeInstance = this
94
+
95
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
96
+ cameraProviderFuture.addListener({
97
+ try {
98
+ cameraProvider = cameraProviderFuture.get()
99
+ bindCameraUseCases()
100
+ } catch (e: Exception) {
101
+ Log.e(TAG, "Failed to get camera provider", e)
102
+ }
103
+ }, executor!!)
104
+ }
105
+
106
+ /**
107
+ * Stop camera and release resources.
108
+ */
109
+ fun stopCamera() {
110
+ activeRecording?.stop()
111
+ activeRecording = null
112
+ cameraProvider?.unbindAll()
113
+ camera = null
114
+ imageCapture = null
115
+ videoCapture = null
116
+ preview = null
117
+ if (activeInstance == this) {
118
+ activeInstance = null
119
+ }
120
+ }
121
+
122
+ private fun bindCameraUseCases() {
123
+ val provider = cameraProvider ?: return
124
+ val owner = lifecycleOwner ?: return
125
+ val surface = surfaceProvider ?: return
126
+
127
+ provider.unbindAll()
128
+
129
+ val cameraSelector = CameraSelector.Builder()
130
+ .requireLensFacing(currentLensFacing)
131
+ .build()
132
+
133
+ preview = Preview.Builder()
134
+ .build()
135
+ .also { it.setSurfaceProvider(surface) }
136
+
137
+ imageCapture = ImageCapture.Builder()
138
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
139
+ .setFlashMode(currentFlashMode)
140
+ .build()
141
+
142
+ val recorder = Recorder.Builder()
143
+ .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
144
+ .build()
145
+ videoCapture = VideoCapture.withOutput(recorder)
146
+
147
+ try {
148
+ camera = provider.bindToLifecycle(owner, cameraSelector, preview, imageCapture, videoCapture)
149
+ } catch (e: Exception) {
150
+ Log.w(TAG, "Failed to bind with VideoCapture, retrying without it: ${e.message}")
151
+ videoCapture = null
152
+ try {
153
+ camera = provider.bindToLifecycle(owner, cameraSelector, preview, imageCapture)
154
+ } catch (e2: Exception) {
155
+ Log.e(TAG, "Failed to bind camera use cases", e2)
156
+ return
157
+ }
158
+ }
159
+ if (currentZoom != 1.0f) {
160
+ setZoom(currentZoom)
161
+ }
162
+ }
163
+
164
+ // === Camera Controls ===
165
+
166
+ fun setZoom(factor: Float) {
167
+ val cam = camera ?: return
168
+ val maxZoom = cam.cameraInfo.zoomState.value?.maxZoomRatio ?: 1.0f
169
+ val cappedMax = minOf(maxZoom, MAX_ZOOM_CAP)
170
+ currentZoom = CameraUtils.clampZoom(factor, cappedMax)
171
+ cam.cameraControl.setZoomRatio(currentZoom)
172
+ }
173
+
174
+ fun setZoomAnimated(factor: Float) {
175
+ // CameraX doesn't have a built-in smooth ramp like AVFoundation; use linear zoom for smooth transitions
176
+ val cam = camera ?: return
177
+ val zoomState = cam.cameraInfo.zoomState.value ?: return
178
+ val minZoom = zoomState.minZoomRatio
179
+ val maxZoom = minOf(zoomState.maxZoomRatio, MAX_ZOOM_CAP)
180
+ val clamped = factor.coerceIn(minZoom, maxZoom)
181
+ val linearZoom = if (maxZoom > minZoom) (clamped - minZoom) / (maxZoom - minZoom) else 0f
182
+ cam.cameraControl.setLinearZoom(linearZoom)
183
+ currentZoom = clamped
184
+ }
185
+
186
+ fun setFlashMode(mode: String) {
187
+ currentFlashMode = CameraUtils.mapFlashMode(mode)
188
+ imageCapture?.flashMode = currentFlashMode
189
+ }
190
+
191
+ fun getMinZoom(): Float {
192
+ return camera?.cameraInfo?.zoomState?.value?.minZoomRatio ?: 1.0f
193
+ }
194
+
195
+ fun getMaxZoom(): Float {
196
+ val cam = camera ?: return 1.0f
197
+ val maxZoom = cam.cameraInfo.zoomState.value?.maxZoomRatio ?: 1.0f
198
+ return minOf(maxZoom, MAX_ZOOM_CAP)
199
+ }
200
+
201
+ fun focusAtPoint(x: Float, y: Float) {
202
+ val cam = camera ?: return
203
+
204
+ val factory = SurfaceOrientedMeteringPointFactory(1.0f, 1.0f)
205
+ val point = factory.createPoint(x, y)
206
+ val action = FocusMeteringAction.Builder(point).build()
207
+
208
+ cam.cameraControl.startFocusAndMetering(action)
209
+ }
210
+
211
+ // === Exposure ===
212
+
213
+ fun getExposureRange(): Pair<Float, Float> {
214
+ val state = camera?.cameraInfo?.exposureState ?: return Pair(-2.0f, 2.0f)
215
+ val range = state.exposureCompensationRange
216
+ val step = state.exposureCompensationStep.toFloat()
217
+ return Pair(range.lower * step, range.upper * step)
218
+ }
219
+
220
+ fun resetExposure() {
221
+ camera?.cameraControl?.setExposureCompensationIndex(0)
222
+ }
223
+
224
+ // === Emulator Helpers ===
225
+
226
+ private fun isEmulator(): Boolean {
227
+
228
+ return (Build.HARDWARE == "goldfish"
229
+ || Build.HARDWARE == "ranchu"
230
+ || Build.FINGERPRINT.startsWith("generic")
231
+ || Build.FINGERPRINT.contains("emulator")
232
+ || Build.MODEL.contains("Emulator")
233
+ || Build.MODEL.contains("Android SDK built for")
234
+ || Build.MANUFACTURER.contains("Genymotion")
235
+ || Build.BRAND.startsWith("generic")
236
+ || Build.DEVICE.startsWith("generic"))
237
+ }
238
+
239
+ private fun createTestImage(): Bitmap {
240
+ val width = 1920
241
+ val height = 1080
242
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
243
+ val canvas = Canvas(bitmap)
244
+
245
+ val rng = Random.Default
246
+ val hue = rng.nextFloat() * 360f
247
+ val saturation = 0.55f + rng.nextFloat() * 0.35f
248
+ val brightness1 = 0.5f + rng.nextFloat() * 0.25f
249
+ val brightness2 = (brightness1 + 0.15f + rng.nextFloat() * 0.2f).coerceAtMost(1.0f)
250
+
251
+ val color1 = Color.HSVToColor(floatArrayOf(hue, saturation, brightness1))
252
+ val color2 = Color.HSVToColor(floatArrayOf(hue, saturation, brightness2))
253
+
254
+ val gradientPaint = Paint()
255
+ gradientPaint.shader = LinearGradient(
256
+ 0f, 0f, width.toFloat(), height.toFloat(),
257
+ color1, color2, Shader.TileMode.CLAMP
258
+ )
259
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gradientPaint)
260
+
261
+ val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
262
+ color = Color.argb(230, 255, 255, 255)
263
+ textSize = 96f
264
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
265
+ textAlign = Paint.Align.CENTER
266
+ }
267
+
268
+ val label = "EMULATOR TEST IMAGE"
269
+ canvas.drawText(label, width / 2f, height / 2f - 60f, textPaint)
270
+
271
+ val datePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
272
+ color = Color.argb(230, 255, 255, 255)
273
+ textSize = 72f
274
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
275
+ textAlign = Paint.Align.CENTER
276
+ }
277
+ val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
278
+ canvas.drawText(dateStr, width / 2f, height / 2f + 80f, datePaint)
279
+
280
+ return bitmap
281
+ }
282
+
283
+ // === Photo Capture ===
284
+
285
+ /**
286
+ * Capture a photo and resolve the promise with path and format.
287
+ * The returned temp file is owned by the caller.
288
+ */
289
+ fun takePhoto(format: String, flash: String, promise: Promise) {
290
+ if (format == "dng") {
291
+ promise.reject("UNSUPPORTED_FORMAT", "DNG format is not supported on Android")
292
+ return
293
+ }
294
+
295
+ if (isEmulator()) {
296
+ try {
297
+ val bitmap = createTestImage()
298
+ val tempFile = File.createTempFile("zcam1_emulator_", ".jpg")
299
+ tempFile.outputStream().use { out ->
300
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
301
+ }
302
+ bitmap.recycle()
303
+
304
+ val dateStr = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US).format(Date())
305
+ val exif = WritableNativeMap().apply {
306
+ putArray("ISOSpeedRatings", WritableNativeArray())
307
+ putInt("PixelXDimension", 1920)
308
+ putInt("PixelYDimension", 1080)
309
+ putDouble("ExposureTime", 0.0)
310
+ putDouble("FNumber", 1.0)
311
+ putDouble("FocalLength", 5.0)
312
+ }
313
+ val tiff = WritableNativeMap().apply {
314
+ putString("DateTime", dateStr)
315
+ putString("Make", "Android")
316
+ putString("Model", "Android Emulator")
317
+ putString("Software", "Android Emulator")
318
+ }
319
+ val metadata = WritableNativeMap().apply {
320
+ putMap("{Exif}", exif)
321
+ putMap("{TIFF}", tiff)
322
+ putInt("Orientation", 1)
323
+ }
324
+ val result = WritableNativeMap().apply {
325
+ putString("filePath", tempFile.absolutePath)
326
+ putString("format", "jpeg")
327
+ putMap("metadata", metadata)
328
+ }
329
+ promise.resolve(result)
330
+ } catch (e: Exception) {
331
+ promise.reject("CAPTURE_ERROR", "Failed to create emulator test image: ${e.message}", e)
332
+ }
333
+ return
334
+ }
335
+
336
+ if (camera == null) {
337
+ promise.reject(
338
+ "CAMERA_ERROR",
339
+ "Camera not bound — check that the device has a camera and permissions are granted"
340
+ )
341
+ return
342
+ }
343
+
344
+ val capture = imageCapture ?: run {
345
+ promise.reject("CAMERA_ERROR", "Camera not initialized")
346
+ return
347
+ }
348
+ val exec = executor ?: run {
349
+ promise.reject("CAMERA_ERROR", "Executor not available")
350
+ return
351
+ }
352
+
353
+ capture.flashMode = CameraUtils.mapFlashMode(flash)
354
+ capture.targetRotation = when (Zcam1OrientationManager.currentOrientation()) {
355
+ 90 -> android.view.Surface.ROTATION_90
356
+ 180 -> android.view.Surface.ROTATION_180
357
+ 270 -> android.view.Surface.ROTATION_270
358
+ else -> android.view.Surface.ROTATION_0
359
+ }
360
+
361
+ val tempFile = File.createTempFile("zcam1_", ".jpg")
362
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
363
+
364
+ capture.takePicture(
365
+ outputOptions,
366
+ exec,
367
+ object : ImageCapture.OnImageSavedCallback {
368
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
369
+ val exifData = try {
370
+ val exifInterface = ExifInterface(tempFile.absolutePath)
371
+ val exifMap = WritableNativeMap().apply {
372
+ val width = exifInterface.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
373
+ val height = exifInterface.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)
374
+ if (width > 0) putInt("PixelXDimension", width)
375
+ if (height > 0) putInt("PixelYDimension", height)
376
+ exifInterface.getAttributeDouble(ExifInterface.TAG_EXPOSURE_TIME, Double.NaN)
377
+ .takeIf { !it.isNaN() }?.let { putDouble("ExposureTime", it) }
378
+ exifInterface.getAttributeDouble(ExifInterface.TAG_F_NUMBER, Double.NaN)
379
+ .takeIf { !it.isNaN() }?.let { putDouble("FNumber", it) }
380
+ exifInterface.getAttributeDouble(ExifInterface.TAG_FOCAL_LENGTH, Double.NaN)
381
+ .takeIf { !it.isNaN() }?.let { putDouble("FocalLength", it) }
382
+ exifInterface.getAttribute(ExifInterface.TAG_ISO_SPEED_RATINGS)
383
+ ?.let { putString("ISOSpeedRatings", it) }
384
+ }
385
+ val tiffMap = WritableNativeMap().apply {
386
+ exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
387
+ ?.let { putString("DateTime", it) }
388
+ exifInterface.getAttribute(ExifInterface.TAG_MAKE)
389
+ ?.let { putString("Make", it) }
390
+ exifInterface.getAttribute(ExifInterface.TAG_MODEL)
391
+ ?.let { putString("Model", it) }
392
+ exifInterface.getAttribute(ExifInterface.TAG_SOFTWARE)
393
+ ?.let { putString("Software", it) }
394
+ }
395
+ val orientation = exifInterface.getAttributeInt(
396
+ ExifInterface.TAG_ORIENTATION,
397
+ ExifInterface.ORIENTATION_NORMAL
398
+ )
399
+ WritableNativeMap().apply {
400
+ putMap("{Exif}", exifMap)
401
+ putMap("{TIFF}", tiffMap)
402
+ putInt("Orientation", orientation)
403
+ }
404
+ } catch (e: Exception) {
405
+ Log.w(TAG, "Failed to read EXIF from captured image", e)
406
+ null
407
+ }
408
+
409
+ val result = WritableNativeMap().apply {
410
+ putString("filePath", tempFile.absolutePath)
411
+ putString("format", "jpeg")
412
+ if (exifData != null) putMap("metadata", exifData) else putNull("metadata")
413
+ }
414
+ promise.resolve(result)
415
+ }
416
+
417
+ override fun onError(exception: ImageCaptureException) {
418
+ Log.e(TAG, "Photo capture failed", exception)
419
+ tempFile.delete()
420
+ promise.reject("CAPTURE_ERROR", exception.message, exception)
421
+ }
422
+ }
423
+ )
424
+ }
425
+
426
+ // === Video Recording ===
427
+
428
+ fun startVideoRecording(maxDurationSeconds: Double, promise: Promise) {
429
+ val vc = videoCapture ?: run {
430
+ promise.reject("CAMERA_ERROR", "Camera not initialized")
431
+ return
432
+ }
433
+ val ctx = context ?: run {
434
+ promise.reject("CAMERA_ERROR", "Context not available")
435
+ return
436
+ }
437
+ val exec = executor ?: run {
438
+ promise.reject("CAMERA_ERROR", "Executor not available")
439
+ return
440
+ }
441
+
442
+ if (activeRecording != null) {
443
+ promise.reject("RECORDING_ERROR", "A recording is already in progress")
444
+ return
445
+ }
446
+
447
+ val hasAudio = ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
448
+ PackageManager.PERMISSION_GRANTED
449
+ recordingHasAudio = hasAudio
450
+
451
+ val outputFile = File.createTempFile("zcam1_video_", ".mp4")
452
+ recordingOutputFile = outputFile
453
+
454
+ val outputOptions = FileOutputOptions.Builder(outputFile).apply {
455
+ if (maxDurationSeconds > 0) {
456
+ setDurationLimitMillis((maxDurationSeconds * 1000).toLong())
457
+ }
458
+ }.build()
459
+
460
+ var pendingRecording = vc.output.prepareRecording(ctx, outputOptions)
461
+ if (hasAudio) {
462
+ pendingRecording = pendingRecording.withAudioEnabled()
463
+ }
464
+
465
+ activeRecording = pendingRecording.start(exec) { event ->
466
+ when (event) {
467
+ is VideoRecordEvent.Finalize -> {
468
+ val stopPromise = stopRecordingPromise
469
+ stopRecordingPromise = null
470
+ activeRecording = null
471
+
472
+ if (event.hasError()) {
473
+ Log.e(TAG, "Video recording finalized with error: ${event.error}")
474
+ outputFile.delete()
475
+ stopPromise?.reject("RECORDING_ERROR", "Recording failed: ${event.error}")
476
+ } else {
477
+ stopPromise?.resolve(buildStopResult(outputFile, hasAudio))
478
+ }
479
+ }
480
+
481
+ else -> { /* ignore Start, Status events */
482
+ }
483
+ }
484
+ }
485
+
486
+ val startResult = WritableNativeMap().apply {
487
+ putString("status", "recording")
488
+ putString("filePath", outputFile.absolutePath)
489
+ putString("format", "mov")
490
+ putBoolean("hasAudio", hasAudio)
491
+ }
492
+ promise.resolve(startResult)
493
+ }
494
+
495
+ fun stopVideoRecording(promise: Promise) {
496
+ val recording = activeRecording ?: run {
497
+ promise.reject("RECORDING_ERROR", "No active recording")
498
+ return
499
+ }
500
+ stopRecordingPromise = promise
501
+ recording.stop()
502
+ }
503
+
504
+ private fun buildStopResult(outputFile: File, hasAudio: Boolean): WritableNativeMap {
505
+ val retriever = MediaMetadataRetriever()
506
+ try {
507
+ retriever.setDataSource(outputFile.absolutePath)
508
+
509
+ val durationMs =
510
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
511
+ val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
512
+ val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
513
+ val rotation =
514
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
515
+ val frameRate =
516
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toDoubleOrNull()
517
+ ?: 0.0
518
+
519
+ return WritableNativeMap().apply {
520
+ putString("filePath", outputFile.absolutePath)
521
+ putString("format", "mov")
522
+ putBoolean("hasAudio", hasAudio)
523
+ putString("deviceMake", Build.MANUFACTURER)
524
+ putString("deviceModel", Build.MODEL)
525
+ putString("softwareVersion", Build.VERSION.RELEASE)
526
+ putDouble("durationSeconds", durationMs / 1000.0)
527
+ putDouble("fileSizeBytes", outputFile.length().toDouble())
528
+ putInt("width", width)
529
+ putInt("height", height)
530
+ putInt("rotationDegrees", rotation)
531
+ putDouble("frameRate", frameRate)
532
+ }
533
+ } catch (e: Exception) {
534
+ Log.e(TAG, "Failed to extract video metadata", e)
535
+ return WritableNativeMap().apply {
536
+ putString("filePath", outputFile.absolutePath)
537
+ putString("format", "mov")
538
+ putBoolean("hasAudio", hasAudio)
539
+ putString("deviceMake", Build.MANUFACTURER)
540
+ putString("deviceModel", Build.MODEL)
541
+ putString("softwareVersion", Build.VERSION.RELEASE)
542
+ putDouble("durationSeconds", 0.0)
543
+ putDouble("fileSizeBytes", outputFile.length().toDouble())
544
+ putInt("width", 0)
545
+ putInt("height", 0)
546
+ putInt("rotationDegrees", 0)
547
+ putDouble("frameRate", 0.0)
548
+ }
549
+ } finally {
550
+ retriever.release()
551
+ }
552
+ }
553
+
554
+ // === Diagnostics ===
555
+
556
+ @androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
557
+ fun getDeviceDiagnostics(): WritableNativeMap {
558
+ val cam = camera
559
+ val zoomState = cam?.cameraInfo?.zoomState?.value
560
+ val exposureState = cam?.cameraInfo?.exposureState
561
+
562
+ val minZoom = zoomState?.minZoomRatio?.toDouble() ?: 1.0
563
+ val maxZoom = minOf(zoomState?.maxZoomRatio ?: 1.0f, MAX_ZOOM_CAP).toDouble()
564
+ val currentZoomVal = zoomState?.zoomRatio?.toDouble() ?: currentZoom.toDouble()
565
+
566
+ val expStep = exposureState?.exposureCompensationStep?.toFloat() ?: 1.0f
567
+ val expRange = exposureState?.exposureCompensationRange
568
+ val minExpBias = (expRange?.lower ?: -2) * expStep
569
+ val maxExpBias = (expRange?.upper ?: 2) * expStep
570
+ val currentExpIndex = exposureState?.exposureCompensationIndex ?: 0
571
+ val currentExpBias = currentExpIndex * expStep
572
+
573
+ return WritableNativeMap().apply {
574
+ putString("deviceType", "builtInCamera")
575
+ putDouble("minZoom", minZoom)
576
+ putDouble("maxZoom", maxZoom)
577
+ putDouble("currentZoom", currentZoomVal)
578
+ putArray("switchOverFactors", WritableNativeArray())
579
+ putInt("switchingBehavior", 0)
580
+ putBoolean("isVirtualDevice", false)
581
+ putDouble("currentExposureBias", currentExpBias.toDouble())
582
+ putDouble("minExposureBias", minExpBias.toDouble())
583
+ putDouble("maxExposureBias", maxExpBias.toDouble())
584
+ putDouble("currentISO", 0.0)
585
+ putDouble("exposureDuration", 0.0)
586
+ }
587
+ }
588
+ }
@@ -0,0 +1,107 @@
1
+ package com.succinctlabs.zcam1sdk.camera
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.widget.FrameLayout
6
+ import androidx.camera.view.PreviewView
7
+ import androidx.lifecycle.LifecycleOwner
8
+ import com.facebook.react.uimanager.ThemedReactContext
9
+
10
+ /**
11
+ * React Native camera view. Hosts a CameraX PreviewView and manages
12
+ * the camera service lifecycle tied to the view's window attachment.
13
+ *
14
+ * Handles React Native Fabric's view recycling: Fabric may detach and
15
+ * re-attach the view during layout passes. We delay camera teardown
16
+ * to avoid restarting the camera on every re-layout.
17
+ */
18
+ class Zcam1CameraView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext) {
19
+
20
+ private val previewView = PreviewView(reactContext)
21
+ private val cameraService = Zcam1CameraService()
22
+ private val mainHandler = Handler(Looper.getMainLooper())
23
+ private var pendingTeardown: Runnable? = null
24
+ private var cameraStarted = false
25
+
26
+ var cameraType: String = "back"
27
+ set(value) {
28
+ if (field != value) {
29
+ field = value
30
+ if (cameraStarted) restartCamera()
31
+ }
32
+ }
33
+
34
+ var flashMode: String = "off"
35
+ set(value) {
36
+ field = value
37
+ cameraService.setFlashMode(value)
38
+ }
39
+
40
+ var zoom: Float = 1.0f
41
+ set(value) {
42
+ field = value
43
+ cameraService.setZoom(value)
44
+ }
45
+
46
+ init {
47
+ addView(previewView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
48
+ }
49
+
50
+ /**
51
+ * Override requestLayout to work with Fabric's measurement system.
52
+ * Without this, the PreviewView may not render correctly after Fabric layout passes.
53
+ */
54
+ override fun requestLayout() {
55
+ super.requestLayout()
56
+ post {
57
+ measure(
58
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
59
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
60
+ )
61
+ layout(left, top, right, bottom)
62
+ }
63
+ }
64
+
65
+ override fun onAttachedToWindow() {
66
+ super.onAttachedToWindow()
67
+ // Cancel any pending teardown — this is just Fabric re-attaching us
68
+ pendingTeardown?.let {
69
+ mainHandler.removeCallbacks(it)
70
+ pendingTeardown = null
71
+ }
72
+ if (!cameraStarted) {
73
+ Zcam1OrientationManager.startUpdates(reactContext)
74
+ startCamera()
75
+ }
76
+ }
77
+
78
+ override fun onDetachedFromWindow() {
79
+ // Delay teardown to distinguish real removal from Fabric re-layout
80
+ pendingTeardown = Runnable {
81
+ pendingTeardown = null
82
+ cameraService.stopCamera()
83
+ Zcam1OrientationManager.stopUpdates()
84
+ cameraStarted = false
85
+ }
86
+ mainHandler.postDelayed(pendingTeardown!!, 200)
87
+ super.onDetachedFromWindow()
88
+ }
89
+
90
+ private fun startCamera() {
91
+ val lifecycleOwner = reactContext.currentActivity as? LifecycleOwner ?: return
92
+
93
+ cameraService.startCamera(
94
+ context = reactContext,
95
+ lifecycleOwner = lifecycleOwner,
96
+ surfaceProvider = previewView.surfaceProvider,
97
+ lensFacing = cameraType,
98
+ flashMode = flashMode
99
+ )
100
+ cameraStarted = true
101
+ }
102
+
103
+ private fun restartCamera() {
104
+ cameraService.stopCamera()
105
+ startCamera()
106
+ }
107
+ }