react-native-rectangle-doc-scanner 3.194.0 → 3.196.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,298 +1,425 @@
1
1
  package com.reactnativerectangledocscanner
2
2
 
3
+ import android.Manifest
3
4
  import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.graphics.Bitmap
7
+ import android.graphics.BitmapFactory
4
8
  import android.graphics.ImageFormat
9
+ import android.graphics.Matrix
10
+ import android.graphics.Rect
11
+ import android.graphics.RectF
12
+ import android.graphics.SurfaceTexture
13
+ import android.graphics.YuvImage
14
+ import android.hardware.camera2.CameraCaptureSession
15
+ import android.hardware.camera2.CameraCharacteristics
16
+ import android.hardware.camera2.CameraDevice
17
+ import android.hardware.camera2.CameraManager
18
+ import android.hardware.camera2.CaptureRequest
19
+ import android.media.Image
20
+ import android.media.ImageReader
21
+ import android.os.Handler
22
+ import android.os.HandlerThread
5
23
  import android.util.Log
6
24
  import android.util.Size
7
25
  import android.view.Surface
8
- import androidx.camera.core.*
9
- import androidx.camera.lifecycle.ProcessCameraProvider
10
- import androidx.camera.view.PreviewView
26
+ import android.view.TextureView
11
27
  import androidx.core.content.ContextCompat
12
- import androidx.lifecycle.Lifecycle
13
28
  import androidx.lifecycle.LifecycleOwner
14
- import androidx.lifecycle.LiveData
15
- import androidx.lifecycle.Observer
29
+ import java.io.ByteArrayOutputStream
16
30
  import java.io.File
17
- import java.util.concurrent.ExecutorService
18
- import java.util.concurrent.Executors
31
+ import java.io.FileOutputStream
32
+ import java.util.concurrent.atomic.AtomicBoolean
19
33
 
20
34
  class CameraController(
21
35
  private val context: Context,
22
36
  private val lifecycleOwner: LifecycleOwner,
23
- private val previewView: PreviewView
37
+ private val previewView: TextureView
24
38
  ) {
25
- private var camera: Camera? = null
26
- private var cameraProvider: ProcessCameraProvider? = null
27
- private var imageCapture: ImageCapture? = null
28
- private var imageAnalysis: ImageAnalysis? = null
29
- private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
30
-
39
+ private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
40
+ private var cameraDevice: CameraDevice? = null
41
+ private var captureSession: CameraCaptureSession? = null
42
+ private var previewRequestBuilder: CaptureRequest.Builder? = null
43
+ private var imageReader: ImageReader? = null
44
+ private var backgroundThread: HandlerThread? = null
45
+ private var backgroundHandler: Handler? = null
46
+
47
+ private var cameraId: String? = null
48
+ private var sensorOrientation: Int = 0
49
+ private var previewSize: Size? = null
50
+ private var analysisSize: Size? = null
31
51
  private var useFrontCamera = false
32
52
  private var torchEnabled = false
33
53
  private var detectionEnabled = true
34
- private var isCaptureSession = false
35
- private var hasFallbackAttempted = false
36
- private var cameraStateLiveData: LiveData<CameraState>? = null
37
- private var cameraStateObserver: Observer<CameraState>? = null
54
+ private var hasStarted = false
55
+
56
+ private val isOpening = AtomicBoolean(false)
57
+ private val lastFrameLock = Any()
58
+ private var lastFrame: LastFrame? = null
38
59
 
39
60
  var onFrameAnalyzed: ((Rectangle?, Int, Int) -> Unit)? = null
40
61
 
41
62
  companion object {
42
63
  private const val TAG = "CameraController"
64
+ private const val MAX_PREVIEW_WIDTH = 1280
65
+ private const val MAX_PREVIEW_HEIGHT = 720
66
+ }
67
+
68
+ private data class LastFrame(
69
+ val nv21: ByteArray,
70
+ val width: Int,
71
+ val height: Int,
72
+ val rotationDegrees: Int,
73
+ val isFront: Boolean
74
+ )
75
+
76
+ private val textureListener = object : TextureView.SurfaceTextureListener {
77
+ override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
78
+ Log.d(TAG, "[CAMERA2] Texture available: ${width}x${height}")
79
+ createPreviewSession()
80
+ }
81
+
82
+ override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
83
+ Log.d(TAG, "[CAMERA2] Texture size changed: ${width}x${height}")
84
+ updatePreviewTransform()
85
+ }
86
+
87
+ override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
88
+ Log.d(TAG, "[CAMERA2] Texture destroyed")
89
+ return true
90
+ }
91
+
92
+ override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
43
93
  }
44
94
 
