react-native-rectangle-doc-scanner 7.65.0 → 9.0.0

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.
@@ -1,90 +1,53 @@
1
1
  package com.reactnativerectangledocscanner
2
2
 
3
- import android.Manifest
4
3
  import android.content.Context
5
- import android.content.pm.PackageManager
6
- import android.graphics.Bitmap
7
- import android.graphics.BitmapFactory
8
- import android.graphics.Matrix
9
- import android.graphics.SurfaceTexture
10
- import android.graphics.Rect
11
- import android.graphics.RectF
12
- import android.graphics.ImageFormat
13
- import android.hardware.camera2.CameraCaptureSession
14
- import android.hardware.camera2.CameraCharacteristics
15
- import android.hardware.camera2.CameraDevice
16
- import android.hardware.camera2.CameraManager
17
- import android.hardware.camera2.CaptureRequest
18
- import android.media.Image
19
- import android.media.ImageReader
20
- import android.os.Handler
21
- import android.os.HandlerThread
22
4
  import android.util.Log
23
- import android.util.Size
24
- import android.view.Surface
25
- import android.view.TextureView
5
+ import androidx.camera.core.*
6
+ import androidx.camera.lifecycle.ProcessCameraProvider
7
+ import androidx.camera.view.PreviewView
26
8
  import androidx.core.content.ContextCompat
27
- import androidx.exifinterface.media.ExifInterface
9
+ import androidx.lifecycle.LifecycleOwner
28
10
  import com.google.mlkit.vision.common.InputImage
29
11
  import com.google.mlkit.vision.objects.ObjectDetection
30
12
  import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions
31
- import org.opencv.core.Point
32
13
  import java.io.File
33
- import java.io.FileOutputStream
34
- import java.io.ByteArrayInputStream
35
- import java.util.concurrent.atomic.AtomicReference
36
- import java.util.concurrent.atomic.AtomicBoolean
37
- import kotlin.math.abs
38
- import kotlin.math.max
39
- import kotlin.math.min
14
+ import java.util.concurrent.ExecutorService
15
+ import java.util.concurrent.Executors
40
16
 
17
+ /**
18
+ * CameraX-based camera controller for document scanning
19
+ * Handles Preview, ImageAnalysis (ML Kit + OpenCV), and ImageCapture
20
+ */
41
21
  class CameraController(
42
22
  private val context: Context,
43
- private val lifecycleOwner: androidx.lifecycle.LifecycleOwner,
44
- private val previewView: TextureView
23
+ private val lifecycleOwner: LifecycleOwner,
24
+ private val previewView: PreviewView
45
25
  ) {
46
- private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
47
- private var cameraDevice: CameraDevice? = null
48
- private var captureSession: CameraCaptureSession? = null
49
- private var previewRequestBuilder: CaptureRequest.Builder? = null
26
+ private var camera: Camera? = null
27
+ private var cameraProvider: ProcessCameraProvider? = null
28
+ private var preview: Preview? = null
29
+ private var imageAnalyzer: ImageAnalysis? = null
30
+ private var imageCapture: ImageCapture? = null
50
31
 
51
- private var previewSize: Size? = null
52
- private var analysisSize: Size? = null
53
- private var captureSize: Size? = null
54
- private var sensorOrientation: Int = 0
55
-
56
- private var yuvReader: ImageReader? = null
57
- private var jpegReader: ImageReader? = null
58
-
59
- private val cameraThread = HandlerThread("Camera2Thread").apply { start() }
60
- private val cameraHandler = Handler(cameraThread.looper)
61
- private val analysisThread = HandlerThread("Camera2Analysis").apply { start() }
62
- private val analysisHandler = Handler(analysisThread.looper)
32
+ private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
63
33
 
64
34
  private var useFrontCamera = false
65
35
  private var detectionEnabled = true
66
- private var torchEnabled = false
67
-
68
- private val pendingCapture = AtomicReference<PendingCapture?>()
69
- private val analysisInFlight = AtomicBoolean(false)
70
- private var latestTransform: Matrix? = null
71
- private var latestBufferWidth = 0
72
- private var latestBufferHeight = 0
73
- private var latestTransformRotation = 0
36
+
74
37
  private val objectDetector = ObjectDetection.getClient(
75
38
  ObjectDetectorOptions.Builder()
76
39
  .setDetectorMode(ObjectDetectorOptions.STREAM_MODE)
77
40
  .enableMultipleObjects()
78
41
  .build()
79
42
  )
80
- private var lastRectangle: Rectangle? = null
81
- private var lastRectangleTimestamp = 0L
82
43
 
83
44
  var onFrameAnalyzed: ((Rectangle?, Int, Int) -> Unit)? = null
84
45
 
46
+ private var pendingCapture: PendingCapture? = null
47
+
85
48
  companion object {
86
49
  private const val TAG = "CameraController"
87
- private const val ANALYSIS_ASPECT_TOLERANCE = 0.15
50
+ private const val ANALYSIS_TARGET_RESOLUTION = 1280 // Max dimension for analysis
88
51
  }
89
52
 
90
53
  private data class PendingCapture(
@@ -93,859 +56,297 @@ class CameraController(
93
56
  val onError: (Exception) -> Unit
94
57
  )
95
58
 
96
- private val textureListener = object : TextureView.SurfaceTextureListener {
97
- override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
98
- openCamera()
99
- }
100
-
101
- override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
102
- configureTransform()
103
- }
104
-
105
- override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
106
- return true
107
- }
108
-
109
- override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
110
- // no-op
111
- }
112
- }
113
-
114
- fun startCamera(
115
- useFrontCam: Boolean = false,
116
- enableDetection: Boolean = true
117
- ) {
118
- Log.d(TAG, "[CAMERA2] startCamera called")
119
- this.useFrontCamera = useFrontCam
120
- this.detectionEnabled = enableDetection
121
-
122
- if (!hasCameraPermission()) {
123
- Log.e(TAG, "[CAMERA2] Camera permission not granted")
124
- return
125
- }
126
-
127
- // Always set the listener so we get size-change callbacks for transform updates.
128
- previewView.surfaceTextureListener = textureListener
129
- if (previewView.isAvailable) {
130
- openCamera()
131
- }
132
- }
59
+ fun startCamera(useFront: Boolean = false, enableDetection: Boolean = true) {
60
+ Log.d(TAG, "[CAMERAX] startCamera called: useFront=$useFront, enableDetection=$enableDetection")
133
61
 
134
- fun stopCamera() {
135
- Log.d(TAG, "[CAMERA2] stopCamera called")
136
- previewView.surfaceTextureListener = null
137
- closeSession()
138
- }
62
+ useFrontCamera = useFront
63
+ detectionEnabled = enableDetection
139
64
 
140
- fun refreshTransform() {
141
- configureTransform()
142
- }
65
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
143
66
 
144
- fun capturePhoto(
145
- outputDirectory: File,
146
- onImageCaptured: (File) -> Unit,
147
- onError: (Exception) -> Unit
148
- ) {
149
- val device = cameraDevice
150
- val session = captureSession
151
- val reader = jpegReader
152
- if (device == null || session == null || reader == null) {
153
- onError(IllegalStateException("Camera not ready for capture"))
154
- return
155
- }
156
-
157
- if (!pendingCapture.compareAndSet(null, PendingCapture(outputDirectory, onImageCaptured, onError))) {
158
- onError(IllegalStateException("Capture already in progress"))
159
- return
160
- }
161
-
162
- try {
163
- // Match JPEG orientation to current device rotation and sensor orientation.
164
- val jpegOrientation = computeRotationDegrees()
165
- Log.d(TAG, "[CAPTURE] Setting JPEG_ORIENTATION to $jpegOrientation")
166
-
167
- val requestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
168
- addTarget(reader.surface)
169
- set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
170
- set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
171
- if (torchEnabled) {
172
- set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH)
173
- }
174
- set(CaptureRequest.JPEG_ORIENTATION, jpegOrientation)
67
+ cameraProviderFuture.addListener({
68
+ try {
69
+ cameraProvider = cameraProviderFuture.get()
70
+ bindCameraUseCases()
71
+ } catch (e: Exception) {
72
+ Log.e(TAG, "[CAMERAX] Failed to start camera", e)
175
73
  }
176
-
177
- session.capture(requestBuilder.build(), object : CameraCaptureSession.CaptureCallback() {}, cameraHandler)
178
- } catch (e: Exception) {
179
- pendingCapture.getAndSet(null)?.onError?.invoke(e)
180
- }
74
+ }, ContextCompat.getMainExecutor(context))
181
75
  }
