catalyst-core 0.1.0-beta.3 → 0.1.0-beta.5

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 (81) hide show
  1. package/bin/catalyst.js +8 -1
  2. package/changelog.md +6 -0
  3. package/dist/native/androidProject/app/build.gradle.kts +9 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +17 -1
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +31 -3
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +148 -0
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
  13. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
  14. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
  15. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
  22. package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
  23. package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
  24. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
  25. package/dist/native/bridge/WebBridge.js +5 -5
  26. package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
  27. package/dist/native/bridge/hooks/index.js +1 -0
  28. package/dist/native/bridge/hooks/useCamera.js +9 -0
  29. package/dist/native/bridge/hooks/useDataProtection.js +2 -0
  30. package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
  31. package/dist/native/bridge/hooks/useFilePicker.js +2 -0
  32. package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
  33. package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
  34. package/dist/native/bridge/hooks/useIntent.js +1 -0
  35. package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
  36. package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
  37. package/dist/native/bridge/hooks/useNotification.js +2 -0
  38. package/dist/native/bridge/hooks/useSafeArea.js +2 -0
  39. package/dist/native/bridge/hooks/useVideoStream.js +6 -0
  40. package/dist/native/bridge/hooks/utils.js +1 -0
  41. package/dist/native/bridge/hooks.js +1 -137
  42. package/dist/native/bridge/useBaseHook.js +8 -3
  43. package/dist/native/bridge/utils/NativeBridge.js +36 -1
  44. package/dist/native/buildAppAndroid.js +2 -2
  45. package/dist/native/buildAppIos.js +12 -17
  46. package/dist/native/internalPluginUtils.js +7 -0
  47. package/dist/native/iosnativeWebView/Package.resolved +36 -0
  48. package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
  49. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
  50. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
  51. package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
  52. package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
  53. package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
  54. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
  55. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
  56. package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
  57. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
  58. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  59. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  60. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  61. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
  62. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
  63. package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
  64. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  65. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +38 -7
  66. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +6 -2
  67. package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +15 -4
  68. package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
  69. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
  70. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  71. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  72. package/dist/native/pluginComposerAndroid.js +28 -0
  73. package/dist/native/pluginComposerIos.js +16 -0
  74. package/dist/otel.js +70 -2
  75. package/dist/scripts/plugins.js +1 -0
  76. package/dist/server/renderer/extract.js +3 -3
  77. package/dist/server/renderer/handler.js +15 -14
  78. package/dist/webpack/development.client.babel.js +5 -1
  79. package/dist/webpack/production.ssr.babel.js +5 -1
  80. package/mcp_v2/knowledge-base.json +1564 -1067
  81. package/package.json +1 -1