45
- /**
46
- * Start camera with preview and analysis
47
- */
48
95
  fun startCamera(
49
96
  useFrontCam: Boolean = false,
50
97
  enableDetection: Boolean = true
51
98
  ) {
52
99
  Log.d(TAG, "========================================")
53
- Log.d(TAG, "[CAMERA_CONTROLLER] startCamera called")
54
- Log.d(TAG, "[CAMERA_CONTROLLER] useFrontCam: $useFrontCam")
55
- Log.d(TAG, "[CAMERA_CONTROLLER] enableDetection: $enableDetection")
56
- Log.d(TAG, "[CAMERA_CONTROLLER] lifecycleOwner: $lifecycleOwner")
57
- Log.d(TAG, "[CAMERA_CONTROLLER] lifecycleOwner.lifecycle.currentState: ${lifecycleOwner.lifecycle.currentState}")
100
+ Log.d(TAG, "[CAMERA2] startCamera called")
101
+ Log.d(TAG, "[CAMERA2] useFrontCam: $useFrontCam")
102
+ Log.d(TAG, "[CAMERA2] enableDetection: $enableDetection")
103
+ Log.d(TAG, "[CAMERA2] lifecycleOwner: $lifecycleOwner")
58
104
  Log.d(TAG, "========================================")
59
105
 
60
106
  this.useFrontCamera = useFrontCam
61
107
  this.detectionEnabled = enableDetection
62
108
 
63
- Log.d(TAG, "[CAMERA_CONTROLLER] Getting ProcessCameraProvider instance...")
64
- val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
109
+ if (hasStarted) {
110
+ Log.d(TAG, "[CAMERA2] Already started, skipping")
111
+ return
112
+ }
113
+ hasStarted = true
114
+
115
+ if (!hasCameraPermission()) {
116
+ Log.e(TAG, "[CAMERA2] Camera permission not granted")
117
+ return
118
+ }
119
+
120
+ startBackgroundThread()
121
+ chooseCamera()
122
+
123
+ if (previewView.isAvailable) {
124
+ openCamera()
125
+ } else {
126
+ previewView.surfaceTextureListener = textureListener
127
+ }
128
+ }
129
+
130
+ fun stopCamera() {
131
+ Log.d(TAG, "[CAMERA2] stopCamera called")
132
+ try {
133
+ captureSession?.close()
134
+ captureSession = null
135
+ } catch (e: Exception) {
136
+ Log.w(TAG, "[CAMERA2] Failed to close session", e)
137
+ }
138
+ try {
139
+ cameraDevice?.close()
140
+ cameraDevice = null
141
+ } catch (e: Exception) {
142
+ Log.w(TAG, "[CAMERA2] Failed to close camera device", e)
143
+ }
144
+ imageReader?.close()
145
+ imageReader = null
146
+ stopBackgroundThread()
147
+ hasStarted = false
148
+ }
65
149
 
66
- cameraProviderFuture.addListener({
150
+ fun capturePhoto(
151
+ outputDirectory: File,
152
+ onImageCaptured: (File) -> Unit,
153
+ onError: (Exception) -> Unit
154
+ ) {
155
+ val frame = synchronized(lastFrameLock) { lastFrame }
156
+ if (frame == null) {
157
+ onError(Exception("No frame available for capture"))
158
+ return
159
+ }
160
+
161
+ backgroundHandler?.post {
67
162
  try {
68
- Log.d(TAG, "[CAMERA_CONTROLLER] ProcessCameraProvider future resolved")
69
- cameraProvider = cameraProviderFuture.get()
70
- Log.d(TAG, "[CAMERA_CONTROLLER] Got cameraProvider: $cameraProvider")
71
- Log.d(TAG, "[CAMERA_CONTROLLER] Calling bindCameraUseCases...")
72
- // Bind preview + analysis only. ImageCapture is bound lazily during capture
73
- // to avoid stream configuration timeouts on some devices.
74
- bindCameraUseCases(enableDetection, useImageCapture = false)
163
+ val photoFile = File(
164
+ outputDirectory,
165
+ "doc_scan_${System.currentTimeMillis()}.jpg"
166
+ )
167
+
168
+ val jpegBytes = nv21ToJpeg(frame.nv21, frame.width, frame.height, 95)
169
+ val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
170
+ ?: throw IllegalStateException("Failed to decode JPEG")
171
+
172
+ val rotated = rotateAndMirror(bitmap, frame.rotationDegrees, frame.isFront)
173
+ FileOutputStream(photoFile).use { out ->
174
+ rotated.compress(Bitmap.CompressFormat.JPEG, 95, out)
175
+ }
176
+ if (rotated != bitmap) {
177
+ rotated.recycle()
178
+ }
179
+ bitmap.recycle()
180
+
181
+ Log.d(TAG, "[CAMERA2] Photo capture succeeded: ${photoFile.absolutePath}")
182
+ onImageCaptured(photoFile)
75
183
  } catch (e: Exception) {
76
- Log.e(TAG, "[CAMERA_CONTROLLER] Failed to start camera", e)
77
- e.printStackTrace()
184
+ Log.e(TAG, "[CAMERA2] Photo capture failed", e)
185
+ onError(e)
78
186
  }
79
- }, ContextCompat.getMainExecutor(context))
187
+ }
80
188
  }
