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.
- package/bin/catalyst.js +8 -1
- package/changelog.md +6 -0
- package/dist/native/androidProject/app/build.gradle.kts +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +17 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +31 -3
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +148 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
- package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
- package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
- package/dist/native/bridge/WebBridge.js +5 -5
- package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
- package/dist/native/bridge/hooks/index.js +1 -0
- package/dist/native/bridge/hooks/useCamera.js +9 -0
- package/dist/native/bridge/hooks/useDataProtection.js +2 -0
- package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
- package/dist/native/bridge/hooks/useFilePicker.js +2 -0
- package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
- package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
- package/dist/native/bridge/hooks/useIntent.js +1 -0
- package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
- package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
- package/dist/native/bridge/hooks/useNotification.js +2 -0
- package/dist/native/bridge/hooks/useSafeArea.js +2 -0
- package/dist/native/bridge/hooks/useVideoStream.js +6 -0
- package/dist/native/bridge/hooks/utils.js +1 -0
- package/dist/native/bridge/hooks.js +1 -137
- package/dist/native/bridge/useBaseHook.js +8 -3
- package/dist/native/bridge/utils/NativeBridge.js +36 -1
- package/dist/native/buildAppAndroid.js +2 -2
- package/dist/native/buildAppIos.js +12 -17
- package/dist/native/internalPluginUtils.js +7 -0
- package/dist/native/iosnativeWebView/Package.resolved +36 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
- package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +38 -7
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +6 -2
- package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +15 -4
- package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/plugin-bridge/PluginBridge.js +1 -0
- package/dist/native/pluginComposerAndroid.js +28 -0
- package/dist/native/pluginComposerIos.js +16 -0
- package/dist/otel.js +70 -2
- package/dist/scripts/plugins.js +1 -0
- package/dist/server/renderer/extract.js +3 -3
- package/dist/server/renderer/handler.js +15 -14
- package/dist/webpack/development.client.babel.js +5 -1
- package/dist/webpack/production.ssr.babel.js +5 -1
- package/mcp_v2/knowledge-base.json +1564 -1067
- 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
|
+
}
|