182
76
 
183
- fun setTorchEnabled(enabled: Boolean) {
184
- torchEnabled = enabled
185
- updateRepeatingRequest()
186
- }
187
-
188
- fun switchCamera() {
189
- useFrontCamera = !useFrontCamera
190
- closeSession()
191
- openCamera()
192
- }
193
-
194
- fun isTorchAvailable(): Boolean {
195
- return try {
196
- val cameraId = selectCameraId() ?: return false
197
- val characteristics = cameraManager.getCameraCharacteristics(cameraId)
198
- characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
199
- } catch (e: Exception) {
200
- false
201
- }
202
- }
203
-
204
- fun focusAt(x: Float, y: Float) {
205
- // Optional: implement touch-to-focus if needed.
206
- }
207
-
208
- fun shutdown() {
209
- stopCamera()
210
- objectDetector.close()
211
- cameraThread.quitSafely()
212
- analysisThread.quitSafely()
213
- }
214
-
215
- fun mapRectangleToView(rectangle: Rectangle?, imageWidth: Int, imageHeight: Int): Rectangle? {
216
- val transform = latestTransform ?: return null
217
- if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
218
- if (latestBufferWidth <= 0 || latestBufferHeight <= 0) return null
219
-
220
- val rotationDegrees = latestTransformRotation
221
- val inverseRotation = (360 - rotationDegrees) % 360
222
-
223
- fun rotatePoint(point: Point): Point {
224
- return when (inverseRotation) {
225
- 90 -> Point(imageHeight - point.y, point.x)
226
- 180 -> Point(imageWidth - point.x, imageHeight - point.y)
227
- 270 -> Point(point.y, imageWidth - point.x)
228
- else -> point
229
- }
230
- }
231
-
232
- val rotated = Rectangle(
233
- rotatePoint(rectangle.topLeft),
234
- rotatePoint(rectangle.topRight),
235
- rotatePoint(rectangle.bottomLeft),
236
- rotatePoint(rectangle.bottomRight)
237
- )
238
-
239
- val bufferWidth = if (inverseRotation == 90 || inverseRotation == 270) {
240
- imageHeight.toDouble()
241
- } else {
242
- imageWidth.toDouble()
243
- }
244
- val bufferHeight = if (inverseRotation == 90 || inverseRotation == 270) {
245
- imageWidth.toDouble()
246
- } else {
247
- imageHeight.toDouble()
248
- }
249
-
250
- val scaleX = latestBufferWidth.toDouble() / bufferWidth
251
- val scaleY = latestBufferHeight.toDouble() / bufferHeight
252
-
253
- fun scalePoint(point: Point): Point {
254
- return Point(point.x * scaleX, point.y * scaleY)
255
- }
256
-
257
- val scaled = Rectangle(
258
- scalePoint(rotated.topLeft),
259
- scalePoint(rotated.topRight),
260
- scalePoint(rotated.bottomLeft),
261
- scalePoint(rotated.bottomRight)
262
- )
263
-
264
- val pts = floatArrayOf(
265
- scaled.topLeft.x.toFloat(), scaled.topLeft.y.toFloat(),
266
- scaled.topRight.x.toFloat(), scaled.topRight.y.toFloat(),
267
- scaled.bottomLeft.x.toFloat(), scaled.bottomLeft.y.toFloat(),
268
- scaled.bottomRight.x.toFloat(), scaled.bottomRight.y.toFloat()
269
- )
270
- transform.mapPoints(pts)
271
-
272
- return Rectangle(
273
- Point(pts[0].toDouble(), pts[1].toDouble()),
274
- Point(pts[2].toDouble(), pts[3].toDouble()),
275
- Point(pts[4].toDouble(), pts[5].toDouble()),
276
- Point(pts[6].toDouble(), pts[7].toDouble())
277
- )
278
- }
279
-
280
- fun getPreviewViewport(): RectF? {
281
- val transform = latestTransform ?: return null
282
- if (latestBufferWidth <= 0 || latestBufferHeight <= 0) return null
283
- val rotation = latestTransformRotation
284
- val isSwapped = rotation == 90 || rotation == 270
285
- val bufferWidth = if (isSwapped) latestBufferHeight.toFloat() else latestBufferWidth.toFloat()
286
- val bufferHeight = if (isSwapped) latestBufferWidth.toFloat() else latestBufferHeight.toFloat()
287
-
288
- val pts = floatArrayOf(
289
- 0f, 0f,
290
- bufferWidth, 0f,
291
- 0f, bufferHeight,
292
- bufferWidth, bufferHeight
293
- )
294
- transform.mapPoints(pts)
295
-
296
- val minX = min(min(pts[0], pts[2]), min(pts[4], pts[6]))
297
- val maxX = max(max(pts[0], pts[2]), max(pts[4], pts[6]))
298
- val minY = min(min(pts[1], pts[3]), min(pts[5], pts[7]))
299
- val maxY = max(max(pts[1], pts[3]), max(pts[5], pts[7]))
300
-
301
- return RectF(minX, minY, maxX, maxY)
302
- }
303
-
304
- private fun openCamera() {
305
- if (cameraDevice != null) {
77
+ private fun bindCameraUseCases() {
78
+ val cameraProvider = cameraProvider ?: run {
79
+ Log.e(TAG, "[CAMERAX] CameraProvider is null")
306
80
  return
307
81
  }
308
- val cameraId = selectCameraId() ?: return
309
- try {
310
- val characteristics = cameraManager.getCameraCharacteristics(cameraId)
311
- val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
312
- ?: return
313
- sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
314
-
315
- // Calculate view aspect ratio considering sensor orientation
316
- // For portrait mode with 90/270 degree sensor, we need to swap width/height
317
- val displayRotation = displayRotationDegrees()
318
- val totalRotation = if (useFrontCamera) {
319
- (sensorOrientation + displayRotation) % 360
320
- } else {
321
- (sensorOrientation - displayRotation + 360) % 360
322
- }
323
-
324
- val viewWidth = previewView.width.takeIf { it > 0 } ?: 1200
325
- val viewHeight = previewView.height.takeIf { it > 0 } ?: 1928
326
-
327
- // If total rotation is 90 or 270, the sensor output is rotated, so we need to match against swapped aspect
328
- val viewAspect = if (totalRotation == 90 || totalRotation == 270) {
329
- // Sensor outputs landscape (e.g., 1920x1080), but we display portrait
330
- // So we want to find sensor size with aspect ~= viewHeight/viewWidth
331
- viewHeight.toDouble() / viewWidth.toDouble()
332
- } else {
333
- viewWidth.toDouble() / viewHeight.toDouble()
334
- }
335
-
336
- Log.d(TAG, "[CAMERA2] sensorOrientation=$sensorOrientation displayRotation=$displayRotation totalRotation=$totalRotation")
337
- Log.d(TAG, "[CAMERA2] viewAspect=$viewAspect (view: ${viewWidth}x${viewHeight})")
338
-
339
- val previewSizes = streamConfigMap.getOutputSizes(SurfaceTexture::class.java)
340
- Log.d(TAG, "[CAMERA2] Available preview sizes: ${previewSizes?.take(10)?.joinToString { "${it.width}x${it.height}" }}")
341
-
342
- // Prefer 4:3 so height-based scaling fills the screen without stretching.
343
- val targetPreviewAspect = 4.0 / 3.0
344
- val minPreviewArea = 960 * 720
345
- previewSize = chooseBestSize(previewSizes, targetPreviewAspect, null, minPreviewArea, preferClosestAspect = true)
346
- ?: chooseBestSize(previewSizes, viewAspect, null, preferClosestAspect = true)
347
- ?: previewSizes?.maxByOrNull { it.width * it.height }
348
- Log.d(TAG, "[CAMERA2] Selected preview size: ${previewSize?.width}x${previewSize?.height}")
349
-
350
- val previewAspect = previewSize?.let { it.width.toDouble() / it.height.toDouble() } ?: viewAspect
351
- val analysisSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888)
352
- analysisSize = chooseBestSize(analysisSizes, previewAspect, null, preferClosestAspect = true)
353
-
354
- val captureSizes = streamConfigMap.getOutputSizes(ImageFormat.JPEG)
355
- captureSize = chooseBestSize(captureSizes, previewAspect, null, preferClosestAspect = true)
356
- ?: captureSizes?.maxByOrNull { it.width * it.height }
357
-
358
- val viewAspectNormalized = max(viewAspect, 1.0 / viewAspect)
359
- val previewAspectNormalized = max(previewAspect, 1.0 / previewAspect)
360
- val previewDiff = abs(previewAspectNormalized - viewAspectNormalized)
361
- Log.d(
362
- TAG,
363
- "[SIZE_SELECTION] targetAspect=$viewAspectNormalized viewAspect=$viewAspectNormalized " +
364
- "previewAspect=$previewAspectNormalized diff=$previewDiff selected=${previewSize?.width}x${previewSize?.height}"
365
- )
366
-
367
- setupImageReaders()
368
- Log.d(
369
- TAG,
370
- "[CAMERA2] view=${previewView.width}x${previewView.height} " +
371
- "preview=${previewSize?.width}x${previewSize?.height} " +
372
- "analysis=${analysisSize?.width}x${analysisSize?.height} " +
373
- "capture=${captureSize?.width}x${captureSize?.height}"
374
- )
375
-
376
- if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
377
- Log.e(TAG, "[CAMERA2] Camera permission not granted")
378
- return
379
- }
380
82
 
381
- cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
382
- override fun onOpened(camera: CameraDevice) {
383
- cameraDevice = camera
384
- createCaptureSession()
385
- }
386
-
387
- override fun onDisconnected(camera: CameraDevice) {
388
- camera.close()
389
- cameraDevice = null
390
- }
391
-
392
- override fun onError(camera: CameraDevice, error: Int) {
393
- Log.e(TAG, "[CAMERA2] CameraDevice error: $error")
394
- camera.close()
395
- cameraDevice = null
396
- }
397
- }, cameraHandler)
398
- } catch (e: Exception) {
399
- Log.e(TAG, "[CAMERA2] Failed to open camera", e)
83
+ // Select camera
84
+ val cameraSelector = if (useFrontCamera) {
85
+ CameraSelector.DEFAULT_FRONT_CAMERA
86
+ } else {
87
+ CameraSelector.DEFAULT_BACK_CAMERA
400
88
  }
401
- }
402
89
 
