react-native-rectangle-doc-scanner 3.229.0 → 3.231.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.
@@ -6,112 +6,114 @@ import android.content.pm.PackageManager
6
6
  import android.graphics.Bitmap
7
7
  import android.graphics.BitmapFactory
8
8
  import android.graphics.Matrix
9
- import android.graphics.Rect
10
- import android.graphics.YuvImage
9
+ import android.graphics.SurfaceTexture
10
+ import android.graphics.ImageFormat
11
+ import android.hardware.camera2.CameraCaptureSession
12
+ import android.hardware.camera2.CameraCharacteristics
13
+ import android.hardware.camera2.CameraDevice
14
+ import android.hardware.camera2.CameraManager
15
+ import android.hardware.camera2.CaptureRequest
16
+ import android.media.Image
17
+ import android.media.ImageReader
18
+ import android.os.Handler
19
+ import android.os.HandlerThread
11
20
  import android.util.Log
12
21
  import android.util.Size
13
22
  import android.view.Surface
14
- import androidx.camera.core.Camera
15
- import androidx.camera.core.CameraSelector
16
- import androidx.camera.core.ImageAnalysis
17
- import androidx.camera.core.ImageCapture
18
- import androidx.camera.core.ImageCaptureException
19
- import androidx.camera.core.Preview
20
- import androidx.camera.lifecycle.ProcessCameraProvider
21
- import androidx.camera.view.PreviewView
23
+ import android.view.TextureView
22
24
  import androidx.core.content.ContextCompat
23
- import androidx.lifecycle.LifecycleOwner
24
- import com.google.common.util.concurrent.ListenableFuture
25
- import java.io.ByteArrayOutputStream
26
25
  import java.io.File
27
26
  import java.io.FileOutputStream
28
- import java.util.concurrent.ExecutorService
29
- import java.util.concurrent.Executors
30
27
  import java.util.concurrent.atomic.AtomicReference
28
+ import java.util.concurrent.atomic.AtomicBoolean
29
+ import kotlin.math.abs
30
+ import kotlin.math.max
31
31
 
