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,337 @@
1
+ package io.yourname.androidproject
2
+
3
+ import android.graphics.Color
4
+ import android.graphics.RectF
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.util.Log
8
+ import android.view.MotionEvent
9
+ import android.view.View
10
+ import android.webkit.WebView
11
+ import android.widget.TextView
12
+ import androidx.camera.view.PreviewView
13
+ import com.google.mlkit.vision.barcode.common.Barcode
14
+ import io.yourname.androidproject.camera.*
15
+ import io.yourname.androidproject.utils.BridgeUtils
16
+ import org.json.JSONObject
17
+
18
+ /**
19
+ * Thin facade — wires all camera sub-components and exposes the public API
20
+ * that NativeBridge.kt calls. All logic lives in the camera/ package.
21
+ */
22
+ class NativeCameraManager(
23
+ private val activity: MainActivity,
24
+ private val previewView: PreviewView,
25
+ private val webView: WebView,
26
+ private val debugOverlay: View? = null,
27
+ private val debugQrStatus: TextView? = null,
28
+ private val debugBarcodeOverlay: View? = null
29
+ ) {
30
+
31
+ companion object {
32
+ private const val TAG = "NativeCameraManager"
33
+ const val PERMISSION_REQUEST_CODE = CameraSessionManager.PERMISSION_REQUEST_CODE
34
+
35
+ // QR overlay colors — change here to update both states
36
+ private const val COLOR_QR_IN_FRAME = "#5500FF00" // green, semi-transparent
37
+ private const val COLOR_QR_DETECTED = "#55FF6600" // orange, semi-transparent
38
+ private const val OVERLAY_HIDE_DELAY_MS = 200L
39
+ }
40
+
41
+ private val stateMachine = VideoStreamStateMachine()
42
+
43
+ private val zoomController = ZoomController(
44
+ context = activity,
45
+ stateMachine = stateMachine,
46
+ onZoomChanged = { zoomLevel, minZoom, maxZoom ->
47
+ val payload = JSONObject().apply {
48
+ // zoomLevel = raw CameraX zoomRatio (1.0=1x, 2.0=2x)
49
+ put("zoomLevel", Math.round(zoomLevel * 10.0) / 10.0)
50
+ put("minZoom", Math.round(minZoom * 10.0) / 10.0)
51
+ put("maxZoom", Math.round(maxZoom * 10.0) / 10.0)
52
+ }
53
+ BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_ZOOM_CHANGED, payload)
54
+ // Repaint overlay at new zoom level — barcode rect shifts as camera zooms
55
+ if (showQrDetected && overlayVisible) {
56
+ lastBarcodeScreenRect?.let { repaintQrOverlay(it, overlayIsOrange) }
57
+ }
58
+ }
59
+ )
60
+
61
+ private val torchController = TorchController(
62
+ context = activity,
63
+ onTorchChanged = { enabled ->
64
+ val payload = JSONObject().apply { put("enabled", enabled) }
65
+ BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_TORCH_CHANGED, payload)
66
+ }
67
+ )
68
+
69
+ private val barcodeDetector = BarcodeDetector(
70
+ zoomController = zoomController,
71
+ onDetected = { barcode, imgW, imgH -> onBarcodeDetected(barcode, imgW, imgH) }
72
+ )
73
+
74
+ private val sessionManager = CameraSessionManager(
75
+ activity = activity,
76
+ previewView = previewView,
77
+ stateMachine = stateMachine,
78
+ zoomController = zoomController,
79
+ torchController = torchController,
80
+ barcodeDetector = barcodeDetector,
81
+ onReady = {
82
+ BridgeUtils.notifyWeb(webView, BridgeUtils.WebEvents.ON_VIDEO_STREAM_READY)
83
+ },
84
+ onStopped = {
85
+ debugOverlay?.let { activity.runOnUiThread { it.visibility = View.GONE } }
86
+ BridgeUtils.notifyWeb(webView, BridgeUtils.WebEvents.ON_VIDEO_STREAM_STOPPED)
87
+ },
88
+ onError = { message ->
89
+ BridgeUtils.notifyWebError(webView, BridgeUtils.WebEvents.ON_CAMERA_ERROR, message)
90
+ }
91
+ )
92
+
93
+ private val holdController = HoldController(
94
+ stateMachine = stateMachine,
95
+ barcodeDetector = barcodeDetector
96
+ )
97
+
98
+ // Viewfinder state — written on main thread, read on analyzer thread
99
+ @Volatile private var viewfinderScreenRect: RectF? = null
100
+
101
+ // QR detected overlay state — written on analyzer thread, read on main thread and vice versa
102
+ @Volatile private var showQrDetected: Boolean = false
103
+ @Volatile private var overlayIsOrange: Boolean = false // logical state — set before showQrOverlay
104
+ @Volatile private var overlayPaintedOrange: Boolean = false // what color was last painted
105
+ @Volatile private var overlayVisible: Boolean = false
106
+ @Volatile private var lastBarcodeScreenRect: RectF? = null
107
+ private val overlayHideHandler = Handler(Looper.getMainLooper())
108
+ @Volatile private var overlayHideRunnable: Runnable? = null
109
+
110
+ init {
111
+ stateMachine.addListener(object : VideoStreamStateListener {
112
+ override fun onStateChanged(prev: VideoStreamState, next: VideoStreamState) {
113
+ // Reset overlay when hold ends (new scan cycle) or stream stops
114
+ if ((prev == VideoStreamState.HOLD && next == VideoStreamState.STREAMING) ||
115
+ next == VideoStreamState.IDLE || next == VideoStreamState.STOPPING
116
+ ) {
117
+ hideQrOverlay()
118
+ }
119
+ }
120
+ })
121
+ }
122
+
123
+ // ---- Public API (called from NativeBridge) ----
124
+
125
+ fun start(facing: String = "back", viewfinderRectJson: JSONObject? = null, zoomOptions: JSONObject? = null, scanFormat: String = "all", fpsMin: Int? = null, fpsMax: Int? = null, showQrDetected: Boolean = false) {
126
+ if (stateMachine.isActive) return
127
+
128
+ this.showQrDetected = showQrDetected
129
+ this.overlayIsOrange = false
130
+ this.overlayPaintedOrange = false
131
+ this.overlayVisible = false
132
+ this.lastBarcodeScreenRect = null
133
+
134
+ val autoZoom = zoomOptions?.optBoolean("auto", false) ?: false
135
+ val initialZoomPct = zoomOptions?.optDouble("initial", 1.0)?.toFloat() ?: 1.0f
136
+
137
+ // Parse viewfinder rect and position debug overlay
138
+ viewfinderRectJson?.let { json ->
139
+ val result = ViewfinderMapper.parseViewfinderRect(json, webView)
140
+ viewfinderScreenRect = result?.first
141
+ result?.second?.let { jsRect -> positionDebugOverlay(jsRect) }
142
+ } ?: run {
143
+ viewfinderScreenRect = null
144
+ Log.w(TAG, "No viewfinderRect — all QR codes will be reported")
145
+ }
146
+
147
+ stateMachine.transition(VideoStreamState.STARTING)
148
+ sessionManager.start(
149
+ facing = facing,
150
+ autoZoom = autoZoom,
151
+ initialZoomPct = initialZoomPct, // now Float multiplier (1.0 = 1x)
152
+ scanFormat = scanFormat,
153
+ fpsMin = fpsMin,
154
+ fpsMax = fpsMax
155
+ )
156
+ }
157
+
158
+ fun stop() {
159
+ if (!stateMachine.isActive) return
160
+ holdController.reset()
161
+ stateMachine.transition(VideoStreamState.STOPPING)
162
+ sessionManager.stop()
163
+ }
164
+
165
+ fun flip() {
166
+ if (!stateMachine.isActive) return
167
+ holdController.reset()
168
+ stateMachine.transition(VideoStreamState.FLIPPING)
169
+ sessionManager.flip()
170
+ }
171
+
172
+ fun setZoom(multiplier: Float) = zoomController.setZoom(multiplier)
173
+
174
+ fun setTorch(on: Boolean) = torchController.setTorch(on)
175
+
176
+ fun setFps(min: Int?, max: Int?) {
177
+ if (!stateMachine.isActive) return
178
+ sessionManager.setFps(min, max)
179
+ }
180
+
181
+ fun onTouchEvent(event: MotionEvent): Boolean = zoomController.onTouchEvent(event)
182
+
183
+ fun onPermissionResult(granted: Boolean) = sessionManager.onPermissionResult(granted)
184
+
185
+ fun cleanup() {
186
+ overlayHideRunnable?.let { overlayHideHandler.removeCallbacks(it) }
187
+ overlayHideRunnable = null
188
+ sessionManager.cleanup()
189
+ }
190
+
191
+ // ---- Detection handler ----
192
+
193
+ private fun onBarcodeDetected(barcode: Barcode, imageWidth: Int, imageHeight: Int) {
194
+ val value = barcode.rawValue ?: return
195
+
196
+ // Hold gate — defensive check for in-flight frames
197
+ if (stateMachine.state == VideoStreamState.HOLD) {
198
+ Log.d(TAG, "onBarcodeDetected — in hold, skipping: $value")
199
+ return
200
+ }
201
+
202
+ // QR overlay — show green immediately (QR in frame), update position each frame
203
+ val barcodeScreenRect = barcode.boundingBox?.let { box ->
204
+ ViewfinderMapper.mapBarcodeToScreen(
205
+ box.left, box.top, box.right, box.bottom,
206
+ imageWidth, imageHeight,
207
+ previewView.width.toFloat(), previewView.height.toFloat()
208
+ )
209
+ }
210
+ if (showQrDetected && barcodeScreenRect != null) {
211
+ showQrOverlay(barcodeScreenRect, orange = overlayIsOrange)
212
+ }
213
+
214
+ // Viewfinder region filter
215
+ val regionRect = viewfinderScreenRect
216
+ if (regionRect != null) {
217
+ if (barcodeScreenRect != null) {
218
+ if (!regionRect.contains(barcodeScreenRect)) {
219
+ updateDebugQrStatus(inside = false)
220
+ return
221
+ }
222
+ updateDebugQrStatus(inside = true)
223
+ }
224
+ }
225
+
226
+ // Same-value dedup — enter hold either way
227
+ if (!holdController.isNewValue(value)) {
228
+ Log.d(TAG, "onBarcodeDetected — same value, suppressing: $value")
229
+ holdController.startHold()
230
+ return
231
+ }
232
+
233
+ // New value confirmed — flip overlay to orange before firing event
234
+ if (showQrDetected && barcodeScreenRect != null) {
235
+ overlayIsOrange = true
236
+ showQrOverlay(barcodeScreenRect, orange = true)
237
+ }
238
+
239
+ holdController.startHold()
240
+
241
+ val payload = JSONObject().apply {
242
+ put("value", value)
243
+ put("format", BarcodeDetector.formatName(barcode.format))
244
+ }
245
+ BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_QR_DETECTED, payload)
246
+ Log.d(TAG, "QR detected (new): $value")
247
+ }
248
+
249
+ // ---- QR detected overlay ----
250
+
251
+ /**
252
+ * Called on every ML Kit frame that has a barcode.
253
+ * If overlay is already visible: only reschedule the hide — no layout/color update (prevents flicker).
254
+ * If overlay is not visible: position it, set color, show it.
255
+ * lastBarcodeScreenRect is always updated so zoom repaints use the freshest position.
256
+ */
257
+ private fun showQrOverlay(screenRect: RectF, orange: Boolean) {
258
+ lastBarcodeScreenRect = screenRect
259
+
260
+ // Reschedule hide regardless
261
+ overlayHideRunnable?.let { overlayHideHandler.removeCallbacks(it) }
262
+ val hideRunnable = Runnable { hideQrOverlayOnUiThread() }
263
+ overlayHideRunnable = hideRunnable
264
+ overlayHideHandler.postDelayed(hideRunnable, OVERLAY_HIDE_DELAY_MS)
265
+
266
+ if (overlayVisible && orange == overlayPaintedOrange) return // already painted at correct color — skip to prevent flicker
267
+
268
+ repaintQrOverlay(screenRect, orange)
269
+ }
270
+
271
+ /**
272
+ * Force-reposition and repaint the overlay. Called on first show and on zoom change.
273
+ * screenRect is in PreviewView/RelativeLayout space — no offset adjustment needed.
274
+ */
275
+ private fun repaintQrOverlay(screenRect: RectF, orange: Boolean) {
276
+ val color = Color.parseColor(if (orange) COLOR_QR_DETECTED else COLOR_QR_IN_FRAME)
277
+ overlayVisible = true
278
+ overlayPaintedOrange = orange
279
+ activity.runOnUiThread {
280
+ debugBarcodeOverlay?.let { overlay ->
281
+ val lp = overlay.layoutParams as? android.widget.RelativeLayout.LayoutParams ?: return@runOnUiThread
282
+ lp.leftMargin = screenRect.left.toInt()
283
+ lp.topMargin = screenRect.top.toInt()
284
+ lp.width = screenRect.width().toInt()
285
+ lp.height = screenRect.height().toInt()
286
+ overlay.layoutParams = lp
287
+ overlay.setBackgroundColor(color)
288
+ overlay.visibility = View.VISIBLE
289
+ }
290
+ }
291
+ }
292
+
293
+ private fun hideQrOverlay() {
294
+ overlayHideRunnable?.let { overlayHideHandler.removeCallbacks(it) }
295
+ overlayHideRunnable = null
296
+ overlayIsOrange = false
297
+ overlayPaintedOrange = false
298
+ lastBarcodeScreenRect = null
299
+ hideQrOverlayOnUiThread()
300
+ }
301
+
302
+ private fun hideQrOverlayOnUiThread() {
303
+ overlayVisible = false
304
+ activity.runOnUiThread {
305
+ debugBarcodeOverlay?.visibility = View.GONE
306
+ }
307
+ }
308
+
309
+ // ---- Debug overlay helpers ----
310
+
311
+ private fun positionDebugOverlay(jsRect: RectF) {
312
+ activity.runOnUiThread {
313
+ debugOverlay?.let { overlay ->
314
+ val lp = overlay.layoutParams as? android.widget.RelativeLayout.LayoutParams ?: return@runOnUiThread
315
+ lp.leftMargin = jsRect.left.toInt()
316
+ lp.topMargin = jsRect.top.toInt()
317
+ lp.width = jsRect.width().toInt()
318
+ lp.height = jsRect.height().toInt()
319
+ overlay.layoutParams = lp
320
+ overlay.setBackgroundResource(R.drawable.debug_viewfinder_border)
321
+ overlay.visibility = View.VISIBLE
322
+ }
323
+ }
324
+ }
325
+
326
+ private fun updateDebugQrStatus(inside: Boolean) {
327
+ activity.runOnUiThread {
328
+ if (inside) {
329
+ debugQrStatus?.setTextColor(Color.parseColor("#34C759"))
330
+ debugQrStatus?.text = "⬤ QR inside"
331
+ } else {
332
+ debugQrStatus?.setTextColor(Color.parseColor("#FF3B30"))
333
+ debugQrStatus?.text = "⬤ QR outside"
334
+ }
335
+ }
336
+ }
337
+ }
@@ -0,0 +1,202 @@
1
+ package io.yourname.androidproject
2
+
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.ValueAnimator
6
+ import android.app.Activity
7
+ import android.graphics.Bitmap
8
+ import android.graphics.Canvas
9
+ import android.os.Handler
10
+ import android.os.Looper
11
+ import android.util.Log
12
+ import android.view.View
13
+ import android.view.ViewGroup
14
+ import android.webkit.WebView
15
+ import android.widget.FrameLayout
16
+ import android.widget.ImageView
17
+ import io.yourname.androidproject.utils.BridgeUtils
18
+
19
+ /**
20
+ * Manages native page transitions using a snapshot-overlay pattern.
21
+ *
22
+ * Flow:
23
+ * startTransition() — captures a bitmap of the current WebView, places it as an
24
+ * ImageView overlay on top of the content, covering the skeleton
25
+ * rendered by the router while the new page loads.
26
+ * commitTransition() — animates the overlay out (slide or fade) to reveal the new page.
27
+ * cancelTransition() — removes the overlay immediately without animating.
28
+ *
29
+ * Safety timer: a Handler timer fires on the main thread after `timeout` ms if
30
+ * commitTransition() was never called (JS error/hang). It force-fades the overlay
31
+ * and fires ON_TRANSITION_TIMEOUT to JS so the hook can reset its state.
32
+ *
33
+ * Thread safety: all public methods must be called on the main thread.
34
+ */
35
+ class TransitionManager(
36
+ private val activity: Activity,
37
+ private val webView: WebView
38
+ ) {
39
+ private val TAG = "TransitionManager"
40
+ private val mainHandler = Handler(Looper.getMainLooper())
41
+
42
+ private var overlayView: ImageView? = null
43
+ private var safetyRunnable: Runnable? = null
44
+ private var activeDuration: Int = 300
45
+ private var activeType: String = "slide"
46
+ private var activeDirection: String = "left"
47
+
48
+ // -------------------------------------------------------------------------
49
+ // Public API
50
+ // -------------------------------------------------------------------------
51
+
52
+ fun startTransition(type: String, direction: String, duration: Int, timeout: Int) {
53
+ // Cancel any orphaned transition first
54
+ cancelTransitionInternal(notify = false)
55
+
56
+ activeType = type
57
+ activeDirection = direction
58
+ activeDuration = duration
59
+
60
+ val snapshot = captureSnapshot() ?: run {
61
+ Log.w(TAG, "startTransition — snapshot failed, transition skipped")
62
+ return
63
+ }
64
+
65
+ val overlay = ImageView(activity).apply {
66
+ setImageBitmap(snapshot)
67
+ layoutParams = ViewGroup.LayoutParams(
68
+ ViewGroup.LayoutParams.MATCH_PARENT,
69
+ ViewGroup.LayoutParams.MATCH_PARENT
70
+ )
71
+ scaleType = ImageView.ScaleType.MATRIX
72
+ }
73
+
74
+ val root = activity.window.decorView as ViewGroup
75
+ root.addView(overlay)
76
+ overlayView = overlay
77
+
78
+ Log.d(TAG, "startTransition — overlay attached (type=$type direction=$direction duration=${duration}ms timeout=${timeout}ms)")
79
+
80
+ // Arm safety timer
81
+ val safety = Runnable {
82
+ Log.w(TAG, "startTransition — safety timeout fired, force-removing overlay")
83
+ cancelTransitionInternal(notify = false)
84
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_TIMEOUT)
85
+ }
86
+ safetyRunnable = safety
87
+ mainHandler.postDelayed(safety, timeout.toLong())
88
+ }
89
+
90
+ fun commitTransition() {
91
+ val overlay = overlayView ?: run {
92
+ Log.w(TAG, "commitTransition — no active overlay, ignoring")
93
+ return
94
+ }
95
+
96
+ // Disarm safety timer — we're committing normally
97
+ disarmSafetyTimer()
98
+
99
+ when (activeType) {
100
+ "fade" -> animateFadeOut(overlay, activeDuration)
101
+ else -> animateSlideOut(overlay, activeDuration, activeDirection)
102
+ }
103
+ }
104
+
105
+ fun cancelTransition() {
106
+ cancelTransitionInternal(notify = true)
107
+ }
108
+
109
+ fun cleanup() {
110
+ cancelTransitionInternal(notify = false)
111
+ }
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Private helpers
115
+ // -------------------------------------------------------------------------
116
+
117
+ private fun captureSnapshot(): Bitmap? {
118
+ return try {
119
+ val bmp = Bitmap.createBitmap(webView.width, webView.height, Bitmap.Config.ARGB_8888)
120
+ val canvas = Canvas(bmp)
121
+ webView.draw(canvas)
122
+ bmp
123
+ } catch (e: Exception) {
124
+ Log.e(TAG, "captureSnapshot failed", e)
125
+ null
126
+ }
127
+ }
128
+
129
+ private fun animateSlideOut(overlay: ImageView, duration: Int, direction: String) {
130
+ val root = activity.window.decorView as ViewGroup
131
+ val w = root.width.toFloat()
132
+ val h = root.height.toFloat()
133
+
134
+ val (endX, endY) = when (direction) {
135
+ "right" -> Pair(-w, 0f)
136
+ "up" -> Pair(0f, h)
137
+ "down" -> Pair(0f, -h)
138
+ else -> Pair(w, 0f) // "left" — overlay slides out to the right
139
+ }
140
+
141
+ ValueAnimator.ofFloat(0f, 1f).apply {
142
+ this.duration = duration.toLong()
143
+ addUpdateListener { anim ->
144
+ val fraction = anim.animatedFraction
145
+ overlay.translationX = endX * fraction
146
+ overlay.translationY = endY * fraction
147
+ }
148
+ addListener(object : AnimatorListenerAdapter() {
149
+ override fun onAnimationEnd(animation: Animator) {
150
+ removeOverlay(overlay)
151
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_COMMITTED)
152
+ }
153
+ })
154
+ start()
155
+ }
156
+ }
157
+
158
+ private fun animateFadeOut(overlay: ImageView, duration: Int) {
159
+ ValueAnimator.ofFloat(1f, 0f).apply {
160
+ this.duration = duration.toLong()
161
+ addUpdateListener { anim ->
162
+ overlay.alpha = anim.animatedValue as Float
163
+ }
164
+ addListener(object : AnimatorListenerAdapter() {
165
+ override fun onAnimationEnd(animation: Animator) {
166
+ removeOverlay(overlay)
167
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_COMMITTED)
168
+ }
169
+ })
170
+ start()
171
+ }
172
+ }
173
+
174
+ private fun cancelTransitionInternal(notify: Boolean) {
175
+ disarmSafetyTimer()
176
+
177
+ overlayView?.let { overlay ->
178
+ removeOverlay(overlay)
179
+ if (notify) {
180
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_CANCELLED)
181
+ }
182
+ }
183
+ }
184
+
185
+ private fun removeOverlay(overlay: ImageView) {
186
+ try {
187
+ val root = activity.window.decorView as ViewGroup
188
+ root.removeView(overlay)
189
+ } catch (e: Exception) {
190
+ Log.w(TAG, "removeOverlay — view already detached: ${e.message}")
191
+ }
192
+ if (overlayView === overlay) {
193
+ overlayView = null
194
+ }
195
+ overlay.setImageBitmap(null)
196
+ }
197
+
198
+ private fun disarmSafetyTimer() {
199
+ safetyRunnable?.let { mainHandler.removeCallbacks(it) }
200
+ safetyRunnable = null
201
+ }
202
+ }
@@ -0,0 +1,123 @@
1
+ package io.yourname.androidproject.camera
2
+
3
+ import android.util.Log
4
+ import androidx.camera.core.ImageAnalysis
5
+ import androidx.camera.core.ImageProxy
6
+ import com.google.mlkit.vision.barcode.BarcodeScanner
7
+ import com.google.mlkit.vision.barcode.BarcodeScannerOptions
8
+ import com.google.mlkit.vision.barcode.BarcodeScanning
9
+ import com.google.mlkit.vision.barcode.ZoomSuggestionOptions
10
+ import com.google.mlkit.vision.barcode.common.Barcode
11
+ import com.google.mlkit.vision.common.InputImage
12
+ import java.util.concurrent.ExecutorService
13
+
14
+ /**
15
+ * Wraps ML Kit barcode scanning.
16
+ * Builds the scanner with format filter and optional auto-zoom.
17
+ * Fires [onDetected] for each barcode found in a frame.
18
+ */
19
+ class BarcodeDetector(
20
+ private val zoomController: ZoomController,
21
+ private val onDetected: (barcode: Barcode, imageWidth: Int, imageHeight: Int) -> Unit
22
+ ) {
23
+
24
+ private val TAG = "BarcodeDetector"
25
+
26
+ private var scanner: BarcodeScanner? = null
27
+
28
+ /** When true, frames are still analyzed (no flicker) but results are not forwarded. */
29
+ @Volatile var suppressResults = false
30
+
31
+ /** scan: "qr" | "barcode" | "all" */
32
+ fun buildImageAnalysis(
33
+ executor: ExecutorService,
34
+ resolutionSelector: androidx.camera.core.resolutionselector.ResolutionSelector,
35
+ autoZoomEnabled: Boolean,
36
+ scanFormat: String
37
+ ): ImageAnalysis {
38
+ val formats = resolveFormats(scanFormat)
39
+ val optionsBuilder = BarcodeScannerOptions.Builder()
40
+ .setBarcodeFormats(formats.first(), *formats.drop(1).toIntArray())
41
+
42
+ if (autoZoomEnabled) {
43
+ Log.d(TAG, "Auto-zoom enabled, wiring ZoomSuggestionOptions")
44
+ optionsBuilder.setZoomSuggestionOptions(
45
+ ZoomSuggestionOptions.Builder { suggestedRatio ->
46
+ zoomController.onAutoZoomSuggestion(suggestedRatio)
47
+ }
48
+ .setMaxSupportedZoomRatio(zoomController.currentMaxZoomRatio())
49
+ .build()
50
+ )
51
+ }
52
+
53
+ scanner?.close()
54
+ val newScanner = BarcodeScanning.getClient(optionsBuilder.build())
55
+ scanner = newScanner
56
+
57
+ return ImageAnalysis.Builder()
58
+ .setResolutionSelector(resolutionSelector)
59
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
60
+ .build()
61
+ .also { analysis ->
62
+ // Capture newScanner (not the field) so this analyzer always uses
63
+ // the scanner instance that was live when buildImageAnalysis() ran.
64
+ analysis.setAnalyzer(executor) { imageProxy ->
65
+ processFrame(imageProxy, newScanner)
66
+ }
67
+ }
68
+ }
69
+
70
+ fun close() {
71
+ scanner?.close()
72
+ scanner = null
73
+ }
74
+
75
+ private fun processFrame(imageProxy: ImageProxy, activeScanner: BarcodeScanner) {
76
+ val mediaImage = imageProxy.image
77
+ if (mediaImage == null) {
78
+ imageProxy.close()
79
+ return
80
+ }
81
+ val rotDeg = imageProxy.imageInfo.rotationDegrees
82
+ val image = InputImage.fromMediaImage(mediaImage, rotDeg)
83
+ // Swap w/h for 90°/270° — ML Kit boxes are in post-rotation space
84
+ val imgW = if (rotDeg == 90 || rotDeg == 270) imageProxy.height else imageProxy.width
85
+ val imgH = if (rotDeg == 90 || rotDeg == 270) imageProxy.width else imageProxy.height
86
+
87
+ activeScanner.process(image)
88
+ .addOnSuccessListener { barcodes ->
89
+ if (!suppressResults) {
90
+ barcodes.forEach { onDetected(it, imgW, imgH) }
91
+ }
92
+ }
93
+ .addOnFailureListener { e -> Log.w(TAG, "Barcode processing failed", e) }
94
+ .addOnCompleteListener { imageProxy.close() }
95
+ }
96
+
97
+ private fun resolveFormats(scan: String): List<Int> = when (scan) {
98
+ "qr" -> listOf(Barcode.FORMAT_QR_CODE)
99
+ "barcode" -> listOf(
100
+ Barcode.FORMAT_EAN_13, Barcode.FORMAT_EAN_8,
101
+ Barcode.FORMAT_CODE_128, Barcode.FORMAT_CODE_39,
102
+ Barcode.FORMAT_UPC_A, Barcode.FORMAT_UPC_E,
103
+ Barcode.FORMAT_PDF417
104
+ )
105
+ else -> listOf(Barcode.FORMAT_ALL_FORMATS)
106
+ }
107
+
108
+ companion object {
109
+ fun formatName(format: Int): String = when (format) {
110
+ Barcode.FORMAT_QR_CODE -> "QR"
111
+ Barcode.FORMAT_EAN_13 -> "EAN_13"
112
+ Barcode.FORMAT_EAN_8 -> "EAN_8"
113
+ Barcode.FORMAT_CODE_128 -> "CODE_128"
114
+ Barcode.FORMAT_CODE_39 -> "CODE_39"
115
+ Barcode.FORMAT_DATA_MATRIX -> "DATA_MATRIX"
116
+ Barcode.FORMAT_PDF417 -> "PDF417"
117
+ Barcode.FORMAT_AZTEC -> "AZTEC"
118
+ Barcode.FORMAT_UPC_A -> "UPC_A"
119
+ Barcode.FORMAT_UPC_E -> "UPC_E"
120
+ else -> "UNKNOWN"
121
+ }
122
+ }
123
+ }