403
- private fun setupImageReaders() {
404
- val analysis = analysisSize
405
- val capture = captureSize
406
-
407
- yuvReader?.close()
408
- jpegReader?.close()
409
-
410
- if (analysis != null) {
411
- yuvReader = ImageReader.newInstance(analysis.width, analysis.height, ImageFormat.YUV_420_888, 2).apply {
412
- setOnImageAvailableListener({ reader ->
413
- if (!detectionEnabled || onFrameAnalyzed == null) {
414
- try {
415
- reader.acquireLatestImage()?.close()
416
- } catch (e: Exception) {
417
- Log.w(TAG, "[CAMERA2] Failed to drain analysis image", e)
418
- }
419
- return@setOnImageAvailableListener
420
- }
421
- if (!analysisInFlight.compareAndSet(false, true)) {
422
- try {
423
- reader.acquireLatestImage()?.close()
424
- } catch (e: Exception) {
425
- Log.w(TAG, "[CAMERA2] Failed to drop analysis image", e)
426
- }
427
- return@setOnImageAvailableListener
428
- }
429
- val image = try {
430
- reader.acquireLatestImage()
431
- } catch (e: Exception) {
432
- analysisInFlight.set(false)
433
- Log.w(TAG, "[CAMERA2] acquireLatestImage failed", e)
434
- null
435
- }
436
- if (image == null) {
437
- analysisInFlight.set(false)
438
- return@setOnImageAvailableListener
439
- }
440
- analysisHandler.post { analyzeImage(image) }
441
- }, cameraHandler)
90
+ // Preview UseCase
91
+ Log.d(TAG, "[CAMERAX] PreviewView size: ${previewView.width}x${previewView.height}")
92
+ Log.d(TAG, "[CAMERAX] PreviewView visibility: ${previewView.visibility}")
93
+ preview = Preview.Builder()
94
+ .build()
95
+ .also {
96
+ it.setSurfaceProvider(previewView.surfaceProvider)
97
+ Log.d(TAG, "[CAMERAX] SurfaceProvider set successfully")
442
98
  }
443
- }
444
99
 
445
- if (capture != null) {
446
- jpegReader = ImageReader.newInstance(capture.width, capture.height, ImageFormat.JPEG, 2).apply {
447
- setOnImageAvailableListener({ reader ->
448
- val image = reader.acquireNextImage() ?: return@setOnImageAvailableListener
449
- val pending = pendingCapture.getAndSet(null)
450
- if (pending == null) {
451
- image.close()
452
- return@setOnImageAvailableListener
100
+ // ImageAnalysis UseCase for document detection
101
+ imageAnalyzer = ImageAnalysis.Builder()
102
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
103
+ .build()
104
+ .also {
105
+ it.setAnalyzer(cameraExecutor) { imageProxy ->
106
+ if (detectionEnabled) {
107
+ analyzeImage(imageProxy)
108
+ } else {
109
+ imageProxy.close()
453
110
  }
454
- analysisHandler.post { processCapture(image, pending) }
455
- }, cameraHandler)
111
+ }
456
112
  }
457
- }
458
- }
459
-
460
- private fun createCaptureSession() {
461
- val device = cameraDevice ?: return
462
- val surfaceTexture = previewView.surfaceTexture ?: return
463
- val preview = previewSize ?: return
464
113
 
465
- surfaceTexture.setDefaultBufferSize(preview.width, preview.height)
466
- Log.d(TAG, "[CAMERA2] SurfaceTexture defaultBufferSize=${preview.width}x${preview.height}")
467
- val previewSurface = Surface(surfaceTexture)
468
-
469
- val targets = mutableListOf(previewSurface)
470
- yuvReader?.surface?.let { targets.add(it) }
471
- jpegReader?.surface?.let { targets.add(it) }
114
+ // ImageCapture UseCase
115
+ imageCapture = ImageCapture.Builder()
116
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
117
+ .build()
472
118
 
473
119
  try {
474
- device.createCaptureSession(targets, object : CameraCaptureSession.StateCallback() {
475
- override fun onConfigured(session: CameraCaptureSession) {
476
- captureSession = session
477
- configureTransform()
478
- startRepeating(previewSurface)
479
- }
480
-
481
- override fun onConfigureFailed(session: CameraCaptureSession) {
482
- Log.e(TAG, "[CAMERA2] Failed to configure capture session")
483
- }
484
- }, cameraHandler)
485
- } catch (e: Exception) {
486
- Log.e(TAG, "[CAMERA2] Failed to create capture session", e)
487
- }
488
- }
120
+ // Unbind all use cases before rebinding
121
+ cameraProvider.unbindAll()
122
+
123
+ // Bind use cases to camera
124
+ camera = cameraProvider.bindToLifecycle(
125
+ lifecycleOwner,
126
+ cameraSelector,
127
+ preview,
128
+ imageAnalyzer,
129
+ imageCapture
130
+ )
489
131
 
490
- private fun startRepeating(previewSurface: Surface) {
491
- val device = cameraDevice ?: return
492
- try {
493
- previewRequestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
494
- addTarget(previewSurface)
495
- yuvReader?.surface?.let { addTarget(it) }
496
- set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
497
- set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
498
- if (torchEnabled) {
499
- set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH)
500
- }
501
- }
502
- captureSession?.setRepeatingRequest(previewRequestBuilder?.build() ?: return, null, cameraHandler)
503
- } catch (e: Exception) {
504
- Log.e(TAG, "[CAMERA2] Failed to start repeating request", e)
505
- }
506
- }
132
+ Log.d(TAG, "[CAMERAX] Camera bound successfully")
507
133
 
508
- private fun updateRepeatingRequest() {
509
- val builder = previewRequestBuilder ?: return
510
- builder.set(CaptureRequest.FLASH_MODE, if (torchEnabled) CaptureRequest.FLASH_MODE_TORCH else CaptureRequest.FLASH_MODE_OFF)
511
- try {
512
- captureSession?.setRepeatingRequest(builder.build(), null, cameraHandler)
513
134
  } catch (e: Exception) {
514
- Log.e(TAG, "[CAMERA2] Failed to update torch state", e)
135
+ Log.e(TAG, "[CAMERAX] Use case binding failed", e)
515
136
  }
516
137
  }
517
138
 
518
- private fun analyzeImage(image: Image) {
519
- val rotationDegrees = computeRotationDegrees()
520
- val imageWidth = image.width
521
- val imageHeight = image.height
522
- val nv21 = try {
523
- imageToNv21(image)
524
- } catch (e: Exception) {
525
- Log.e(TAG, "[CAMERA2] Failed to read image buffer", e)
526
- try {
527
- image.close()
528
- } catch (closeError: Exception) {
529
- Log.w(TAG, "[CAMERA2] Failed to close image", closeError)
530
- }
531
- analysisInFlight.set(false)
139
+ @androidx.annotation.OptIn(ExperimentalGetImage::class)
140
+ private fun analyzeImage(imageProxy: ImageProxy) {
141
+ val mediaImage = imageProxy.image
142
+ if (mediaImage == null) {
143
+ imageProxy.close()
532
144
  return
533
- } finally {
534
- try {
535
- image.close()
536
- } catch (e: Exception) {
537
- Log.w(TAG, "[CAMERA2] Failed to close image", e)
538
- }
539
145
  }
540
146
 
541
- val inputImage = try {
542
- InputImage.fromByteArray(
543
- nv21,
544
- imageWidth,
545
- imageHeight,
546
- rotationDegrees,
547
- InputImage.IMAGE_FORMAT_NV21
548
- )
549
- } catch (e: Exception) {
550
- Log.e(TAG, "[CAMERA2] Failed to create InputImage", e)
551
- analysisInFlight.set(false)
552
- return
553
- }
147
+ val rotationDegrees = imageProxy.imageInfo.rotationDegrees
148
+ val imageWidth = imageProxy.width
149
+ val imageHeight = imageProxy.height
150
+
151
+ // Try ML Kit first
152
+ val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
554
153
 
555
154
  objectDetector.process(inputImage)
556
155
  .addOnSuccessListener { objects ->
156
+ if (objects.isEmpty()) {
157
+ // No objects detected, fallback to OpenCV
158
+ fallbackToOpenCV(imageProxy, rotationDegrees)
159
+ return@addOnSuccessListener
160
+ }
161
+
162
+ // Find largest object
557
163
  val best = objects.maxByOrNull { obj ->
558
164
  val box = obj.boundingBox
559
165
  box.width() * box.height()
560
166
  }
561
167
  val mlBox = best?.boundingBox
562
- val rectangle = refineWithOpenCv(nv21, imageWidth, imageHeight, rotationDegrees, mlBox)
563
168
 
564
- val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
565
- val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
566
- onFrameAnalyzed?.invoke(smoothRectangle(rectangle), frameWidth, frameHeight)
567
- }
568
- .addOnFailureListener { e ->
569
- Log.e(TAG, "[CAMERA2] ML Kit detection failed", e)
570
- val rectangle = try {
571
- DocumentDetector.detectRectangleInYUV(nv21, imageWidth, imageHeight, rotationDegrees)
572
- } catch (detectError: Exception) {
573
- Log.w(TAG, "[CAMERA2] OpenCV fallback failed", detectError)
169
+ // Refine with OpenCV
170
+ val nv21 = imageProxyToNV21(imageProxy)
171
+ val rectangle = if (nv21 != null) {
172
+ try {
173
+ refineWithOpenCv(nv21, imageWidth, imageHeight, rotationDegrees, mlBox)
174
+ } catch (e: Exception) {
175
+ Log.w(TAG, "[CAMERAX] OpenCV refinement failed", e)
176
+ null
177
+ }
178
+ } else {
574
179
  null
575
180
  }
181
+
576
182
  val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
577
183
  val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
578
- onFrameAnalyzed?.invoke(smoothRectangle(rectangle), frameWidth, frameHeight)
579
- }
580
- .addOnCompleteListener {
581
- analysisInFlight.set(false)
582
- }
583
- }
584
184
 
585
- private fun processCapture(image: Image, pending: PendingCapture) {
586
- try {
587
- val buffer = image.planes[0].buffer
588
- val bytes = ByteArray(buffer.remaining())
589
- buffer.get(bytes)
590
- val exifRotation = readExifRotation(bytes)
591
- val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
592
- ?: throw IllegalStateException("Failed to decode JPEG")
593
-
594
- val rotation = if (exifRotation != 0) exifRotation else computeRotationDegrees()
595
- val shouldRotate = if (rotation == 90 || rotation == 270) {
596
- bitmap.width > bitmap.height
597
- } else {
598
- bitmap.height > bitmap.width
599
- }
600
- val appliedRotation = if (shouldRotate) rotation else 0
601
- val rotated = rotateAndMirror(bitmap, appliedRotation, useFrontCamera)
602
- val photoFile = File(pending.outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
603
- FileOutputStream(photoFile).use { out ->
604
- rotated.compress(Bitmap.CompressFormat.JPEG, 95, out)
185
+ onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
186
+ imageProxy.close()
605
187
  }
606
-
607
- if (rotated != bitmap) {
608
- rotated.recycle()
188
+ .addOnFailureListener { e ->
189
+ Log.w(TAG, "[CAMERAX] ML Kit detection failed, using OpenCV", e)
190
+ fallbackToOpenCV(imageProxy, rotationDegrees)
609
191
  }
610
- bitmap.recycle()
611
-
612
- pending.onImageCaptured(photoFile)
613
- } catch (e: Exception) {
614
- pending.onError(e)
615
- } finally {
616
- image.close()
617
- }
618
192
  }
619
193
 
620
- private fun closeSession() {
621
- try {
622
- captureSession?.close()
623
- captureSession = null
624
- cameraDevice?.close()
625
- cameraDevice = null
626
- } catch (e: Exception) {
627
- Log.e(TAG, "[CAMERA2] Error closing camera", e)
628
- } finally {
629
- yuvReader?.close()
630
- jpegReader?.close()
631
- yuvReader = null
632
- jpegReader = null
633
- previewRequestBuilder = null
634
- }
635
- }
636
-
637
- private fun computeRotationDegrees(): Int {
638
- val displayRotation = displayRotationDegrees()
639
- val rotation = if (useFrontCamera) {
640
- (sensorOrientation + displayRotation) % 360
641
- } else {
642
- (sensorOrientation - displayRotation + 360) % 360
643
- }
644
- Log.d(TAG, "[ROTATION] sensor=$sensorOrientation display=$displayRotation front=$useFrontCamera -> rotation=$rotation")
645
- return rotation
646
- }
647
-
648
- private fun displayRotationDegrees(): Int {
649
- val rotation = previewView.display?.rotation ?: Surface.ROTATION_0
650
- return when (rotation) {
651
- Surface.ROTATION_0 -> 0
652
- Surface.ROTATION_90 -> 90
653
- Surface.ROTATION_180 -> 180
654
- Surface.ROTATION_270 -> 270
655
- else -> 0
656
- }
657
- }
658
-
659
- private fun configureTransform() {
660
- val viewWidth = previewView.width.toFloat()
661
- val viewHeight = previewView.height.toFloat()
662
- val preview = previewSize ?: return
663
- if (viewWidth == 0f || viewHeight == 0f) return
664
-
665
- val rotationDegrees = computeRotationDegrees()
666
- val displayRotation = displayRotationDegrees()
667
-
668
- val viewRect = RectF(0f, 0f, viewWidth, viewHeight)
669
- val centerX = viewRect.centerX()
670
- val centerY = viewRect.centerY()
671
-
672
- // Portrait-only mode: swap buffer dimensions based on sensor orientation
673
- // sensorOrientation=90 means camera is rotated 90° from device natural orientation
674
- val swap = sensorOrientation == 90 || sensorOrientation == 270
675
- val bufferWidth = if (swap) preview.height.toFloat() else preview.width.toFloat()
676
- val bufferHeight = if (swap) preview.width.toFloat() else preview.height.toFloat()
677
- val bufferRect = RectF(0f, 0f, bufferWidth, bufferHeight)
678
- bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY())
679
-
680
- val matrix = Matrix()
681
- matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
682
- val scale = max(viewWidth / bufferRect.width(), viewHeight / bufferRect.height())
683
- matrix.postScale(scale, scale, centerX, centerY)
684
- // Portrait-only: no additional rotation needed, sensor orientation is already handled by buffer swap
685
-
686
- val pts = floatArrayOf(
687
- 0f, 0f,
688
- bufferWidth, 0f,
689
- 0f, bufferHeight,
690
- bufferWidth, bufferHeight
691
- )
692
- matrix.mapPoints(pts)
693
-
694
- Log.d(
695
- TAG,
696
- "[TRANSFORM] sensor=$sensorOrientation display=$displayRotation swap=$swap " +
697
- "view=${viewWidth}x${viewHeight} preview=${preview.width}x${preview.height} buffer=${bufferWidth}x${bufferHeight}"
698
- )
699
-
700
- previewView.setTransform(matrix)
701
- latestTransform = Matrix(matrix)
702
- latestBufferWidth = preview.width
703
- latestBufferHeight = preview.height
704
- latestTransformRotation = 0 // Portrait-only mode, no rotation applied
705
-
706
- Log.d(
707
- TAG,
708
- "[TRANSFORM] scale=$scale matrix=$matrix"
709
- )
710
- Log.d(TAG, "[TRANSFORM] Matrix applied successfully")
711
- }
712
-
713
- private fun chooseBestSize(
714
- sizes: Array<Size>?,
715
- targetAspect: Double,
716
- maxArea: Int?,
717
- minArea: Int? = null,
718
- preferClosestAspect: Boolean = false
719
- ): Size? {
720
- if (sizes == null || sizes.isEmpty()) return null
721
- val sorted = sizes.sortedByDescending { it.width * it.height }
722
-
723
- val capped = if (maxArea != null) {
724
- sorted.filter { it.width * it.height <= maxArea }
725
- } else {
726
- sorted
727
- }
728
-
729
- if (capped.isEmpty()) {
730
- return sorted.first()
731
- }
732
-
733
- val minCapped = if (minArea != null) {
734
- capped.filter { it.width * it.height >= minArea }
735
- } else {
736
- capped
737
- }
738
-
739
- val poolForSelection = if (minCapped.isNotEmpty()) minCapped else capped
740
-
741
- fun aspectDiff(size: Size): Double {
742
- val w = size.width.toDouble()
743
- val h = size.height.toDouble()
744
- val aspect = max(w, h) / min(w, h)
745
- val target = max(targetAspect, 1.0 / targetAspect)
746
- return abs(aspect - target)
747
- }
748
-
749
- if (preferClosestAspect) {
750
- // Prefer aspect ratio match first, then pick the highest resolution among matches.
751
- poolForSelection.forEach { size ->
752
- val diff = aspectDiff(size)
753
- Log.d(TAG, "[SIZE_SELECTION] ${size.width}x${size.height} aspect=${size.width.toDouble()/size.height} diff=$diff")
194
+ private fun fallbackToOpenCV(imageProxy: ImageProxy, rotationDegrees: Int) {
195
+ val nv21 = imageProxyToNV21(imageProxy)
196
+ val rectangle = if (nv21 != null) {
197
+ try {
198
+ DocumentDetector.detectRectangleInYUV(
199
+ nv21,
200
+ imageProxy.width,
201
+ imageProxy.height,
202
+ rotationDegrees
203
+ )
204
+ } catch (e: Exception) {
205
+ Log.w(TAG, "[CAMERAX] OpenCV fallback failed", e)
206
+ null
754
207
  }
755
-
756
- val bestDiff = poolForSelection.minOf { aspectDiff(it) }
757
- val close = poolForSelection.filter { aspectDiff(it) <= bestDiff + 0.001 }
758
- val selected = close.maxByOrNull { it.width * it.height } ?: poolForSelection.maxByOrNull { it.width * it.height }
759
- Log.d(TAG, "[SIZE_SELECTION] Best aspect diff: $bestDiff, candidates: ${close.size}, selected: ${selected?.width}x${selected?.height}")
760
- return selected
208
+ } else {
209
+ null
761
210
  }
762
211
 
763
- val matching = poolForSelection.filter { aspectDiff(it) <= ANALYSIS_ASPECT_TOLERANCE }
212
+ val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.height else imageProxy.width
213
+ val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.width else imageProxy.height
764
214
 
765
- return matching.firstOrNull() ?: poolForSelection.first()
215
+ onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
216
+ imageProxy.close()
766
217
  }
767
218
 
768
- private fun rotateAndMirror(bitmap: Bitmap, rotationDegrees: Int, mirror: Boolean): Bitmap {
769
- Log.d(TAG, "[ROTATE_MIRROR] rotationDegrees=$rotationDegrees mirror=$mirror bitmap=${bitmap.width}x${bitmap.height}")
219
+ private fun imageProxyToNV21(imageProxy: ImageProxy): ByteArray? {
220
+ return try {
221
+ val yBuffer = imageProxy.planes[0].buffer
222
+ val uBuffer = imageProxy.planes[1].buffer
223
+ val vBuffer = imageProxy.planes[2].buffer
770
224
 
771
- if (rotationDegrees == 0 && !mirror) {
772
- Log.d(TAG, "[ROTATE_MIRROR] No rotation/mirror needed, returning bitmap as-is")
773
- return bitmap
774
- }
225
+ val ySize = yBuffer.remaining()
226
+ val uSize = uBuffer.remaining()
227
+ val vSize = vBuffer.remaining()
775
228
 
776
- val matrix = Matrix()
777
- if (rotationDegrees != 0) {
778
- matrix.postRotate(rotationDegrees.toFloat(), bitmap.width / 2f, bitmap.height / 2f)
779
- }
780
- if (mirror) {
781
- matrix.postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f)
782
- }
229
+ val nv21 = ByteArray(ySize + uSize + vSize)
783
230
 
784
- return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
785
- }
231
+ yBuffer.get(nv21, 0, ySize)
232
+ vBuffer.get(nv21, ySize, vSize)
233
+ uBuffer.get(nv21, ySize + vSize, uSize)
786
234
 
787
- private fun readExifRotation(bytes: ByteArray): Int {
788
- return try {
789
- val exif = ExifInterface(ByteArrayInputStream(bytes))
790
- when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
791
- ExifInterface.ORIENTATION_ROTATE_90 -> 90
792
- ExifInterface.ORIENTATION_ROTATE_180 -> 180
793
- ExifInterface.ORIENTATION_ROTATE_270 -> 270
794
- else -> 0
795
- }
235
+ nv21
796
236
  } catch (e: Exception) {
797
- Log.w(TAG, "[CAMERA2] Failed to read EXIF rotation", e)
798
- 0
237
+ Log.e(TAG, "[CAMERAX] Failed to convert ImageProxy to NV21", e)
238
+ null
799
239
  }
800
240
  }
801
241
 
802
242
  private fun refineWithOpenCv(
803
243
  nv21: ByteArray,
804
- imageWidth: Int,
805
- imageHeight: Int,
806
- rotationDegrees: Int,
807
- mlBox: Rect?
244
+ width: Int,
245
+ height: Int,
246
+ rotation: Int,
247
+ mlBox: android.graphics.Rect?
808
248
  ): Rectangle? {
809
249
  return try {
810
- val uprightWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
811
- val uprightHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
812
- val openCvRect = if (mlBox != null) {
813
- val expanded = expandRect(mlBox, uprightWidth, uprightHeight, 0.25f)
814
- DocumentDetector.detectRectangleInYUVWithRoi(
815
- nv21,
816
- imageWidth,
817
- imageHeight,
818
- rotationDegrees,
819
- expanded
820
- )
821
- } else {
822
- DocumentDetector.detectRectangleInYUV(nv21, imageWidth, imageHeight, rotationDegrees)
823
- }
824
- if (openCvRect == null) {
825
- mlBox?.let { boxToRectangle(insetBox(it, 0.9f)) }
826
- } else {
827
- openCvRect
828
- }
250
+ DocumentDetector.detectRectangleInYUV(nv21, width, height, rotation)
829
251
  } catch (e: Exception) {
830
- Log.w(TAG, "[CAMERA2] OpenCV refine failed", e)
252
+ Log.w(TAG, "[CAMERAX] OpenCV detection failed", e)
831
253
  null
832
254
  }
833
255
  }
834
256
 
835
- private fun boxToRectangle(box: Rect): Rectangle {
836
- return Rectangle(
837
- Point(box.left.toDouble(), box.top.toDouble()),
838
- Point(box.right.toDouble(), box.top.toDouble()),
839
- Point(box.left.toDouble(), box.bottom.toDouble()),
840
- Point(box.right.toDouble(), box.bottom.toDouble())
257
+ fun capturePhoto(
258
+ outputDirectory: File,
259
+ onImageCaptured: (File) -> Unit,
260
+ onError: (Exception) -> Unit
261
+ ) {
262
+ val imageCapture = imageCapture ?: run {
263
+ onError(IllegalStateException("ImageCapture not initialized"))
264
+ return
265
+ }
266
+
267
+ pendingCapture = PendingCapture(outputDirectory, onImageCaptured, onError)
268
+
269
+ val photoFile = File(outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
270
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
271
+
272
+ imageCapture.takePicture(
273
+ outputOptions,
274
+ ContextCompat.getMainExecutor(context),
275
+ object : ImageCapture.OnImageSavedCallback {
276
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
277
+ Log.d(TAG, "[CAMERAX] Image saved: ${photoFile.absolutePath}")
278
+ onImageCaptured(photoFile)
279
+ pendingCapture = null
280
+ }
281
+
282
+ override fun onError(exception: ImageCaptureException) {
283
+ Log.e(TAG, "[CAMERAX] Image capture failed", exception)
284
+ onError(exception)
285
+ pendingCapture = null
286
+ }
287
+ }
841
288
  )
842
289
  }
843
290
 
844
- private fun expandRect(box: Rect, maxWidth: Int, maxHeight: Int, ratio: Float): Rect {
845
- val padX = (box.width() * ratio).toInt()
846
- val padY = (box.height() * ratio).toInt()
847
- val left = (box.left - padX).coerceAtLeast(0)
848
- val top = (box.top - padY).coerceAtLeast(0)
849
- val right = (box.right + padX).coerceAtMost(maxWidth)
850
- val bottom = (box.bottom + padY).coerceAtMost(maxHeight)
851
- return Rect(left, top, right, bottom)
291
+ fun setTorchEnabled(enabled: Boolean) {
292
+ camera?.cameraControl?.enableTorch(enabled)
852
293
  }
853
294
 
854
- private fun insetBox(box: Rect, ratio: Float): Rect {
855
- if (ratio >= 1f) return box
856
- val insetX = ((1f - ratio) * box.width() / 2f).toInt()
857
- val insetY = ((1f - ratio) * box.height() / 2f).toInt()
858
- return Rect(
859
- box.left + insetX,
860
- box.top + insetY,
861
- box.right - insetX,
862
- box.bottom - insetY
863
- )
295
+ fun stopCamera() {
296
+ Log.d(TAG, "[CAMERAX] stopCamera called")
297
+ cameraProvider?.unbindAll()
298
+ camera = null
864
299
  }
865
300
 
866
- private fun smoothRectangle(current: Rectangle?): Rectangle? {
867
- val now = System.currentTimeMillis()
868
- val last = lastRectangle
869
- if (current == null) {
870
- if (last != null && now - lastRectangleTimestamp < 150) {
871
- return last
872
- }
873
- lastRectangle = null
874
- return null
875
- }
876
-
877
- lastRectangle = current
878
- lastRectangleTimestamp = now
879
- return current
301
+ fun shutdown() {
302
+ stopCamera()
303
+ objectDetector.close()
304
+ cameraExecutor.shutdown()
880
305
  }
881
306
 
882
- private fun rectangleBounds(rectangle: Rectangle): Rect {
883
- val left = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).minOrNull() ?: 0.0
884
- val right = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).maxOrNull() ?: 0.0
885
- val top = listOf(rectangle.topLeft.y, rectangle.bottomLeft.y, rectangle.topRight.y, rectangle.bottomRight.y).minOrNull() ?: 0.0
886
- val bottom = listOf(rectangle.topLeft.y, rectangle.bottomLeft.y, rectangle.topRight.y, rectangle.bottomRight.y).maxOrNull() ?: 0.0
887
- return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
307
+ fun refreshTransform() {
308
+ // CameraX handles transform automatically via PreviewView
309
+ // No manual matrix calculation needed!
310
+ Log.d(TAG, "[CAMERAX] Transform refresh requested - handled automatically by PreviewView")
888
311
  }
889
312
 
890
- private fun imageToNv21(image: Image): ByteArray {
891
- val width = image.width
892
- val height = image.height
893
- val ySize = width * height
894
- val uvSize = width * height / 2
895
- val nv21 = ByteArray(ySize + uvSize)
896
-
897
- val yBuffer = image.planes[0].buffer
898
- val uBuffer = image.planes[1].buffer
899
- val vBuffer = image.planes[2].buffer
900
-
901
- val yRowStride = image.planes[0].rowStride
902
- val yPixelStride = image.planes[0].pixelStride
903
- var outputOffset = 0
904
- for (row in 0 until height) {
905
- var inputOffset = row * yRowStride
906
- for (col in 0 until width) {
907
- nv21[outputOffset++] = yBuffer.get(inputOffset)
908
- inputOffset += yPixelStride
909
- }
910
- }
313
+ // Simplified coordinate mapping - PreviewView handles most of the work
314
+ fun mapRectangleToView(rectangle: Rectangle?, imageWidth: Int, imageHeight: Int): Rectangle? {
315
+ if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
911
316
 
912
- val uvRowStride = image.planes[1].rowStride
913
- val uvPixelStride = image.planes[1].pixelStride
914
- val vRowStride = image.planes[2].rowStride
915
- val vPixelStride = image.planes[2].pixelStride
916
- val uvHeight = height / 2
917
- val uvWidth = width / 2
918
- for (row in 0 until uvHeight) {
919
- var uInputOffset = row * uvRowStride
920
- var vInputOffset = row * vRowStride
921
- for (col in 0 until uvWidth) {
922
- nv21[outputOffset++] = vBuffer.get(vInputOffset)
923
- nv21[outputOffset++] = uBuffer.get(uInputOffset)
924
- uInputOffset += uvPixelStride
925
- vInputOffset += vPixelStride
926
- }
317
+ // CameraX PreviewView with FILL_CENTER handles scaling and centering
318
+ // We just need to scale the coordinates proportionally
319
+ val viewWidth = previewView.width.toFloat()
320
+ val viewHeight = previewView.height.toFloat()
321
+
322
+ if (viewWidth <= 0 || viewHeight <= 0) return null
323
+
324
+ // Simple proportional scaling
325
+ val scaleX = viewWidth / imageWidth.toFloat()
326
+ val scaleY = viewHeight / imageHeight.toFloat()
327
+
328
+ fun scalePoint(point: org.opencv.core.Point): org.opencv.core.Point {
329
+ return org.opencv.core.Point(
330
+ point.x * scaleX,
331
+ point.y * scaleY
332
+ )
927
333
  }
928
334
 
929
- return nv21
930
- }
931
- private fun hasCameraPermission(): Boolean {
932
- return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
335
+ return Rectangle(
336
+ scalePoint(rectangle.topLeft),
337
+ scalePoint(rectangle.topRight),
338
+ scalePoint(rectangle.bottomLeft),
339
+ scalePoint(rectangle.bottomRight)
340
+ )
933
341
  }
934
342
 
935
- private fun selectCameraId(): String? {
936
- return try {
937
- val desiredFacing = if (useFrontCamera) {
938
- CameraCharacteristics.LENS_FACING_FRONT
939
- } else {
940
- CameraCharacteristics.LENS_FACING_BACK
941
- }
942
- cameraManager.cameraIdList.firstOrNull { id ->
943
- val characteristics = cameraManager.getCameraCharacteristics(id)
944
- characteristics.get(CameraCharacteristics.LENS_FACING) == desiredFacing
945
- } ?: cameraManager.cameraIdList.firstOrNull()
946
- } catch (e: Exception) {
947
- Log.e(TAG, "[CAMERA2] Failed to select camera", e)
948
- null
949
- }
343
+ fun getPreviewViewport(): android.graphics.RectF? {
344
+ // With CameraX PreviewView, the viewport is simply the view bounds
345
+ val width = previewView.width.toFloat()
346
+ val height = previewView.height.toFloat()
347
+
348
+ if (width <= 0 || height <= 0) return null
349
+
350
+ return android.graphics.RectF(0f, 0f, width, height)
950
351
  }
951
352
  }
@@ -9,9 +9,9 @@ import android.graphics.PorterDuff
9
9
  import android.graphics.PorterDuffXfermode
10
10
  import org.opencv.core.Point
11
11
  import android.util.Log
12
- import android.view.TextureView
13
12
  import android.view.View
14
13
  import android.widget.FrameLayout
14
+ import androidx.camera.view.PreviewView
15
15
  import androidx.lifecycle.Lifecycle
16
16
  import androidx.lifecycle.LifecycleOwner
17
17
  import androidx.lifecycle.LifecycleRegistry
@@ -28,7 +28,7 @@ import kotlin.math.max
28
28
 
29
29
  class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), LifecycleOwner {
30
30
  private val themedContext = context
31
- private val previewView: TextureView
31
+ private val previewView: PreviewView
32
32
  private val overlayView: OverlayView
33
33
  private val useNativeOverlay = false
34
34
  private var cameraController: CameraController? = null
@@ -81,12 +81,14 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
81
81
  lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED
82
82
  Log.d(TAG, "[INIT] Lifecycle state: ${lifecycleRegistry.currentState}")
83
83
 
84
- // Create preview view
84
+ // Create preview view (CameraX PreviewView)
85
85
  Log.d(TAG, "[INIT] Creating PreviewView...")
86
- previewView = TextureView(context).apply {
86
+ previewView = PreviewView(context).apply {
87
87
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
88
88
  visibility = View.VISIBLE
89
89
  keepScreenOn = true
90
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE // Use TextureView internally
91
+ scaleType = PreviewView.ScaleType.FILL_CENTER // Fill and center the preview
90
92
  }
91
93
  Log.d(TAG, "[INIT] PreviewView created: $previewView")
92
94
  Log.d(TAG, "[INIT] PreviewView visibility: ${previewView.visibility}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "7.65.0",
3
+ "version": "9.0.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",