32
32
  class CameraController(
33
33
  private val context: Context,
34
- private val lifecycleOwner: LifecycleOwner,
35
- private val previewView: PreviewView
34
+ private val lifecycleOwner: androidx.lifecycle.LifecycleOwner,
35
+ private val previewView: TextureView
36
36
  ) {
37
- private var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>? = null
38
- private var cameraProvider: ProcessCameraProvider? = null
39
- private var preview: Preview? = null
40
- private var imageAnalysis: ImageAnalysis? = null
41
- private var imageCapture: ImageCapture? = null
42
- private var camera: Camera? = null
43
- private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
44
- private val lastFrame = AtomicReference<LastFrame?>()
45
- private var analysisBound = false
46
- private var pendingBindAttempts = 0
47
- private var triedLowResFallback = false
48
- private val streamCheckHandler = android.os.Handler(android.os.Looper.getMainLooper())
49
- private var streamCheckRunnable: Runnable? = null
37
+ private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
38
+ private var cameraDevice: CameraDevice? = null
39
+ private var captureSession: CameraCaptureSession? = null
40
+ private var previewRequestBuilder: CaptureRequest.Builder? = null
41
+
42
+ private var previewSize: Size? = null
43
+ private var analysisSize: Size? = null
44
+ private var captureSize: Size? = null
45
+
46
+ private var yuvReader: ImageReader? = null
47
+ private var jpegReader: ImageReader? = null
48
+
49
+ private val cameraThread = HandlerThread("Camera2Thread").apply { start() }
50
+ private val cameraHandler = Handler(cameraThread.looper)
51
+ private val analysisThread = HandlerThread("Camera2Analysis").apply { start() }
52
+ private val analysisHandler = Handler(analysisThread.looper)
50
53
 
51
54
  private var useFrontCamera = false
52
55
  private var detectionEnabled = true
56
+ private var torchEnabled = false
53
57
 
54
- // For periodic frame capture
55
- private var isAnalysisActive = false
56
- private val analysisHandler = android.os.Handler(android.os.Looper.getMainLooper())
57
- private val analysisRunnable = object : Runnable {
58
- override fun run() {
59
- if (isAnalysisActive && onFrameAnalyzed != null) {
60
- captureFrameForAnalysis()
61
- analysisHandler.postDelayed(this, 200) // Capture every 200ms
62
- }
63
- }
64
- }
58
+ private val pendingCapture = AtomicReference<PendingCapture?>()
59
+ private val analysisInFlight = AtomicBoolean(false)
65
60
 
66
61
  var onFrameAnalyzed: ((Rectangle?, Int, Int) -> Unit)? = null
67
62
 
68
63
  companion object {
69
64
  private const val TAG = "CameraController"
65
+ private const val ANALYSIS_MAX_AREA = 1920 * 1080
66
+ private const val ANALYSIS_ASPECT_TOLERANCE = 0.15
70
67
  }
71
68
 
72
- private data class LastFrame(
73
- val nv21: ByteArray,
74
- val width: Int,
75
- val height: Int,
76
- val rotationDegrees: Int,
77
- val isFront: Boolean
69
+ private data class PendingCapture(
70
+ val outputDirectory: File,
71
+ val onImageCaptured: (File) -> Unit,
72
+ val onError: (Exception) -> Unit
78
73
  )
79
74
 
75
+ private val textureListener = object : TextureView.SurfaceTextureListener {
76
+ override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
77
+ openCamera()
78
+ }
79
+
80
+ override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
81
+ configureTransform()
82
+ }
83
+
84
+ override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
85
+ return true
86
+ }
87
+
88
+ override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
89
+ // no-op
90
+ }
91
+ }
92
+
80
93
  fun startCamera(
81
94
  useFrontCam: Boolean = false,
82
95
  enableDetection: Boolean = true
83
96
  ) {
84
- Log.d(TAG, "[CAMERAX-V6] startCamera called")
97
+ Log.d(TAG, "[CAMERA2] startCamera called")
85
98
  this.useFrontCamera = useFrontCam
86
99
  this.detectionEnabled = enableDetection
87
- triedLowResFallback = false
88
100
 
89
101
  if (!hasCameraPermission()) {
90
- Log.e(TAG, "[CAMERAX-V6] Camera permission not granted")
102
+ Log.e(TAG, "[CAMERA2] Camera permission not granted")
91
103
  return
92
104
  }
93
105
 
94
- if (cameraProviderFuture == null) {
95
- cameraProviderFuture = ProcessCameraProvider.getInstance(context)
106
+ if (previewView.isAvailable) {
107
+ openCamera()
108
+ } else {
109
+ previewView.surfaceTextureListener = textureListener
96
110
  }
97
-
98
- cameraProviderFuture?.addListener({
99
- try {
100
- cameraProvider = cameraProviderFuture?.get()
101
- bindCameraUseCases()
102
- } catch (e: Exception) {
103
- Log.e(TAG, "[CAMERAX-V6] Failed to get camera provider", e)
104
- }
105
- }, ContextCompat.getMainExecutor(context))
106
111
  }
107
112
 
108
113
  fun stopCamera() {
109
- Log.d(TAG, "[CAMERAX-V6] stopCamera called")
110
- isAnalysisActive = false
111
- analysisHandler.removeCallbacks(analysisRunnable)
112
- streamCheckRunnable?.let { streamCheckHandler.removeCallbacks(it) }
113
- cameraProvider?.unbindAll()
114
- analysisBound = false
114
+ Log.d(TAG, "[CAMERA2] stopCamera called")
115
+ previewView.surfaceTextureListener = null
116
+ closeSession()
115
117
  }
116
118
 
117
119
  fun capturePhoto(
@@ -119,195 +121,360 @@ class CameraController(
119
121
  onImageCaptured: (File) -> Unit,
120
122
  onError: (Exception) -> Unit
121
123
  ) {
122
- val frame = lastFrame.get()
123
- if (frame == null) {
124
- onError(IllegalStateException("No frame available for capture"))
124
+ val device = cameraDevice
125
+ val session = captureSession
126
+ val reader = jpegReader
127
+ if (device == null || session == null || reader == null) {
128
+ onError(IllegalStateException("Camera not ready for capture"))
125
129
  return
126
130
  }
127
131
 
128
- cameraExecutor.execute {
129
- try {
130
- val photoFile = File(outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
131
- val jpegBytes = nv21ToJpeg(frame.nv21, frame.width, frame.height, 95)
132
- val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
133
- ?: throw IllegalStateException("Failed to decode JPEG")
132
+ if (!pendingCapture.compareAndSet(null, PendingCapture(outputDirectory, onImageCaptured, onError))) {
133
+ onError(IllegalStateException("Capture already in progress"))
134
+ return
135
+ }
134
136
 
135
- val rotated = rotateAndMirror(bitmap, frame.rotationDegrees, frame.isFront)
136
- FileOutputStream(photoFile).use { out ->
137
- rotated.compress(Bitmap.CompressFormat.JPEG, 95, out)
138
- }
139
- if (rotated != bitmap) {
140
- rotated.recycle()
137
+ try {
138
+ val requestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
139
+ addTarget(reader.surface)
140
+ set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
141
+ set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
142
+ if (torchEnabled) {
143
+ set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH)
141
144
  }
142
- bitmap.recycle()
143
-
144
- Log.d(TAG, "[CAMERAX-V6] Photo capture succeeded: ${photoFile.absolutePath}")
145
- onImageCaptured(photoFile)
146
- } catch (e: Exception) {
147
- Log.e(TAG, "[CAMERAX-V6] Photo capture failed", e)
148
- onError(e)
145
+ set(CaptureRequest.JPEG_ORIENTATION, 0)
149
146
  }
147
+
148
+ session.capture(requestBuilder.build(), object : CameraCaptureSession.CaptureCallback() {}, cameraHandler)
149
+ } catch (e: Exception) {
150
+ pendingCapture.getAndSet(null)?.onError?.invoke(e)
150
151
  }
151
152
  }
152
153
 
153
154
  fun setTorchEnabled(enabled: Boolean) {
154
- camera?.cameraControl?.enableTorch(enabled)
155
+ torchEnabled = enabled
156
+ updateRepeatingRequest()
155
157
  }
156
158
 
157
159
  fun switchCamera() {
158
160
  useFrontCamera = !useFrontCamera
159
- bindCameraUseCases()
161
+ closeSession()
162
+ openCamera()
160
163
  }
161
164
 
162
165
  fun isTorchAvailable(): Boolean {
163
- return camera?.cameraInfo?.hasFlashUnit() == true
166
+ return try {
167
+ val cameraId = selectCameraId() ?: return false
168
+ val characteristics = cameraManager.getCameraCharacteristics(cameraId)
169
+ characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
170
+ } catch (e: Exception) {
171
+ false
172
+ }
164
173
  }
165
174
 
166
175
  fun focusAt(x: Float, y: Float) {
167
- // No-op for now.
176
+ // Optional: implement touch-to-focus if needed.
168
177
  }
169
178
 
170
179
  fun shutdown() {
171
180
  stopCamera()
172
- cameraExecutor.shutdown()
181
+ cameraThread.quitSafely()
182
+ analysisThread.quitSafely()
173
183
  }
174
184
 
175
- private fun bindCameraUseCases(useLowRes: Boolean = false) {
176
- if (!previewView.isAttachedToWindow || previewView.width == 0 || previewView.height == 0) {
177
- if (pendingBindAttempts < 5) {
178
- pendingBindAttempts++
179
- Log.d(TAG, "[CAMERAX-V9] PreviewView not ready (attached=${previewView.isAttachedToWindow}, w=${previewView.width}, h=${previewView.height}), retrying...")
180
- previewView.post { bindCameraUseCases() }
181
- } else {
182
- Log.w(TAG, "[CAMERAX-V9] PreviewView still not ready after retries, aborting bind")
183
- }
185
+ private fun openCamera() {
186
+ if (cameraDevice != null) {
184
187
  return
185
188
  }
186
- pendingBindAttempts = 0
189
+ val cameraId = selectCameraId() ?: return
190
+ try {
191
+ val characteristics = cameraManager.getCameraCharacteristics(cameraId)
192
+ val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
193
+ ?: return
194
+
195
+ val viewAspect = if (previewView.height == 0) {
196
+ 1.0
197
+ } else {
198
+ previewView.width.toDouble() / previewView.height.toDouble()
199
+ }
187
200
 
188
- val provider = cameraProvider ?: return
189
- provider.unbindAll()
190
- analysisBound = false
191
- isAnalysisActive = false
201
+ val previewSizes = streamConfigMap.getOutputSizes(SurfaceTexture::class.java)
202
+ previewSize = chooseBestSize(previewSizes, viewAspect, null)
192
203
 
193
- val rotation = previewView.display?.rotation ?: Surface.ROTATION_0
204
+ val analysisSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888)
205
+ analysisSize = chooseBestSize(analysisSizes, viewAspect, ANALYSIS_MAX_AREA)
194
206
 
195
- // Build Preview; fall back to a low-res stream if the default config stalls.
196
- val previewBuilder = Preview.Builder()
197
- .setTargetRotation(rotation)
198
- if (useLowRes) {
199
- previewBuilder.setTargetResolution(Size(640, 480))
200
- }
201
- preview = previewBuilder.build().also {
202
- // IMPORTANT: Set surface provider BEFORE binding
203
- it.setSurfaceProvider(previewView.surfaceProvider)
207
+ val captureSizes = streamConfigMap.getOutputSizes(ImageFormat.JPEG)
208
+ captureSize = captureSizes?.maxByOrNull { it.width * it.height }
209
+
210
+ setupImageReaders()
211
+
212
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
213
+ Log.e(TAG, "[CAMERA2] Camera permission not granted")
214
+ return
215
+ }
216
+
217
+ cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
218
+ override fun onOpened(camera: CameraDevice) {
219
+ cameraDevice = camera
220
+ createCaptureSession()
221
+ }
222
+
223
+ override fun onDisconnected(camera: CameraDevice) {
224
+ camera.close()
225
+ cameraDevice = null
226
+ }
227
+
228
+ override fun onError(camera: CameraDevice, error: Int) {
229
+ Log.e(TAG, "[CAMERA2] CameraDevice error: $error")
230
+ camera.close()
231
+ cameraDevice = null
232
+ }
233
+ }, cameraHandler)
234
+ } catch (e: Exception) {
235
+ Log.e(TAG, "[CAMERA2] Failed to open camera", e)
204
236
  }
237
+ }
205
238
 
206
- val cameraSelector = if (useFrontCamera) {
207
- CameraSelector.DEFAULT_FRONT_CAMERA
208
- } else {
209
- CameraSelector.DEFAULT_BACK_CAMERA
239
+ private fun setupImageReaders() {
240
+ val analysis = analysisSize
241
+ val capture = captureSize
242
+
243
+ yuvReader?.close()
244
+ jpegReader?.close()
245
+
246
+ if (analysis != null) {
247
+ yuvReader = ImageReader.newInstance(analysis.width, analysis.height, ImageFormat.YUV_420_888, 2).apply {
248
+ setOnImageAvailableListener({ reader ->
249
+ if (!detectionEnabled || onFrameAnalyzed == null) {
250
+ try {
251
+ reader.acquireLatestImage()?.close()
252
+ } catch (e: Exception) {
253
+ Log.w(TAG, "[CAMERA2] Failed to drain analysis image", e)
254
+ }
255
+ return@setOnImageAvailableListener
256
+ }
257
+ if (!analysisInFlight.compareAndSet(false, true)) {
258
+ try {
259
+ reader.acquireLatestImage()?.close()
260
+ } catch (e: Exception) {
261
+ Log.w(TAG, "[CAMERA2] Failed to drop analysis image", e)
262
+ }
263
+ return@setOnImageAvailableListener
264
+ }
265
+ val image = try {
266
+ reader.acquireLatestImage()
267
+ } catch (e: Exception) {
268
+ analysisInFlight.set(false)
269
+ Log.w(TAG, "[CAMERA2] acquireLatestImage failed", e)
270
+ null
271
+ }
272
+ if (image == null) {
273
+ analysisInFlight.set(false)
274
+ return@setOnImageAvailableListener
275
+ }
276
+ analysisHandler.post { analyzeImage(image) }
277
+ }, cameraHandler)
278
+ }
210
279
  }
211
280
 
212
- // Bind Preview ONLY first
213
- try {
214
- camera = provider.bindToLifecycle(
215
- lifecycleOwner,
216
- cameraSelector,
217
- preview
218
- )
219
-
220
- if (useLowRes) {
221
- Log.d(TAG, "[CAMERAX-V9] Preview bound with low-res fallback (640x480)")
222
- } else {
223
- Log.d(TAG, "[CAMERAX-V9] Preview bound, waiting for capture session to configure...")
281
+ if (capture != null) {
282
+ jpegReader = ImageReader.newInstance(capture.width, capture.height, ImageFormat.JPEG, 2).apply {
283
+ setOnImageAvailableListener({ reader ->
284
+ val image = reader.acquireNextImage() ?: return@setOnImageAvailableListener
285
+ val pending = pendingCapture.getAndSet(null)
286
+ if (pending == null) {
287
+ image.close()
288
+ return@setOnImageAvailableListener
289
+ }
290
+ analysisHandler.post { processCapture(image, pending) }
291
+ }, cameraHandler)
224
292
  }
293
+ }
294
+ }
295
+
296
+ private fun createCaptureSession() {
297
+ val device = cameraDevice ?: return
298
+ val surfaceTexture = previewView.surfaceTexture ?: return
299
+ val preview = previewSize ?: return
300
+
301
+ surfaceTexture.setDefaultBufferSize(preview.width, preview.height)
302
+ val previewSurface = Surface(surfaceTexture)
225
303
 
226
- // Log session state after some time
227
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
228
- Log.d(TAG, "[CAMERAX-V9] Camera state check - preview should be working now")
229
- }, 6000)
304
+ val targets = mutableListOf(previewSurface)
305
+ yuvReader?.surface?.let { targets.add(it) }
306
+ jpegReader?.surface?.let { targets.add(it) }
307
+
308
+ try {
309
+ device.createCaptureSession(targets, object : CameraCaptureSession.StateCallback() {
310
+ override fun onConfigured(session: CameraCaptureSession) {
311
+ captureSession = session
312
+ configureTransform()
313
+ startRepeating(previewSurface)
314
+ }
230
315
 
231
- scheduleStreamCheck(useLowRes)
316
+ override fun onConfigureFailed(session: CameraCaptureSession) {
317
+ Log.e(TAG, "[CAMERA2] Failed to configure capture session")
318
+ }
319
+ }, cameraHandler)
232
320
  } catch (e: Exception) {
233
- Log.e(TAG, "[CAMERAX-V8] Failed to bind preview", e)
321
+ Log.e(TAG, "[CAMERA2] Failed to create capture session", e)
234
322
  }
235
323
  }
236
324
 
237
- private fun scheduleStreamCheck(usingLowRes: Boolean) {
238
- streamCheckRunnable?.let { streamCheckHandler.removeCallbacks(it) }
239
- streamCheckRunnable = Runnable {
240
- val state = previewView.previewStreamState.value
241
- val streaming = state == PreviewView.StreamState.STREAMING
242
- if (!streaming && !usingLowRes && !triedLowResFallback) {
243
- triedLowResFallback = true
244
- Log.w(TAG, "[CAMERAX-V9] Preview not streaming; retrying with low-res fallback")
245
- bindCameraUseCases(useLowRes = true)
246
- } else if (!streaming) {
247
- Log.w(TAG, "[CAMERAX-V9] Preview still not streaming after fallback")
325
+ private fun startRepeating(previewSurface: Surface) {
326
+ val device = cameraDevice ?: return
327
+ try {
328
+ previewRequestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
329
+ addTarget(previewSurface)
330
+ yuvReader?.surface?.let { addTarget(it) }
331
+ set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
332
+ set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
333
+ if (torchEnabled) {
334
+ set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH)
335
+ }
248
336
  }
337
+ captureSession?.setRepeatingRequest(previewRequestBuilder?.build() ?: return, null, cameraHandler)
338
+ } catch (e: Exception) {
339
+ Log.e(TAG, "[CAMERA2] Failed to start repeating request", e)
249
340
  }
250
- streamCheckHandler.postDelayed(streamCheckRunnable!!, 6500)
251
341
  }
252
342
 
253
- // Function removed - this device cannot handle ImageCapture + Preview simultaneously
254
-
255
- private fun captureFrameForAnalysis() {
256
- val capture = imageCapture ?: return
257
-
258
- capture.takePicture(cameraExecutor, object : ImageCapture.OnImageCapturedCallback() {
259
- override fun onCaptureSuccess(image: androidx.camera.core.ImageProxy) {
260
- try {
261
- val rotationDegrees = image.imageInfo.rotationDegrees
262
- val nv21 = image.toNv21()
263
-
264
- lastFrame.set(
265
- LastFrame(
266
- nv21,
267
- image.width,
268
- image.height,
269
- rotationDegrees,
270
- useFrontCamera
271
- )
272
- )
273
-
274
- val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) {
275
- image.height
276
- } else {
277
- image.width
278
- }
343
+ private fun updateRepeatingRequest() {
344
+ val builder = previewRequestBuilder ?: return
345
+ builder.set(CaptureRequest.FLASH_MODE, if (torchEnabled) CaptureRequest.FLASH_MODE_TORCH else CaptureRequest.FLASH_MODE_OFF)
346
+ try {
347
+ captureSession?.setRepeatingRequest(builder.build(), null, cameraHandler)
348
+ } catch (e: Exception) {
349
+ Log.e(TAG, "[CAMERA2] Failed to update torch state", e)
350
+ }
351
+ }
279
352
 
280
- val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) {
281
- image.width
282
- } else {
283
- image.height
284
- }
353
+ private fun analyzeImage(image: Image) {
354
+ try {
355
+ val nv21 = image.toNv21()
356
+ val rotationDegrees = computeRotationDegrees()
357
+ val rectangle = DocumentDetector.detectRectangleInYUV(nv21, image.width, image.height, rotationDegrees)
285
358
 
286
- val rectangle = DocumentDetector.detectRectangleInYUV(
287
- nv21,
288
- image.width,
289
- image.height,
290
- rotationDegrees
291
- )
292
- onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
293
- } catch (e: Exception) {
294
- Log.e(TAG, "[CAMERAX-V6] Error analyzing frame", e)
295
- } finally {
296
- image.close()
297
- }
359
+ val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) image.height else image.width
360
+ val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) image.width else image.height
361
+
362
+ onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
363
+ } catch (e: Exception) {
364
+ Log.e(TAG, "[CAMERA2] Error analyzing frame", e)
365
+ } finally {
366
+ image.close()
367
+ analysisInFlight.set(false)
368
+ }
369
+ }
370
+
371
+ private fun processCapture(image: Image, pending: PendingCapture) {
372
+ try {
373
+ val buffer = image.planes[0].buffer
374
+ val bytes = ByteArray(buffer.remaining())
375
+ buffer.get(bytes)
376
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
377
+ ?: throw IllegalStateException("Failed to decode JPEG")
378
+
379
+ val rotated = rotateAndMirror(bitmap, computeRotationDegrees(), useFrontCamera)
380
+ val photoFile = File(pending.outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
381
+ FileOutputStream(photoFile).use { out ->
382
+ rotated.compress(Bitmap.CompressFormat.JPEG, 95, out)
298
383
  }
299
384
 
300
- override fun onError(exception: ImageCaptureException) {
301
- Log.e(TAG, "[CAMERAX-V6] Frame capture for analysis failed", exception)
385
+ if (rotated != bitmap) {
386
+ rotated.recycle()
302
387
  }
303
- })
388
+ bitmap.recycle()
389
+
390
+ pending.onImageCaptured(photoFile)
391
+ } catch (e: Exception) {
392
+ pending.onError(e)
393
+ } finally {
394
+ image.close()
395
+ }
396
+ }
397
+
398
+ private fun closeSession() {
399
+ try {
400
+ captureSession?.close()
401
+ captureSession = null
402
+ cameraDevice?.close()
403
+ cameraDevice = null
404
+ } catch (e: Exception) {
405
+ Log.e(TAG, "[CAMERA2] Error closing camera", e)
406
+ } finally {
407
+ yuvReader?.close()
408
+ jpegReader?.close()
409
+ yuvReader = null
410
+ jpegReader = null
411
+ previewRequestBuilder = null
412
+ }
413
+ }
414
+
415
+ private fun computeRotationDegrees(): Int {
416
+ val cameraId = selectCameraId() ?: return 0
417
+ val characteristics = cameraManager.getCameraCharacteristics(cameraId)
418
+ val sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
419
+ val displayRotation = displayRotationDegrees()
420
+ return if (useFrontCamera) {
421
+ (sensorOrientation + displayRotation) % 360
422
+ } else {
423
+ (sensorOrientation - displayRotation + 360) % 360
424
+ }
425
+ }
426
+
427
+ private fun displayRotationDegrees(): Int {
428
+ val rotation = previewView.display?.rotation ?: Surface.ROTATION_0
429
+ return when (rotation) {
430
+ Surface.ROTATION_0 -> 0
431
+ Surface.ROTATION_90 -> 90
432
+ Surface.ROTATION_180 -> 180
433
+ Surface.ROTATION_270 -> 270
434
+ else -> 0
435
+ }
304
436
  }
305
437
 
306
- private fun nv21ToJpeg(nv21: ByteArray, width: Int, height: Int, quality: Int): ByteArray {
307
- val yuv = YuvImage(nv21, android.graphics.ImageFormat.NV21, width, height, null)
308
- val out = ByteArrayOutputStream()
309
- yuv.compressToJpeg(Rect(0, 0, width, height), quality, out)
310
- return out.toByteArray()
438
+ private fun configureTransform() {
439
+ val viewWidth = previewView.width.toFloat()
440
+ val viewHeight = previewView.height.toFloat()
441
+ val preview = previewSize ?: return
442
+ if (viewWidth == 0f || viewHeight == 0f) return
443
+
444
+ val rotation = displayRotationDegrees()
445
+ val bufferWidth = if (rotation == 90 || rotation == 270) preview.height.toFloat() else preview.width.toFloat()
446
+ val bufferHeight = if (rotation == 90 || rotation == 270) preview.width.toFloat() else preview.height.toFloat()
447
+
448
+ val scale = max(viewWidth / bufferWidth, viewHeight / bufferHeight)
449
+ val matrix = Matrix()
450
+ val centerX = viewWidth / 2f
451
+ val centerY = viewHeight / 2f
452
+
453
+ matrix.setScale(scale, scale, centerX, centerY)
454
+ matrix.postRotate(rotation.toFloat(), centerX, centerY)
455
+ previewView.setTransform(matrix)
456
+ }
457
+
458
+ private fun chooseBestSize(sizes: Array<Size>?, targetAspect: Double, maxArea: Int?): Size? {
459
+ if (sizes == null || sizes.isEmpty()) return null
460
+ val sorted = sizes.sortedByDescending { it.width * it.height }
461
+
462
+ val matching = sorted.filter {
463
+ val aspect = it.width.toDouble() / it.height.toDouble()
464
+ abs(aspect - targetAspect) <= ANALYSIS_ASPECT_TOLERANCE && (maxArea == null || it.width * it.height <= maxArea)
465
+ }
466
+
467
+ if (matching.isNotEmpty()) {
468
+ return matching.first()
469
+ }
470
+
471
+ val capped = if (maxArea != null) {
472
+ sorted.filter { it.width * it.height <= maxArea }
473
+ } else {
474
+ sorted
475
+ }
476
+
477
+ return capped.firstOrNull() ?: sorted.first()
311
478
  }
312
479
 
313
480
  private fun rotateAndMirror(bitmap: Bitmap, rotationDegrees: Int, mirror: Boolean): Bitmap {
@@ -324,7 +491,66 @@ class CameraController(
324
491
  return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
325
492
  }
326
493
 
494
+ private fun Image.toNv21(): ByteArray {
495
+ val width = width
496
+ val height = height
497
+ val ySize = width * height
498
+ val uvSize = width * height / 2
499
+ val nv21 = ByteArray(ySize + uvSize)
500
+
501
+ val yBuffer = planes[0].buffer
502
+ val uBuffer = planes[1].buffer
503
+ val vBuffer = planes[2].buffer
504
+
505
+ val yRowStride = planes[0].rowStride
506
+ val yPixelStride = planes[0].pixelStride
507
+ var outputOffset = 0
508
+ for (row in 0 until height) {
509
+ var inputOffset = row * yRowStride
510
+ for (col in 0 until width) {
511
+ nv21[outputOffset++] = yBuffer.get(inputOffset)
512
+ inputOffset += yPixelStride
513
+ }
514
+ }
515
+
516
+ val uvRowStride = planes[1].rowStride
517
+ val uvPixelStride = planes[1].pixelStride
518
+ val vRowStride = planes[2].rowStride
519
+ val vPixelStride = planes[2].pixelStride
520
+ val uvHeight = height / 2
521
+ val uvWidth = width / 2
522
+ for (row in 0 until uvHeight) {
523
+ var uInputOffset = row * uvRowStride
524
+ var vInputOffset = row * vRowStride
525
+ for (col in 0 until uvWidth) {
526
+ nv21[outputOffset++] = vBuffer.get(vInputOffset)
527
+ nv21[outputOffset++] = uBuffer.get(uInputOffset)
528
+ uInputOffset += uvPixelStride
529
+ vInputOffset += vPixelStride
530
+ }
531
+ }
532
+
533
+ return nv21
534
+ }
535
+
327
536
  private fun hasCameraPermission(): Boolean {
328
537
  return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
329
538
  }
539
+
540
+ private fun selectCameraId(): String? {
541
+ return try {
542
+ val desiredFacing = if (useFrontCamera) {
543
+ CameraCharacteristics.LENS_FACING_FRONT
544
+ } else {
545
+ CameraCharacteristics.LENS_FACING_BACK
546
+ }
547
+ cameraManager.cameraIdList.firstOrNull { id ->
548
+ val characteristics = cameraManager.getCameraCharacteristics(id)
549
+ characteristics.get(CameraCharacteristics.LENS_FACING) == desiredFacing
550
+ } ?: cameraManager.cameraIdList.firstOrNull()
551
+ } catch (e: Exception) {
552
+ Log.e(TAG, "[CAMERA2] Failed to select camera", e)
553
+ null
554
+ }
555
+ }
330
556
  }
@@ -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)
@@ -74,13 +74,10 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
74
74
 
75
75
  // Create preview view
76
76
  Log.d(TAG, "[INIT] Creating PreviewView...")
77
- previewView = PreviewView(context).apply {
77
+ previewView = TextureView(context).apply {
78
78
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
79
79
  visibility = View.VISIBLE
80
80
  keepScreenOn = true
81
- // TextureView mode avoids some device-specific Camera2 session timeouts.
82
- implementationMode = PreviewView.ImplementationMode.COMPATIBLE
83
- scaleType = PreviewView.ScaleType.FILL_CENTER
84
81
  }
85
82
  Log.d(TAG, "[INIT] PreviewView created: $previewView")
86
83
  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": "3.229.0",
3
+ "version": "3.231.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",