81
189
 
82
- /**
83
- * Stop camera and release resources
84
- */
85
- fun stopCamera() {
86
- cameraProvider?.unbindAll()
87
- camera = null
190
+ fun setTorchEnabled(enabled: Boolean) {
191
+ torchEnabled = enabled
192
+ val builder = previewRequestBuilder ?: return
193
+ builder.set(CaptureRequest.FLASH_MODE, if (enabled) CaptureRequest.FLASH_MODE_TORCH else CaptureRequest.FLASH_MODE_OFF)
194
+ try {
195
+ captureSession?.setRepeatingRequest(builder.build(), null, backgroundHandler)
196
+ } catch (e: Exception) {
197
+ Log.w(TAG, "[CAMERA2] Failed to update torch", e)
198
+ }
88
199
  }
89
200
 
90
- /**
91
- * Bind camera use cases (preview, capture, analysis)
92
- */
93
- private fun bindCameraUseCases(enableDetection: Boolean, useImageCapture: Boolean) {
94
- Log.d(TAG, "[BIND] bindCameraUseCases called")
95
- Log.d(TAG, "[BIND] enableDetection: $enableDetection")
96
- Log.d(TAG, "[BIND] useImageCapture: $useImageCapture")
97
-
98
- val cameraProvider = cameraProvider
99
- if (cameraProvider == null) {
100
- Log.e(TAG, "[BIND] cameraProvider is null, returning")
201
+ fun switchCamera() {
202
+ useFrontCamera = !useFrontCamera
203
+ stopCamera()
204
+ startCamera(useFrontCamera, detectionEnabled)
205
+ }
206
+
207
+ fun isTorchAvailable(): Boolean {
208
+ val id = cameraId ?: return false
209
+ val characteristics = cameraManager.getCameraCharacteristics(id)
210
+ return characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
211
+ }
212
+
213
+ fun focusAt(x: Float, y: Float) {
214
+ // No-op for now. Camera2 focus metering can be added if needed.
215
+ }
216
+
217
+ fun shutdown() {
218
+ stopCamera()
219
+ }
220
+
221
+ private fun chooseCamera() {
222
+ val lensFacing = if (useFrontCamera) {
223
+ CameraCharacteristics.LENS_FACING_FRONT
224
+ } else {
225
+ CameraCharacteristics.LENS_FACING_BACK
226
+ }
227
+
228
+ val ids = cameraManager.cameraIdList
229
+ val selected = ids.firstOrNull { id ->
230
+ val characteristics = cameraManager.getCameraCharacteristics(id)
231
+ characteristics.get(CameraCharacteristics.LENS_FACING) == lensFacing
232
+ } ?: ids.firstOrNull()
233
+
234
+ if (selected == null) {
235
+ Log.e(TAG, "[CAMERA2] No camera available")
101
236
  return
102
237
  }
103
238
 
104
- // Check lifecycle state
105
- val lifecycle = lifecycleOwner.lifecycle
106
- Log.d(TAG, "[BIND] Lifecycle current state: ${lifecycle.currentState}")
107
- if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
108
- Log.e(TAG, "[BIND] Cannot bind camera - lifecycle is destroyed")
239
+ cameraId = selected
240
+ val characteristics = cameraManager.getCameraCharacteristics(selected)
241
+ sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
242
+
243
+ val streamConfig = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
244
+ val previewChoices = streamConfig?.getOutputSizes(SurfaceTexture::class.java) ?: emptyArray()
245
+ val analysisChoices = streamConfig?.getOutputSizes(ImageFormat.YUV_420_888) ?: emptyArray()
246
+
247
+ previewSize = chooseSize(previewChoices, MAX_PREVIEW_WIDTH, MAX_PREVIEW_HEIGHT)
248
+ analysisSize = chooseSize(analysisChoices, MAX_PREVIEW_WIDTH, MAX_PREVIEW_HEIGHT)
249
+ Log.d(TAG, "[CAMERA2] Selected sizes - preview: $previewSize, analysis: $analysisSize")
250
+ }
251
+
252
+ private fun openCamera() {
253
+ val id = cameraId ?: run {
254
+ Log.e(TAG, "[CAMERA2] Camera id not set")
255
+ return
256
+ }
257
+ if (isOpening.getAndSet(true)) {
109
258
  return
110
259
  }
111
260
 
112
- // Select camera
113
- val cameraSelector = if (useFrontCamera) {
114
- CameraSelector.DEFAULT_FRONT_CAMERA
115
- } else {
116
- CameraSelector.DEFAULT_BACK_CAMERA
117
- }
118
- Log.d(TAG, "[BIND] Camera selector: ${if (useFrontCamera) "FRONT" else "BACK"}")
119
-
120
- val targetRotation = previewView.display?.rotation ?: Surface.ROTATION_0
121
-
122
- // Preview use case (avoid forcing a size to let CameraX pick a compatible stream)
123
- Log.d(TAG, "[BIND] Creating Preview use case...")
124
- val preview = Preview.Builder()
125
- .setTargetRotation(targetRotation)
126
- .build()
127
- Log.d(TAG, "[BIND] Preview created: $preview")
128
-
129
- // Image capture use case (bound only when capture is requested)
130
- if (useImageCapture) {
131
- Log.d(TAG, "[BIND] Creating ImageCapture use case...")
132
- imageCapture = ImageCapture.Builder()
133
- .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
134
- // Cap resolution to avoid camera session timeouts on lower-end devices.
135
- .setTargetResolution(Size(960, 720))
136
- .setTargetRotation(targetRotation)
137
- .setFlashMode(ImageCapture.FLASH_MODE_AUTO)
138
- .build()
139
- Log.d(TAG, "[BIND] ImageCapture created: $imageCapture")
140
- } else {
141
- imageCapture = null
142
- }
143
-
144
- // Image analysis use case for rectangle detection
145
- imageAnalysis = if (enableDetection) {
146
- Log.d(TAG, "[BIND] Creating ImageAnalysis use case...")
147
- ImageAnalysis.Builder()
148
- // Keep analysis lightweight to prevent session configuration timeouts.
149
- .setTargetResolution(Size(960, 720))
150
- .setTargetRotation(targetRotation)
151
- .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
152
- .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
153
- .build()
154
- .also { analysis ->
155
- analysis.setAnalyzer(cameraExecutor) { imageProxy ->
156
- analyzeFrame(imageProxy)
157
- }
158
- Log.d(TAG, "[BIND] ImageAnalysis created and analyzer set: $analysis")
261
+ try {
262
+ cameraManager.openCamera(id, object : CameraDevice.StateCallback() {
263
+ override fun onOpened(device: CameraDevice) {
264
+ Log.d(TAG, "[CAMERA2] Camera opened")
265
+ isOpening.set(false)
266
+ cameraDevice = device
267
+ createPreviewSession()
159
268
  }
160
- } else {
161
- Log.d(TAG, "[BIND] ImageAnalysis disabled")
162
- null
269
+
270
+ override fun onDisconnected(device: CameraDevice) {
271
+ Log.w(TAG, "[CAMERA2] Camera disconnected")
272
+ isOpening.set(false)
273
+ device.close()
274
+ cameraDevice = null
275
+ }
276
+
277
+ override fun onError(device: CameraDevice, error: Int) {
278
+ Log.e(TAG, "[CAMERA2] Camera error: $error")
279
+ isOpening.set(false)
280
+ device.close()
281
+ cameraDevice = null
282
+ }
283
+ }, backgroundHandler)
284
+ } catch (e: SecurityException) {
285
+ isOpening.set(false)
286
+ Log.e(TAG, "[CAMERA2] Camera permission missing", e)
287
+ } catch (e: Exception) {
288
+ isOpening.set(false)
289
+ Log.e(TAG, "[CAMERA2] Failed to open camera", e)
163
290
  }
291
+ }
164
292
 
293
+ private fun createPreviewSession() {
294
+ val device = cameraDevice ?: return
295
+ val texture = previewView.surfaceTexture ?: return
296
+ val previewSize = previewSize ?: return
297
+ val analysisSize = analysisSize ?: previewSize
298
+
299
+ texture.setDefaultBufferSize(previewSize.width, previewSize.height)
300
+ val previewSurface = Surface(texture)
301
+
302
+ imageReader?.close()
303
+ imageReader = ImageReader.newInstance(
304
+ analysisSize.width,
305
+ analysisSize.height,
306
+ ImageFormat.YUV_420_888,
307
+ 2
308
+ ).apply {
309
+ setOnImageAvailableListener({ reader ->
310
+ val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
311
+ handleImage(image)
312
+ }, backgroundHandler)
313
+ }
314
+
315
+ val surfaces = listOf(previewSurface, imageReader!!.surface)
165
316
  try {
166
- Log.d(TAG, "[BIND] PreviewView: $previewView")
167
- Log.d(TAG, "[BIND] PreviewView.surfaceProvider: ${previewView.surfaceProvider}")
168
- Log.d(TAG, "[BIND] PreviewView attached to window: ${previewView.isAttachedToWindow}")
169
- Log.d(TAG, "[BIND] PreviewView size: ${previewView.width}x${previewView.height}")
170
- Log.d(TAG, "[BIND] PreviewView implementationMode: ${previewView.implementationMode}")
171
-
172
- // Set surface provider FIRST, before binding - this is critical
173
- Log.d(TAG, "[BIND] Setting surface provider BEFORE binding...")
174
- preview.setSurfaceProvider(previewView.surfaceProvider)
175
- Log.d(TAG, "[BIND] Surface provider set successfully")
176
-
177
- // Unbind all use cases before rebinding
178
- Log.d(TAG, "[BIND] Unbinding all existing use cases...")
179
- cameraProvider.unbindAll()
180
-
181
- // Bind use cases to camera
182
- val useCases = mutableListOf<UseCase>(preview)
183
- if (imageCapture != null) {
184
- useCases.add(imageCapture!!)
185
- }
186
- if (imageAnalysis != null) {
187
- useCases.add(imageAnalysis!!)
188
- }
189
- Log.d(TAG, "[BIND] Total use cases to bind: ${useCases.size}")
317
+ device.createCaptureSession(
318
+ surfaces,
319
+ object : CameraCaptureSession.StateCallback() {
320
+ override fun onConfigured(session: CameraCaptureSession) {
321
+ if (cameraDevice == null) {
322
+ return
323
+ }
324
+ captureSession = session
325
+ previewRequestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
326
+ addTarget(previewSurface)
327
+ addTarget(imageReader!!.surface)
328
+ set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
329
+ set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
330
+ set(CaptureRequest.FLASH_MODE, if (torchEnabled) CaptureRequest.FLASH_MODE_TORCH else CaptureRequest.FLASH_MODE_OFF)
331
+ }
332
+ try {
333
+ session.setRepeatingRequest(previewRequestBuilder!!.build(), null, backgroundHandler)
334
+ Log.d(TAG, "[CAMERA2] Preview session started")
335
+ updatePreviewTransform()
336
+ } catch (e: Exception) {
337
+ Log.e(TAG, "[CAMERA2] Failed to start preview", e)
338
+ }
339
+ }
190
340
 
191
- Log.d(TAG, "[BIND] Binding to lifecycle...")
192
- camera = cameraProvider.bindToLifecycle(
193
- lifecycleOwner,
194
- cameraSelector,
195
- *useCases.toTypedArray()
341
+ override fun onConfigureFailed(session: CameraCaptureSession) {
342
+ Log.e(TAG, "[CAMERA2] Preview session configure failed")
343
+ }
344
+ },
345
+ backgroundHandler
196
346
  )
197
- Log.d(TAG, "[BIND] Bound to lifecycle successfully, camera: $camera")
198
- registerCameraStateObserver(camera)
199
-
200
- // Restore torch state if it was enabled
201
- if (torchEnabled) {
202
- Log.d(TAG, "[BIND] Restoring torch state...")
203
- setTorchEnabled(true)
204
- }
205
-
206
- Log.d(TAG, "[BIND] ========================================")
207
- Log.d(TAG, "[BIND] Camera started successfully!")
208
- Log.d(TAG, "[BIND] hasFlashUnit: ${camera?.cameraInfo?.hasFlashUnit()}")
209
- Log.d(TAG, "[BIND] ========================================")
210
- isCaptureSession = useImageCapture
211
347
  } catch (e: Exception) {
212
- Log.e(TAG, "[BIND] Failed to bind camera use cases", e)
213
- e.printStackTrace()
348
+ Log.e(TAG, "[CAMERA2] Failed to create preview session", e)
214
349
  }
215
350
  }
216
351
 
217
- private fun registerCameraStateObserver(camera: Camera?) {
218
- val cam = camera ?: return
219
- cameraStateLiveData?.let { liveData ->
220
- cameraStateObserver?.let { liveData.removeObserver(it) }
221
- }
222
-
223
- val observer = Observer<CameraState> { state ->
224
- val error = state.error
225
- if (error != null && !hasFallbackAttempted && !isCaptureSession) {
226
- hasFallbackAttempted = true
227
- Log.e(TAG, "[STATE] Camera error detected (${error.code}), falling back to preview-only")
228
- try {
229
- cameraProvider?.unbindAll()
230
- bindCameraUseCases(enableDetection = false, useImageCapture = false)
231
- } catch (e: Exception) {
232
- Log.e(TAG, "[STATE] Fallback bind failed", e)
233
- }
234
- }
352
+ private fun updatePreviewTransform() {
353
+ val previewSize = previewSize ?: return
354
+ val viewWidth = previewView.width
355
+ val viewHeight = previewView.height
356
+ if (viewWidth == 0 || viewHeight == 0) {
357
+ return
358
+ }
359
+
360
+ val rotation = previewView.display?.rotation ?: Surface.ROTATION_0
361
+ val matrix = Matrix()
362
+ val viewRect = RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
363
+ val bufferRect = if (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) {
364
+ RectF(0f, 0f, previewSize.height.toFloat(), previewSize.width.toFloat())
365
+ } else {
366
+ RectF(0f, 0f, previewSize.width.toFloat(), previewSize.height.toFloat())
235
367
  }
368
+ val centerX = viewRect.centerX()
369
+ val centerY = viewRect.centerY()
370
+
371
+ bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY())
372
+ matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
236
373
 
237
- cameraStateObserver = observer
238
- cameraStateLiveData = cam.cameraInfo.cameraState
239
- cam.cameraInfo.cameraState.observe(lifecycleOwner, observer)
374
+ val scale = if (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) {
375
+ maxOf(viewHeight.toFloat() / previewSize.height, viewWidth.toFloat() / previewSize.width)
376
+ } else {
377
+ maxOf(viewWidth.toFloat() / previewSize.width, viewHeight.toFloat() / previewSize.height)
378
+ }
379
+ matrix.postScale(scale, scale, centerX, centerY)
380
+
381
+ when (rotation) {
382
+ Surface.ROTATION_90 -> matrix.postRotate(90f, centerX, centerY)
383
+ Surface.ROTATION_180 -> matrix.postRotate(180f, centerX, centerY)
384
+ Surface.ROTATION_270 -> matrix.postRotate(270f, centerX, centerY)
385
+ }
386
+
387
+ previewView.setTransform(matrix)
240
388
  }
241
389
 
242
- /**
243
- * Analyze frame for rectangle detection
244
- */
245
- private fun analyzeFrame(imageProxy: ImageProxy) {
390
+ private fun handleImage(image: Image) {
246
391
  try {
247
- val rotationDegrees = imageProxy.imageInfo.rotationDegrees
248
- val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) {
249
- imageProxy.height
250
- } else {
251
- imageProxy.width
252
- }
253
- val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) {
254
- imageProxy.width
255
- } else {
256
- imageProxy.height
257
- }
258
-
259
- if (imageProxy.format != ImageFormat.YUV_420_888 || imageProxy.planes.size < 3) {
260
- onFrameAnalyzed?.invoke(null, frameWidth, frameHeight)
261
- return
262
- }
392
+ val rotationDegrees = getRotationDegrees()
393
+ val width = image.width
394
+ val height = image.height
395
+ val nv21 = imageToNV21(image)
263
396
 
264
- val nv21 = imageProxyToNV21(imageProxy)
265
- val rectangle = DocumentDetector.detectRectangleInYUV(
266
- nv21,
267
- imageProxy.width,
268
- imageProxy.height,
269
- rotationDegrees
270
- )
397
+ val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) height else width
398
+ val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) width else height
271
399
 
272
- onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
273
- } catch (e: Exception) {
274
- Log.e(TAG, "Error analyzing frame", e)
275
- val rotationDegrees = imageProxy.imageInfo.rotationDegrees
276
- val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) {
277
- imageProxy.height
278
- } else {
279
- imageProxy.width
400
+ synchronized(lastFrameLock) {
401
+ lastFrame = LastFrame(nv21, width, height, rotationDegrees, useFrontCamera)
280
402
  }
281
- val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) {
282
- imageProxy.width
403
+
404
+ if (detectionEnabled) {
405
+ val rectangle = DocumentDetector.detectRectangleInYUV(
406
+ nv21,
407
+ width,
408
+ height,
409
+ rotationDegrees
410
+ )
411
+ onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
283
412
  } else {
284
- imageProxy.height
413
+ onFrameAnalyzed?.invoke(null, frameWidth, frameHeight)
285
414
  }
286
- onFrameAnalyzed?.invoke(null, frameWidth, frameHeight)
415
+ } catch (e: Exception) {
416
+ Log.e(TAG, "[CAMERA2] Error analyzing frame", e)
287
417
  } finally {
288
- imageProxy.close()
418
+ image.close()
289
419
  }
290
420
  }
291
421
 
292
- /**
293
- * Convert ImageProxy (YUV_420_888) to NV21 byte array
294
- */
295
- private fun imageProxyToNV21(image: ImageProxy): ByteArray {
422
+ private fun imageToNV21(image: Image): ByteArray {
296
423
  val width = image.width
297
424
  val height = image.height
298
425
 
@@ -336,114 +463,76 @@ class CameraController(
336
463
  return nv21
337
464
  }
338
465
 
339
- /**
340
- * Capture photo
341
- */
342
- fun capturePhoto(
343
- outputDirectory: File,
344
- onImageCaptured: (File) -> Unit,
345
- onError: (Exception) -> Unit
346
- ) {
347
- if (!isCaptureSession) {
348
- val provider = cameraProvider ?: run {
349
- onError(Exception("Camera provider not initialized"))
350
- return
351
- }
352
- ContextCompat.getMainExecutor(context).execute {
353
- try {
354
- // Rebind with ImageCapture only for the capture to avoid stream timeouts.
355
- provider.unbindAll()
356
- bindCameraUseCases(enableDetection = false, useImageCapture = true)
357
- capturePhoto(outputDirectory, onImageCaptured, onError)
358
- } catch (e: Exception) {
359
- onError(e)
360
- }
361
- }
362
- return
363
- }
466
+ private fun nv21ToJpeg(nv21: ByteArray, width: Int, height: Int, quality: Int): ByteArray {
467
+ val yuv = YuvImage(nv21, ImageFormat.NV21, width, height, null)
468
+ val out = ByteArrayOutputStream()
469
+ yuv.compressToJpeg(Rect(0, 0, width, height), quality, out)
470
+ return out.toByteArray()
471
+ }
364
472
 
365
- val imageCapture = imageCapture ?: run {
366
- onError(Exception("Image capture not initialized"))
367
- return
473
+ private fun rotateAndMirror(bitmap: Bitmap, rotationDegrees: Int, mirror: Boolean): Bitmap {
474
+ if (rotationDegrees == 0 && !mirror) {
475
+ return bitmap
368
476
  }
369
-
370
- val photoFile = File(
371
- outputDirectory,
372
- "doc_scan_${System.currentTimeMillis()}.jpg"
373
- )
374
-
375
- val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
376
-
377
- imageCapture.takePicture(
378
- outputOptions,
379
- ContextCompat.getMainExecutor(context),
380
- object : ImageCapture.OnImageSavedCallback {
381
- override fun onImageSaved(output: ImageCapture.OutputFileResults) {
382
- Log.d(TAG, "Photo capture succeeded: ${photoFile.absolutePath}")
383
- onImageCaptured(photoFile)
384
- if (detectionEnabled) {
385
- ContextCompat.getMainExecutor(context).execute {
386
- bindCameraUseCases(enableDetection = true, useImageCapture = false)
387
- }
388
- }
389
- }
390
-
391
- override fun onError(exception: ImageCaptureException) {
392
- Log.e(TAG, "Photo capture failed", exception)
393
- if (exception.imageCaptureError == ImageCapture.ERROR_CAMERA_CLOSED) {
394
- Log.w(TAG, "Camera was closed during capture, attempting restart")
395
- stopCamera()
396
- startCamera(useFrontCamera, detectionEnabled)
397
- }
398
- if (detectionEnabled) {
399
- ContextCompat.getMainExecutor(context).execute {
400
- bindCameraUseCases(enableDetection = true, useImageCapture = false)
401
- }
402
- }
403
- onError(exception)
404
- }
405
- }
406
- )
477
+ val matrix = Matrix()
478
+ if (mirror) {
479
+ matrix.postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f)
480
+ }
481
+ if (rotationDegrees != 0) {
482
+ matrix.postRotate(rotationDegrees.toFloat(), bitmap.width / 2f, bitmap.height / 2f)
483
+ }
484
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
407
485
  }
408
486
 
409
- /**
410
- * Enable or disable torch (flashlight)
411
- */
412
- fun setTorchEnabled(enabled: Boolean) {
413
- torchEnabled = enabled
414
- camera?.cameraControl?.enableTorch(enabled)
487
+ private fun getRotationDegrees(): Int {
488
+ val displayRotation = previewView.display?.rotation ?: Surface.ROTATION_0
489
+ val displayDegrees = when (displayRotation) {
490
+ Surface.ROTATION_0 -> 0
491
+ Surface.ROTATION_90 -> 90
492
+ Surface.ROTATION_180 -> 180
493
+ Surface.ROTATION_270 -> 270
494
+ else -> 0
495
+ }
496
+
497
+ return if (useFrontCamera) {
498
+ (sensorOrientation + displayDegrees) % 360
499
+ } else {
500
+ (sensorOrientation - displayDegrees + 360) % 360
501
+ }
415
502
  }
416
503
 
417
- /**
418
- * Switch between front and back camera
419
- */
420
- fun switchCamera() {
421
- useFrontCamera = !useFrontCamera
422
- startCamera(useFrontCamera)
504
+ private fun chooseSize(choices: Array<Size>, maxWidth: Int, maxHeight: Int): Size? {
505
+ if (choices.isEmpty()) {
506
+ return null
507
+ }
508
+ val filtered = choices.filter { it.width <= maxWidth && it.height <= maxHeight }
509
+ val candidates = if (filtered.isNotEmpty()) filtered else choices.toList()
510
+ return candidates.sortedBy { it.width * it.height }.last()
423
511
  }
424
512
 
425
- /**
426
- * Check if torch is available
427
- */
428
- fun isTorchAvailable(): Boolean {
429
- return camera?.cameraInfo?.hasFlashUnit() == true
513
+ private fun startBackgroundThread() {
514
+ if (backgroundThread != null) {
515
+ return
516
+ }
517
+ backgroundThread = HandlerThread("Camera2Background").also {
518
+ it.start()
519
+ backgroundHandler = Handler(it.looper)
520
+ }
430
521
  }
431
522
 
432
- /**
433
- * Focus at specific point
434
- */
435
- fun focusAt(x: Float, y: Float) {
436
- val factory = previewView.meteringPointFactory
437
- val point = factory.createPoint(x, y)
438
- val action = FocusMeteringAction.Builder(point).build()
439
- camera?.cameraControl?.startFocusAndMetering(action)
523
+ private fun stopBackgroundThread() {
524
+ try {
525
+ backgroundThread?.quitSafely()
526
+ backgroundThread?.join()
527
+ } catch (e: InterruptedException) {
528
+ Log.w(TAG, "[CAMERA2] Background thread shutdown interrupted", e)
529
+ } finally {
530
+ backgroundThread = null
531
+ backgroundHandler = null
532
+ }
440
533
  }
441
534
 
442
- /**
443
- * Cleanup resources
444
- */
445
- fun shutdown() {
446
- cameraExecutor.shutdown()
447
- stopCamera()
535
+ private fun hasCameraPermission(): Boolean {
536
+ return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
448
537
  }
449
538
  }
@@ -8,9 +8,9 @@ import android.graphics.Paint
8
8
  import android.graphics.PorterDuff
9
9
  import android.graphics.PorterDuffXfermode
10
10
  import android.util.Log
11
+ import android.view.TextureView
11
12
  import android.view.View
12
13
  import android.widget.FrameLayout
13
- import androidx.camera.view.PreviewView
14
14
  import androidx.lifecycle.Lifecycle
15
15
  import androidx.lifecycle.LifecycleOwner
16
16
  import androidx.lifecycle.LifecycleRegistry
@@ -25,7 +25,7 @@ import kotlin.math.min
25
25
 
26
26
  class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), LifecycleOwner {
27
27
  private val themedContext = context
28
- private val previewView: PreviewView
28
+ private val previewView: TextureView
29
29
  private val overlayView: OverlayView
30
30
  private var cameraController: CameraController? = null
31
31
  private val lifecycleRegistry = LifecycleRegistry(this)
@@ -73,12 +73,9 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
73
73
  Log.d(TAG, "[INIT] Lifecycle state: ${lifecycleRegistry.currentState}")
74
74
 
75
75
  // Create preview view
76
- Log.d(TAG, "[INIT] Creating PreviewView...")
77
- previewView = PreviewView(context).apply {
76
+ Log.d(TAG, "[INIT] Creating TextureView...")
77
+ previewView = TextureView(context).apply {
78
78
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
79
- scaleType = PreviewView.ScaleType.FILL_CENTER
80
- // Use COMPATIBLE (TextureView) to avoid SurfaceView black frames on some devices.
81
- implementationMode = PreviewView.ImplementationMode.COMPATIBLE
82
79
  visibility = View.VISIBLE
83
80
  keepScreenOn = true
84
81
  // Force view to be drawn
@@ -87,13 +84,12 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
87
84
  bringToFront()
88
85
  requestLayout()
89
86
  }
90
- Log.d(TAG, "[INIT] PreviewView created: $previewView")
91
- Log.d(TAG, "[INIT] PreviewView implementationMode: ${previewView.implementationMode}")
92
- Log.d(TAG, "[INIT] PreviewView visibility: ${previewView.visibility}")
87
+ Log.d(TAG, "[INIT] TextureView created: $previewView")
88
+ Log.d(TAG, "[INIT] TextureView visibility: ${previewView.visibility}")
93
89
 
94
- Log.d(TAG, "[INIT] Adding PreviewView to parent...")
90
+ Log.d(TAG, "[INIT] Adding TextureView to parent...")
95
91
  addView(previewView)
96
- Log.d(TAG, "[INIT] PreviewView added, childCount: $childCount")
92
+ Log.d(TAG, "[INIT] TextureView added, childCount: $childCount")
97
93
 
98
94
  // Create overlay view for drawing rectangle
99
95
  Log.d(TAG, "[INIT] Creating OverlayView...")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.194.0",
3
+ "version": "3.196.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",