@@ -0,0 +1,244 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.Manifest
4
+ import android.content.pm.PackageManager
5
+ import android.util.Log
6
+ import android.util.Size
7
+ import android.view.View
8
+ import android.hardware.camera2.CaptureRequest
9
+ import android.util.Range
10
+ import androidx.camera.camera2.interop.Camera2Interop
11
+ import androidx.camera.core.Camera
12
+ import androidx.camera.core.CameraSelector
13
+ import androidx.camera.core.ImageAnalysis
14
+ import androidx.camera.core.Preview
15
+ import androidx.camera.core.resolutionselector.ResolutionFilter
16
+ import androidx.camera.core.resolutionselector.ResolutionSelector
17
+ import androidx.camera.core.resolutionselector.ResolutionStrategy
18
+ import androidx.camera.lifecycle.ProcessCameraProvider
19
+ import androidx.camera.view.PreviewView
20
+ import androidx.core.content.ContextCompat
21
+ import androidx.lifecycle.LifecycleOwner
22
+ import io.yourname.androidproject.MainActivity
23
+ import io.yourname.androidproject.utils.BridgeUtils
24
+ import java.util.concurrent.ExecutorService
25
+ import java.util.concurrent.Executors
26
+
27
+ /**
28
+ * Owns the CameraX session lifecycle: bind, stop, flip, setFps.
29
+ */
30
+ class CameraSessionManager(
31
+ private val activity: MainActivity,
32
+ private val previewView: PreviewView,
33
+ private val stateMachine: VideoStreamStateMachine,
34
+ private val zoomController: ZoomController,
35
+ private val torchController: TorchController,
36
+ private val barcodeDetector: BarcodeDetector,
37
+ private val onReady: () -> Unit,
38
+ private val onStopped: () -> Unit,
39
+ private val onError: (message: String) -> Unit
40
+ ) {
41
+
42
+ companion object {
43
+ private const val TAG = "CameraSessionManager"
44
+ const val PERMISSION_REQUEST_CODE = 1001
45
+ }
46
+
47
+ private var cameraProvider: ProcessCameraProvider? = null
48
+ private var camera: Camera? = null
49
+ val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
50
+
51
+ private var previewUseCase: Preview? = null
52
+ private var imageAnalysisUseCase: ImageAnalysis? = null
53
+
54
+ // Session params — stored so flip/setFps can rebind with same settings
55
+ private var currentFacing: String = "back"
56
+ private var currentFpsMin: Int? = null
57
+ private var currentFpsMax: Int? = null
58
+ private var currentScanFormat: String = "all"
59
+ private var currentAutoZoom: Boolean = false
60
+ private var currentInitialZoom: Float = 1.0f
61
+
62
+ // ---- Public API ----
63
+
64
+ fun start(
65
+ facing: String,
66
+ autoZoom: Boolean,
67
+ initialZoomPct: Float,
68
+ scanFormat: String,
69
+ fpsMin: Int?,
70
+ fpsMax: Int?
71
+ ) {
72
+ if (cameraProvider != null) {
73
+ Log.w(TAG, "start() called while already streaming — ignoring")
74
+ return
75
+ }
76
+ currentFacing = facing
77
+ currentAutoZoom = autoZoom
78
+ currentInitialZoom = initialZoomPct
79
+ currentScanFormat = scanFormat
80
+ currentFpsMin = fpsMin
81
+ currentFpsMax = fpsMax
82
+
83
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
84
+ == PackageManager.PERMISSION_GRANTED
85
+ ) {
86
+ bindCamera()
87
+ } else {
88
+ activity.requestPermissions(arrayOf(Manifest.permission.CAMERA), PERMISSION_REQUEST_CODE)
89
+ }
90
+ }
91
+
92
+ fun stop() {
93
+ cameraProvider?.unbindAll()
94
+ cameraProvider = null
95
+ camera = null
96
+ previewUseCase = null
97
+ imageAnalysisUseCase = null
98
+ barcodeDetector.close()
99
+ zoomController.detachCamera()
100
+ torchController.detachCamera()
101
+ activity.runOnUiThread {
102
+ previewView.visibility = View.INVISIBLE
103
+ stateMachine.transition(VideoStreamState.IDLE)
104
+ onStopped()
105
+ }
106
+ Log.d(TAG, "Camera stopped")
107
+ }
108
+
109
+ fun flip() {
110
+ val newFacing = if (currentFacing == "back") "front" else "back"
111
+ Log.d(TAG, "flip() — $currentFacing → $newFacing")
112
+ currentFacing = newFacing
113
+ cleanupForRebind()
114
+ bindCamera()
115
+ }
116
+
117
+ fun setFps(min: Int?, max: Int?) {
118
+ if (min != null && max != null && min > max) {
119
+ Log.w(TAG, "setFps() — invalid range min=$min > max=$max, ignoring")
120
+ return
121
+ }
122
+ Log.d(TAG, "setFps($min, $max) — restarting session")
123
+ currentFpsMin = min
124
+ currentFpsMax = max
125
+ cleanupForRebind()
126
+ bindCamera()
127
+ }
128
+
129
+ private fun cleanupForRebind() {
130
+ barcodeDetector.close()
131
+ cameraProvider?.unbindAll()
132
+ camera = null
133
+ previewUseCase = null
134
+ imageAnalysisUseCase = null
135
+ zoomController.detachCamera()
136
+ torchController.detachCamera()
137
+ }
138
+
139
+ fun onPermissionResult(granted: Boolean) {
140
+ if (granted) {
141
+ bindCamera()
142
+ } else {
143
+ stateMachine.transition(VideoStreamState.IDLE)
144
+ onError("Camera permission denied")
145
+ }
146
+ }
147
+
148
+ fun cleanup() {
149
+ stop()
150
+ cameraExecutor.shutdown()
151
+ }
152
+
153
+ // ---- Internal ----
154
+
155
+ private fun bindCamera() {
156
+ val future = ProcessCameraProvider.getInstance(activity)
157
+ future.addListener({
158
+ try {
159
+ cameraProvider = future.get()
160
+
161
+ val previewBuilder = Preview.Builder()
162
+ .setResolutionSelector(
163
+ ResolutionSelector.Builder()
164
+ .setResolutionFilter { supportedSizes, _ ->
165
+ supportedSizes.sortedByDescending { it.width * it.height }
166
+ }
167
+ .build()
168
+ )
169
+ val fpsMin = currentFpsMin
170
+ val fpsMax = currentFpsMax
171
+ if (fpsMin != null && fpsMax != null) {
172
+ if (fpsMin <= fpsMax) {
173
+ Camera2Interop.Extender(previewBuilder)
174
+ .setCaptureRequestOption(
175
+ CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE,
176
+ Range(fpsMin, fpsMax)
177
+ )
178
+ } else {
179
+ Log.w(TAG, "bindCamera — invalid FPS range min=$fpsMin > max=$fpsMax, skipping FPS override")
180
+ }
181
+ }
182
+ previewUseCase = previewBuilder.build().also {
183
+ it.setSurfaceProvider { request ->
184
+ Log.d(TAG, "Preview resolution negotiated: ${request.resolution.width}x${request.resolution.height}")
185
+ previewView.surfaceProvider.onSurfaceRequested(request)
186
+ }
187
+ }
188
+
189
+ val resolutionSelector = ResolutionSelector.Builder()
190
+ .setResolutionStrategy(
191
+ ResolutionStrategy(
192
+ Size(1920, 1080),
193
+ ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
194
+ )
195
+ )
196
+ .build()
197
+
198
+ imageAnalysisUseCase = barcodeDetector.buildImageAnalysis(
199
+ executor = cameraExecutor,
200
+ resolutionSelector = resolutionSelector,
201
+ autoZoomEnabled = currentAutoZoom,
202
+ scanFormat = currentScanFormat
203
+ )
204
+
205
+ cameraProvider?.unbindAll()
206
+ val boundCamera = cameraProvider?.bindToLifecycle(
207
+ activity as LifecycleOwner,
208
+ cameraSelector(currentFacing),
209
+ previewUseCase!!,
210
+ imageAnalysisUseCase!!
211
+ )
212
+ if (boundCamera == null) {
213
+ Log.e(TAG, "bindToLifecycle returned null — aborting camera start")
214
+ stateMachine.transition(VideoStreamState.IDLE)
215
+ onError("Failed to bind camera to lifecycle")
216
+ return@addListener
217
+ }
218
+ camera = boundCamera
219
+ zoomController.attachCamera(boundCamera)
220
+ torchController.attachCamera(boundCamera, currentFacing)
221
+
222
+ zoomController.applyZoomMultiplier(currentInitialZoom)
223
+ torchController.notifyReset()
224
+
225
+ stateMachine.transition(VideoStreamState.STREAMING)
226
+
227
+ activity.runOnUiThread {
228
+ previewView.visibility = View.VISIBLE
229
+ onReady()
230
+ }
231
+ Log.d(TAG, "Camera bound — facing=$currentFacing autoZoom=$currentAutoZoom")
232
+
233
+ } catch (e: Exception) {
234
+ Log.e(TAG, "Failed to bind camera", e)
235
+ stateMachine.transition(VideoStreamState.IDLE)
236
+ onError("Failed to start camera: ${e.message}")
237
+ }
238
+ }, ContextCompat.getMainExecutor(activity))
239
+ }
240
+
241
+ private fun cameraSelector(facing: String) =
242
+ if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA
243
+ else CameraSelector.DEFAULT_BACK_CAMERA
244
+ }
@@ -0,0 +1,70 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.util.Log
6
+ import java.util.concurrent.atomic.AtomicReference
7
+
8
+ /**
9
+ * Manages the QR hold state — after each detection, results are suppressed for HOLD_DURATION_MS
10
+ * to prevent the same QR from firing repeatedly and to let auto-zoom relax.
11
+ *
12
+ * Uses a suppress flag in BarcodeDetector instead of unbind/rebind — camera pipeline
13
+ * stays bound the whole time, so there is no flicker.
14
+ */
15
+ class HoldController(
16
+ private val stateMachine: VideoStreamStateMachine,
17
+ private val barcodeDetector: BarcodeDetector
18
+ ) {
19
+
20
+ private val TAG = "HoldController"
21
+ private val HOLD_DURATION_MS = 200L
22
+
23
+ private val handler = Handler(Looper.getMainLooper())
24
+ @Volatile private var resumeRunnable: Runnable? = null
25
+
26
+ private val lastDetectedValueRef = AtomicReference<String?>(null)
27
+ val lastDetectedValue: String? get() = lastDetectedValueRef.get()
28
+
29
+ /**
30
+ * Call after a new QR value is confirmed. Suppresses results for HOLD_DURATION_MS,
31
+ * then re-enables and transitions back to STREAMING.
32
+ */
33
+ fun startHold() {
34
+ if (!stateMachine.transition(VideoStreamState.HOLD)) return
35
+
36
+ barcodeDetector.suppressResults = true
37
+ Log.d(TAG, "Hold started — results suppressed for ${HOLD_DURATION_MS}ms")
38
+
39
+ resumeRunnable?.let { handler.removeCallbacks(it) }
40
+ val runnable = Runnable {
41
+ if (!stateMachine.state.isActive) return@Runnable
42
+ barcodeDetector.suppressResults = false
43
+ stateMachine.transition(VideoStreamState.STREAMING)
44
+ Log.d(TAG, "Hold ended — results resumed")
45
+ }
46
+ resumeRunnable = runnable
47
+ handler.postDelayed(runnable, HOLD_DURATION_MS)
48
+ }
49
+
50
+ /**
51
+ * Call on stop() or flip() to cancel any pending hold and clear detection memory.
52
+ */
53
+ fun reset() {
54
+ resumeRunnable?.let { handler.removeCallbacks(it) }
55
+ resumeRunnable = null
56
+ barcodeDetector.suppressResults = false
57
+ lastDetectedValueRef.set(null)
58
+ Log.d(TAG, "Hold state reset")
59
+ }
60
+
61
+ /**
62
+ * Returns true if this value is new (not a repeat of the last detected value).
63
+ * Also updates lastDetectedValue if it is new.
64
+ */
65
+ fun isNewValue(value: String): Boolean {
66
+ // compareAndSet atomically checks-and-updates — eliminates TOCTOU race
67
+ // between the read and the write on lastDetectedValue.
68
+ return lastDetectedValueRef.getAndSet(value) != value
69
+ }
70
+ }
@@ -0,0 +1,57 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import androidx.camera.core.Camera
6
+ import androidx.core.content.ContextCompat
7
+
8
+ /**
9
+ * Manages torch (flashlight) state.
10
+ * Guards against front-camera torch calls.
11
+ * Fires ON_TORCH_CHANGED via [onTorchChanged] callback only after the
12
+ * hardware operation succeeds (ListenableFuture resolved on main thread).
13
+ */
14
+ class TorchController(
15
+ private val context: Context,
16
+ private val onTorchChanged: (enabled: Boolean) -> Unit
17
+ ) {
18
+
19
+ private val TAG = "TorchController"
20
+
21
+ private var camera: Camera? = null
22
+ private var currentFacing: String = "back"
23
+
24
+ fun attachCamera(cam: Camera, facing: String) {
25
+ camera = cam
26
+ currentFacing = facing
27
+ }
28
+
29
+ fun detachCamera() {
30
+ camera = null
31
+ }
32
+
33
+ fun setTorch(on: Boolean) {
34
+ val cam = camera ?: run {
35
+ Log.w(TAG, "setTorch($on) — camera is null, skipping")
36
+ return
37
+ }
38
+ if (currentFacing == "front") {
39
+ Log.w(TAG, "setTorch($on) — front camera has no torch, ignoring")
40
+ return
41
+ }
42
+ Log.d(TAG, "setTorch($on)")
43
+ cam.cameraControl.enableTorch(on).addListener(
44
+ {
45
+ Log.d(TAG, "enableTorch($on) resolved — notifying")
46
+ onTorchChanged(on)
47
+ },
48
+ ContextCompat.getMainExecutor(context)
49
+ )
50
+ }
51
+
52
+ /** Called after every bindCamera — torch always resets to off on session start. */
53
+ fun notifyReset() {
54
+ onTorchChanged(false)
55
+ Log.d(TAG, "Torch reset to off (new session)")
56
+ }
57
+ }
@@ -0,0 +1,41 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ /**
4
+ * State machine for the video stream lifecycle.
5
+ *
6
+ * Valid transitions:
7
+ * IDLE → STARTING (start() called, permission check begins)
8
+ * STARTING → STREAMING (camera bound successfully)
9
+ * STARTING → IDLE (permission denied or bind failure)
10
+ * STREAMING → HOLD (QR detected — analyzer unbound for hold duration)
11
+ * HOLD → STREAMING (hold expired — analyzer rebound)
12
+ * STREAMING → FLIPPING (flip() called)
13
+ * FLIPPING → STREAMING (rebind complete after flip)
14
+ * STREAMING → STOPPING (stop() called)
15
+ * FLIPPING → STOPPING (stop() called mid-flip)
16
+ * HOLD → STOPPING (stop() called during hold)
17
+ * STOPPING → IDLE (unbind complete)
18
+ */
19
+ enum class VideoStreamState {
20
+ IDLE,
21
+ STARTING,
22
+ STREAMING,
23
+ HOLD,
24
+ FLIPPING,
25
+ STOPPING;
26
+
27
+ fun canTransitionTo(next: VideoStreamState): Boolean = when (this) {
28
+ IDLE -> next == STARTING
29
+ STARTING -> next == STREAMING || next == IDLE
30
+ STREAMING -> next == HOLD || next == FLIPPING || next == STOPPING
31
+ HOLD -> next == STREAMING || next == STOPPING
32
+ FLIPPING -> next == STREAMING || next == STOPPING
33
+ STOPPING -> next == IDLE
34
+ }
35
+
36
+ val isActive: Boolean get() = this == STREAMING || this == HOLD || this == FLIPPING
37
+ }
38
+
39
+ interface VideoStreamStateListener {
40
+ fun onStateChanged(prev: VideoStreamState, next: VideoStreamState)
41
+ }
@@ -0,0 +1,40 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.util.Log
4
+ import java.util.concurrent.CopyOnWriteArrayList
5
+
6
+ class VideoStreamStateMachine {
7
+
8
+ private val TAG = "VideoStreamStateMachine"
9
+
10
+ @Volatile
11
+ var state: VideoStreamState = VideoStreamState.IDLE
12
+ private set
13
+
14
+ private val listeners = CopyOnWriteArrayList<VideoStreamStateListener>()
15
+
16
+ fun addListener(listener: VideoStreamStateListener) {
17
+ if (!listeners.contains(listener)) listeners.add(listener)
18
+ }
19
+
20
+ fun removeListener(listener: VideoStreamStateListener) {
21
+ listeners.remove(listener)
22
+ }
23
+
24
+ @Synchronized
25
+ fun transition(next: VideoStreamState): Boolean {
26
+ val prev = state
27
+ if (!prev.canTransitionTo(next)) {
28
+ Log.w(TAG, "Invalid transition: $prev → $next, ignoring")
29
+ return false
30
+ }
31
+ state = next
32
+ Log.d(TAG, "State: $prev → $next")
33
+ // Notify outside the synchronized block to prevent deadlock if a listener
34
+ // calls back into transition() on the same thread.
35
+ val snapshot = listeners.toList()
36
+ return true.also { snapshot.forEach { it.onStateChanged(prev, next) } }
37
+ }
38
+
39
+ val isActive: Boolean get() = state.isActive
40
+ }
@@ -0,0 +1,75 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.graphics.RectF
4
+ import android.util.Log
5
+ import android.webkit.WebView
6
+ import org.json.JSONObject
7
+
8
+ /**
9
+ * Pure coordinate math — converts JS viewfinder rect → screen-absolute rect
10
+ * and maps ML Kit barcode bounding boxes from image space → screen space.
11
+ * Uses WebView for origin offset and PreviewView dimensions for scale.
12
+ */
13
+ object ViewfinderMapper {
14
+
15
+ private const val TAG = "ViewfinderMapper"
16
+
17
+ /**
18
+ * Parse viewfinder rect from JS JSON and convert to screen-absolute coordinates.
19
+ * JS sends coords relative to WebView top-left; we add the WebView's on-screen origin.
20
+ *
21
+ * @return Pair(screenAbsoluteRect, jsRelativeRect) or null if parsing fails.
22
+ */
23
+ fun parseViewfinderRect(json: JSONObject, webView: WebView): Pair<RectF, RectF>? {
24
+ return try {
25
+ val x = json.getDouble("x").toFloat()
26
+ val y = json.getDouble("y").toFloat()
27
+ val w = json.getDouble("width").toFloat()
28
+ val h = json.getDouble("height").toFloat()
29
+
30
+ val jsRect = RectF(x, y, x + w, y + h)
31
+
32
+ val loc = IntArray(2)
33
+ webView.getLocationOnScreen(loc)
34
+ val originX = loc[0].toFloat()
35
+ val originY = loc[1].toFloat()
36
+
37
+ val screenRect = RectF(x + originX, y + originY, x + originX + w, y + originY + h)
38
+ Log.d(TAG, "viewfinderScreenRect: left=${screenRect.left} top=${screenRect.top} right=${screenRect.right} bottom=${screenRect.bottom}")
39
+ Pair(screenRect, jsRect)
40
+ } catch (e: Exception) {
41
+ Log.w(TAG, "Failed to parse viewfinderRect: ${e.message}")
42
+ null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Map a barcode bounding box from ML Kit image space → screen space.
48
+ *
49
+ * ML Kit boxes are in post-rotation image space (after imgW/imgH swap).
50
+ * PreviewView uses FILL_CENTER — compute uniform scale + center offset.
51
+ */
52
+ fun mapBarcodeToScreen(
53
+ boxLeft: Int, boxTop: Int, boxRight: Int, boxBottom: Int,
54
+ imageWidth: Int, imageHeight: Int,
55
+ previewWidth: Float, previewHeight: Float
56
+ ): RectF? {
57
+ if (previewWidth == 0f || previewHeight == 0f) {
58
+ Log.w(TAG, "previewView has zero size — cannot map barcode to screen")
59
+ return null
60
+ }
61
+ if (imageWidth == 0 || imageHeight == 0) {
62
+ Log.w(TAG, "imageWidth/imageHeight is zero — cannot map barcode to screen")
63
+ return null
64
+ }
65
+ val scale = maxOf(previewWidth / imageWidth.toFloat(), previewHeight / imageHeight.toFloat())
66
+ val offsetX = (previewWidth - imageWidth * scale) / 2f
67
+ val offsetY = (previewHeight - imageHeight * scale) / 2f
68
+ return RectF(
69
+ boxLeft * scale + offsetX,
70
+ boxTop * scale + offsetY,
71
+ boxRight * scale + offsetX,
72
+ boxBottom * scale + offsetY
73
+ )
74
+ }
75
+ }
@@ -0,0 +1,156 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.util.Log
5
+ import android.view.MotionEvent
6
+ import android.view.ScaleGestureDetector
7
+ import android.view.animation.DecelerateInterpolator
8
+ import androidx.camera.core.Camera
9
+ import android.content.Context
10
+
11
+ /**
12
+ * Handles all zoom operations:
13
+ * - setZoom(multiplier: Float) via bridge — 1.0 = 1x, 2.0 = 2x
14
+ * - pinch-to-zoom via ScaleGestureDetector
15
+ * - auto-zoom suggestion callback (wired from BarcodeDetector)
16
+ *
17
+ * Fires ON_ZOOM_CHANGED via [onZoomChanged] callback after each zoom change.
18
+ * Payload: zoomLevel (Float multiplier, e.g. 1.5), minZoom, maxZoom.
19
+ */
20
+ class ZoomController(
21
+ context: Context,
22
+ private val stateMachine: VideoStreamStateMachine,
23
+ private val onZoomChanged: (zoomLevel: Float, minZoom: Float, maxZoom: Float) -> Unit
24
+ ) {
25
+
26
+ private val TAG = "ZoomController"
27
+
28
+ private var camera: Camera? = null
29
+
30
+ // Smooth auto-zoom state
31
+ private var zoomAnimator: ValueAnimator? = null
32
+ private var lastSuggestedRatio: Float = -1f
33
+ private var consecutiveSuggestionCount: Int = 0
34
+ private val SUGGESTION_CONFIRM_FRAMES = 2 // frames before committing to a zoom target
35
+ private val SUGGESTION_TOLERANCE = 0.1f // ratio delta considered "same target"
36
+ private val ZOOM_ANIMATION_DURATION_MS = 300L
37
+
38
+ private val scaleGestureDetector = ScaleGestureDetector(
39
+ context,
40
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
41
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
42
+ if (!stateMachine.isActive) return true
43
+ val cam = camera ?: return true
44
+ val zoomState = cam.cameraInfo.zoomState.value ?: return true
45
+ val newRatio = (zoomState.zoomRatio * detector.scaleFactor)
46
+ .coerceIn(zoomState.minZoomRatio, zoomState.maxZoomRatio)
47
+ cam.cameraControl.setZoomRatio(newRatio)
48
+ onZoomChanged(newRatio, zoomState.minZoomRatio, zoomState.maxZoomRatio)
49
+ return true
50
+ }
51
+ }
52
+ )
53
+
54
+ fun attachCamera(cam: Camera) {
55
+ camera = cam
56
+ }
57
+
58
+ fun detachCamera() {
59
+ cancelZoomAnimation()
60
+ camera = null
61
+ }
62
+
63
+ fun onTouchEvent(event: MotionEvent): Boolean {
64
+ return if (stateMachine.isActive) scaleGestureDetector.onTouchEvent(event) else false
65
+ }
66
+
67
+ /** Called from bridge: multiplier e.g. 1.0=1x, 2.0=2x */
68
+ fun setZoom(multiplier: Float) {
69
+ Log.d(TAG, "setZoom(${multiplier}x) called")
70
+ applyZoomMultiplier(multiplier)
71
+ }
72
+
73
+ fun applyZoomMultiplier(multiplier: Float) {
74
+ val cam = camera ?: run {
75
+ Log.w(TAG, "applyZoomMultiplier(${multiplier}x) — camera is null, skipping")
76
+ return
77
+ }
78
+ val zoomState = cam.cameraInfo.zoomState.value ?: run {
79
+ Log.w(TAG, "applyZoomMultiplier(${multiplier}x) — zoomState is null, skipping")
80
+ return
81
+ }
82
+ val min = zoomState.minZoomRatio
83
+ val max = zoomState.maxZoomRatio
84
+ val ratio = multiplier.coerceIn(min, max)
85
+ Log.d(TAG, "applyZoomMultiplier(${multiplier}x) → ratio=$ratio (min=$min max=$max)")
86
+ cam.cameraControl.setZoomRatio(ratio)
87
+ onZoomChanged(ratio, min, max)
88
+ }
89
+
90
+ /**
91
+ * Auto-zoom suggestion from ML Kit ZoomSuggestionOptions.
92
+ * Zoom-only-up guard: never apply a suggestion that would zoom out.
93
+ * Debounce: only commit after SUGGESTION_CONFIRM_FRAMES consecutive frames
94
+ * with the same target (within SUGGESTION_TOLERANCE).
95
+ * Animation: smoothly interpolates from current ratio to target over ZOOM_ANIMATION_DURATION_MS.
96
+ */
97
+ fun onAutoZoomSuggestion(suggestedRatio: Float): Boolean {
98
+ val cam = camera ?: return false
99
+ val zoomState = cam.cameraInfo.zoomState.value ?: return false
100
+ val clamped = suggestedRatio.coerceIn(zoomState.minZoomRatio, zoomState.maxZoomRatio)
101
+
102
+ // Zoom-only-up guard
103
+ if (clamped <= zoomState.zoomRatio) {
104
+ Log.d(TAG, "auto-zoom suggestion=$suggestedRatio ignored — would zoom out")
105
+ consecutiveSuggestionCount = 0
106
+ lastSuggestedRatio = -1f
107
+ return false
108
+ }
109
+
110
+ // Debounce: count consecutive frames pointing at the same target
111
+ if (Math.abs(clamped - lastSuggestedRatio) <= SUGGESTION_TOLERANCE) {
112
+ consecutiveSuggestionCount++
113
+ } else {
114
+ consecutiveSuggestionCount = 1
115
+ lastSuggestedRatio = clamped
116
+ }
117
+
118
+ if (consecutiveSuggestionCount < SUGGESTION_CONFIRM_FRAMES) {
119
+ Log.d(TAG, "auto-zoom debounce: $consecutiveSuggestionCount/$SUGGESTION_CONFIRM_FRAMES for target=$clamped")
120
+ return false
121
+ }
122
+
123
+ // Confirmed — reset counters and animate
124
+ consecutiveSuggestionCount = 0
125
+ lastSuggestedRatio = -1f
126
+
127
+ val fromRatio = zoomState.zoomRatio
128
+ val minZoom = zoomState.minZoomRatio
129
+ val maxZoom = zoomState.maxZoomRatio
130
+ Log.d(TAG, "auto-zoom: animating $fromRatio → $clamped")
131
+
132
+ zoomAnimator?.cancel()
133
+ zoomAnimator = ValueAnimator.ofFloat(fromRatio, clamped).apply {
134
+ duration = ZOOM_ANIMATION_DURATION_MS
135
+ interpolator = DecelerateInterpolator()
136
+ addUpdateListener { animator ->
137
+ val ratio = animator.animatedValue as Float
138
+ cam.cameraControl.setZoomRatio(ratio)
139
+ onZoomChanged(ratio, minZoom, maxZoom)
140
+ }
141
+ start()
142
+ }
143
+ return true
144
+ }
145
+
146
+ fun cancelZoomAnimation() {
147
+ zoomAnimator?.cancel()
148
+ zoomAnimator = null
149
+ consecutiveSuggestionCount = 0
150
+ lastSuggestedRatio = -1f
151
+ }
152
+
153
+ fun currentMaxZoomRatio(): Float {
154
+ return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 8.0f
155
+ }
156
+ }
@@ -0,0 +1,7 @@
1
+ package io.yourname.androidproject.plugins
2
+
3
+ import org.json.JSONObject
4
+
5
+ interface CatalystPlugin {
6
+ fun handle(command: String, data: JSONObject?, bridge: PluginBridgeContext)
7
+ }