catalyst-core 0.1.0-canary.8 → 0.2.0-beta.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/README.md +1 -1
  2. package/changelog.md +5 -0
  3. package/dist/native/androidProject/app/build.gradle.kts +9 -0
  4. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +2 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +19 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +13 -0
  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/WebCacheManager.kt +22 -15
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
  13. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
  14. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
  15. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/security/FridaDetector.kt +16 -5
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +7 -3
  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/androidSetup.js +1 -0
  25. package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
  26. package/dist/native/bridge/hooks/index.js +1 -0
  27. package/dist/native/bridge/hooks/useCamera.js +9 -0
  28. package/dist/native/bridge/hooks/useDataProtection.js +2 -0
  29. package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
  30. package/dist/native/bridge/hooks/useFilePicker.js +2 -0
  31. package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
  32. package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
  33. package/dist/native/bridge/hooks/useIntent.js +1 -0
  34. package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
  35. package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
  36. package/dist/native/bridge/hooks/useNotification.js +2 -0
  37. package/dist/native/bridge/hooks/useSafeArea.js +2 -0
  38. package/dist/native/bridge/hooks/useVideoStream.js +6 -0
  39. package/dist/native/bridge/hooks/utils.js +1 -0
  40. package/dist/native/bridge/hooks.js +1 -137
  41. package/dist/native/bridge/useBaseHook.js +12 -7
  42. package/dist/native/bridge/utils/NativeBridge.js +41 -6
  43. package/dist/native/buildAndroid/assets.js +52 -0
  44. package/dist/native/buildAndroid/build.js +19 -0
  45. package/dist/native/buildAndroid/config.js +1 -0
  46. package/dist/native/buildAndroid/index.js +1 -0
  47. package/dist/native/buildAppAndroid.js +2 -150
  48. package/dist/native/buildAppIos.js +2 -305
  49. package/dist/native/buildIos/assets.js +8 -0
  50. package/dist/native/buildIos/build.js +40 -0
  51. package/dist/native/buildIos/config.js +71 -0
  52. package/dist/native/buildIos/index.js +6 -0
  53. package/dist/native/buildIos/plugins.js +45 -0
  54. package/dist/native/internalPluginUtils.js +7 -1
  55. package/dist/native/iosnativeWebView/Package.resolved +36 -0
  56. package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
  57. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
  58. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
  59. package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
  60. package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
  61. package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
  62. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
  63. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
  64. package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
  65. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
  66. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +2 -1
  67. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
  68. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +214 -2
  69. package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
  70. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +32 -7
  71. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +6 -2
  72. package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +30 -11
  73. package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
  74. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -2
  75. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BootTimingUtilityTests.swift +1 -1
  76. package/dist/native/iosnativeWebView/iosnativeWebViewTests/ConfigMappingTests.swift +4 -2
  77. package/dist/native/pluginComposerAndroid.js +23 -4
  78. package/dist/native/pluginComposerIos.js +11 -2
  79. package/dist/native/renameAndroidProject.js +3 -3
  80. package/dist/native/setupEmulatorIos.js +6 -4
  81. package/dist/native/setupServer.js +2 -3
  82. package/dist/native/utils.js +4 -1
  83. package/dist/otel.js +70 -2
  84. package/dist/scripts/build.js +1 -1
  85. package/dist/scripts/cleanCache.js +1 -0
  86. package/dist/scripts/devBuild.js +1 -1
  87. package/dist/scripts/devServe.js +1 -1
  88. package/dist/scripts/loadEnvironmentVariables.js +1 -2
  89. package/dist/scripts/registerAliases.js +6 -2
  90. package/dist/scripts/scriptUtils.js +2 -1
  91. package/dist/scripts/serve.js +2 -1
  92. package/dist/scripts/start.js +7 -2
  93. package/dist/server/expressServer.js +3 -8
  94. package/dist/server/renderer/document/Body.js +4 -4
  95. package/dist/server/renderer/document/Head.js +2 -2
  96. package/dist/server/renderer/extract.js +31 -27
  97. package/dist/server/renderer/handler.js +47 -24
  98. package/dist/server/renderer/index.js +1 -1
  99. package/dist/server/startServer.js +57 -7
  100. package/dist/server/utils/userAgentUtil.js +9 -3
  101. package/dist/server/utils/validator.js +1 -1
  102. package/dist/webpack/babel-plugins/remove-client.plugin.js +2 -2
  103. package/dist/webpack/babel-plugins/remove-ssr.plugin.js +2 -2
  104. package/dist/webpack/babel.config.client.js +1 -1
  105. package/dist/webpack/babel.config.ssr.js +1 -1
  106. package/dist/webpack/base.babel.js +7 -8
  107. package/dist/webpack/development.client.babel.js +7 -16
  108. package/dist/webpack/production.client.babel.js +1 -1
  109. package/dist/webpack/production.ssr.babel.js +5 -1
  110. package/mcp_v2/knowledge-base.json +193 -100
  111. package/mcp_v2/lib/helpers.js +52 -12
  112. package/mcp_v2/lib/seed.js +88 -0
  113. package/mcp_v2/mcp.js +154 -7
  114. package/mcp_v2/setup.js +34 -195
  115. package/mcp_v2/tools/build.js +1 -1
  116. package/mcp_v2/tools/config.js +16 -4
  117. package/mcp_v2/tools/conversion.js +11 -11
  118. package/mcp_v2/tools/github.js +1117 -0
  119. package/mcp_v2/tools/knowledge.js +16 -3
  120. package/mcp_v2/tools/sync.js +179 -10
  121. package/mcp_v2/tools/tasks.js +13 -4
  122. package/package.json +25 -21
  123. package/.release-meta.json +0 -4
  124. package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +0 -43
  125. package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +0 -28
  126. package/dist/native/internal-plugins/device-info-plugin/manifest.json +0 -19
  127. package/license +0 -10
@@ -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
+ }
@@ -330,25 +330,27 @@ class WebCacheManager(private val context: Context, private val properties: java
330
330
  val eTag = connection.getHeaderField("ETag")
331
331
  val lastModified = connection.getHeaderField("Last-Modified")
332
332
 
333
+ // Capture all response headers for replay on cache hits
334
+ val savedHeaders = mutableMapOf<String, String>()
335
+ var i = 0
336
+ while (true) {
337
+ val key = connection.getHeaderFieldKey(i) ?: break
338
+ val value = connection.getHeaderField(i)
339
+ if (key.isNotEmpty()) savedHeaders[key] = value
340
+ i++
341
+ }
342
+
333
343
  val responseBytes = connection.inputStream.use { it.readBytes() }
334
344
  if (!isValidResponse(mimeType, responseBytes)) {
335
345
  return@withContext null
336
346
  }
337
347
 
338
348
  // Create response for immediate use
339
- val response = WebResourceResponse(
340
- mimeType,
341
- encoding,
342
- ByteArrayInputStream(responseBytes)
343
- )
349
+ val response = WebResourceResponse(mimeType, encoding, 200, "OK", savedHeaders, ByteArrayInputStream(responseBytes))
344
350
 
345
351
  // Cache the response
346
352
  val cacheEntry = CacheEntry(
347
- response = WebResourceResponse(
348
- mimeType,
349
- encoding,
350
- ByteArrayInputStream(responseBytes.copyOf())
351
- ),
353
+ response = WebResourceResponse(mimeType, encoding, 200, "OK", savedHeaders, ByteArrayInputStream(responseBytes.copyOf())),
352
354
  eTag = eTag,
353
355
  lastModified = lastModified
354
356
  )
@@ -363,7 +365,8 @@ class WebCacheManager(private val context: Context, private val properties: java
363
365
  mimeType = mimeType,
364
366
  encoding = encoding,
365
367
  eTag = eTag,
366
- lastModified = lastModified
368
+ lastModified = lastModified,
369
+ responseHeaders = savedHeaders
367
370
  ))
368
371
  if (BuildConfig.DEBUG) {
369
372
  Log.d(TAG, "✅ Successfully cached response for: $url")
@@ -391,25 +394,29 @@ class WebCacheManager(private val context: Context, private val properties: java
391
394
  val mimeType: String,
392
395
  val encoding: String,
393
396
  val eTag: String? = null,
394
- val lastModified: String? = null
397
+ val lastModified: String? = null,
398
+ val responseHeaders: Map<String, String> = emptyMap()
395
399
  ) : Serializable
396
400
 
397
401
  private fun createResponseFromCache(cacheFile: File, metadata: CacheMetadata): WebResourceResponse {
398
402
  return WebResourceResponse(
399
403
  metadata.mimeType,
400
404
  metadata.encoding,
405
+ 200,
406
+ "OK",
407
+ metadata.responseHeaders.ifEmpty { null },
401
408
  BufferedInputStream(FileInputStream(cacheFile), BUFFER_SIZE)
402
409
  )
403
410
  }
404
411
 
405
412
  private fun generateCacheKey(url: String): String {
406
413
  try {
407
- val md = MessageDigest.getInstance("MD5")
414
+ val md = MessageDigest.getInstance("SHA-256")
408
415
  val digest = md.digest(url.toByteArray())
409
416
  return digest.joinToString("") { "%02x".format(it) }
410
417
  } catch (e: Exception) {
411
418
  Log.e(TAG, "Error generating cache key: ${e.message}")
412
- // Fallback to a simple hash code if MD5 fails
419
+ // Fallback to a simple hash code if SHA-256 fails
413
420
  return url.hashCode().toString()
414
421
  }
415
422
  }
@@ -459,4 +466,4 @@ class WebCacheManager(private val context: Context, private val properties: java
459
466
  )
460
467
  }
461
468
  }
462
- }
469
+ }
@@ -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
+ }
@@ -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
+ }