capacitor-camera-view 1.0.2 → 1.0.4

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.
@@ -7,23 +7,24 @@ import android.hardware.camera2.CameraCharacteristics
7
7
  import android.hardware.camera2.CameraManager
8
8
  import android.util.Base64
9
9
  import android.util.Log
10
+ import android.view.Surface
10
11
  import android.view.ViewGroup
11
12
  import android.webkit.WebView
12
13
  import androidx.annotation.OptIn
13
14
  import androidx.camera.camera2.interop.Camera2CameraInfo
14
15
  import androidx.camera.camera2.interop.ExperimentalCamera2Interop
15
16
  import androidx.camera.core.CameraSelector
17
+ import androidx.camera.core.ExperimentalZeroShutterLag
16
18
  import androidx.camera.core.ImageAnalysis
17
19
  import androidx.camera.core.ImageCapture
18
20
  import androidx.camera.core.ImageCaptureException
21
+ import androidx.camera.core.ImageProxy
19
22
  import androidx.camera.core.resolutionselector.AspectRatioStrategy
20
23
  import androidx.camera.core.resolutionselector.ResolutionSelector
21
24
  import androidx.camera.mlkit.vision.MlKitAnalyzer
22
- import androidx.camera.view.CameraController
23
25
  import androidx.camera.view.LifecycleCameraController
24
26
  import androidx.camera.view.PreviewView
25
27
  import androidx.core.content.ContextCompat
26
- import androidx.exifinterface.media.ExifInterface
27
28
  import androidx.lifecycle.LifecycleOwner
28
29
  import com.getcapacitor.Plugin
29
30
  import com.google.mlkit.vision.barcode.BarcodeScanner
@@ -35,8 +36,6 @@ import com.michaelwolz.capacitorcameraview.model.CameraDevice
35
36
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
36
37
  import com.michaelwolz.capacitorcameraview.model.ZoomFactors
37
38
  import java.io.ByteArrayOutputStream
38
- import java.io.File
39
- import java.util.UUID
40
39
  import java.util.concurrent.ExecutorService
41
40
  import java.util.concurrent.Executors
42
41
 
@@ -53,9 +52,6 @@ class CameraView(plugin: Plugin) {
53
52
  private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
54
53
  private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
55
54
 
56
- // Camera use cases
57
- private var imageCapture: ImageCapture? = null
58
-
59
55
  // Plugin context
60
56
  private var lifecycleOwner: LifecycleOwner? = null
61
57
  private var pluginDelegate: Plugin = plugin
@@ -95,8 +91,6 @@ class CameraView(plugin: Plugin) {
95
91
  cameraController?.unbind()
96
92
 
97
93
  try {
98
- imageCapture = null
99
-
100
94
  previewView?.let { view ->
101
95
  try {
102
96
  (webView.parent as? ViewGroup)?.removeView(view)
@@ -126,34 +120,44 @@ class CameraView(plugin: Plugin) {
126
120
 
127
121
  /** Capture a photo with the current camera configuration */
128
122
  fun capturePhoto(quality: Int, callback: (String?, Exception?) -> Unit) {
129
- val timeStart = System.currentTimeMillis()
130
- val controller = this.cameraController
123
+ val startTime = System.currentTimeMillis()
124
+ val controller =
125
+ this.cameraController
126
+ ?: run {
127
+ callback(null, Exception("Camera controller not initialized"))
128
+ return
129
+ }
130
+
131
+ val preview = previewView
131
132
  ?: run {
132
- callback(null, Exception("Camera controller not initialized"))
133
+ callback(null, Exception("Camera preview not initialized"))
133
134
  return
134
135
  }
135
136
 
136
137
  mainHandler.post {
137
- try {
138
- // Create temporary file for the captured image
139
- val tempFile =
140
- File.createTempFile(UUID.randomUUID().toString(), ".jpg", context.cacheDir)
141
- val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
138
+ val cameraInfo = controller.cameraInfo
139
+ val isFrontFacing = controller.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
140
+ val sensorRotationDegrees = cameraInfo?.sensorRotationDegrees ?: 0
141
+ val displayRotationDegrees = preview.display?.rotation ?: Surface.ROTATION_0
142
+ val imageRotationDegrees = calculateImageRotationBasedOnDisplayRotation(
143
+ displayRotationDegrees,
144
+ sensorRotationDegrees,
145
+ isFrontFacing
146
+ )
142
147
 
148
+ try {
143
149
  controller.takePicture(
144
- outputOptions,
145
150
  cameraExecutor,
146
- object : ImageCapture.OnImageSavedCallback {
147
- override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
151
+ object : ImageCapture.OnImageCapturedCallback() {
152
+ override fun onCaptureSuccess(image: ImageProxy) {
148
153
  Log.d(
149
154
  TAG,
150
- "Image stored to temp file in ${System.currentTimeMillis() - timeStart} ms"
155
+ "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
151
156
  )
152
- handleImageSaved(tempFile, quality, callback)
157
+ handleCaptureSuccess(image, quality, imageRotationDegrees, callback)
153
158
  }
154
159
 
155
160
  override fun onError(exception: ImageCaptureException) {
156
- tempFile.delete()
157
161
  Log.e(TAG, "Error capturing image", exception)
158
162
  callback(null, exception)
159
163
  }
@@ -167,58 +171,25 @@ class CameraView(plugin: Plugin) {
167
171
  }
168
172
 
169
173
  /**
170
- * Handles the image saved callback, re-encodes the JPEG if quality is specified
171
- * and returns the Base64 encoded string as the callback result.
174
+ * Handles the successful capture of an image, converting it to a Base64 string
172
175
  */
173
- private fun handleImageSaved(
174
- tempFile: File,
176
+ fun handleCaptureSuccess(
177
+ image: ImageProxy,
175
178
  quality: Int,
179
+ rotationDegrees: Int,
176
180
  callback: (String?, Exception?) -> Unit
177
181
  ) {
178
182
  val startTime = System.currentTimeMillis()
179
183
  try {
180
- val jpegBytes = tempFile.readBytes()
181
- Log.d(TAG, "Image size : ${jpegBytes.size} bytes")
182
- val base64String = if (quality == 100) {
183
- // If quality is 100, return the original JPEG without re-encoding
184
- Log.d(TAG, "Encoding original JPEG with quality 100")
185
- Base64.encodeToString(jpegBytes, Base64.NO_WRAP)
186
- } else {
187
- // Otherwise, re-encode the JPEG with the specified quality
188
- // which is a little bit more expensive
189
- Log.d(TAG, "Re-encoding JPEG with quality $quality")
190
- val originalExif = ExifInterface(tempFile.absolutePath)
191
- val orientation = originalExif.getAttributeInt(
192
- ExifInterface.TAG_ORIENTATION,
193
- ExifInterface.ORIENTATION_UNDEFINED
194
- )
195
-
196
- val bitmap =
197
- android.graphics.BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
198
- val compressedFile =
199
- File.createTempFile(UUID.randomUUID().toString(), ".jpg", context.cacheDir)
200
- val outputStream = compressedFile.outputStream()
201
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
202
- outputStream.close()
203
-
204
- val newExif = ExifInterface(compressedFile.absolutePath)
205
- newExif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
206
- newExif.saveAttributes()
207
-
208
- val compressedBytes = compressedFile.readBytes()
209
- compressedFile.delete()
210
- Log.d(TAG, "Re-encoded image size: ${compressedBytes.size} bytes")
211
- Base64.encodeToString(compressedBytes, Base64.NO_WRAP)
212
- }
213
- Log.d(
214
- TAG,
215
- "Image processing took ${System.currentTimeMillis() - startTime} ms (quality: $quality)"
216
- )
217
- tempFile.delete()
184
+ // Turn the image into a Base64 encoded string and apply rotation if necessary
185
+ val base64String = imageProxyToBase64(image, quality, rotationDegrees)
186
+ Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
218
187
  callback(base64String, null)
219
188
  } catch (e: Exception) {
220
- tempFile.delete()
189
+ Log.e(TAG, "Error processing captured image", e)
221
190
  callback(null, e)
191
+ } finally {
192
+ image.close()
222
193
  }
223
194
  }
224
195
 
@@ -262,9 +233,9 @@ class CameraView(plugin: Plugin) {
262
233
  /** Flip between front and back cameras */
263
234
  fun flipCamera(callback: (Exception?) -> Unit) {
264
235
  currentCameraSelector = when (currentCameraSelector) {
265
- CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
266
- else -> CameraSelector.DEFAULT_FRONT_CAMERA
267
- }
236
+ CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
237
+ else -> CameraSelector.DEFAULT_FRONT_CAMERA
238
+ }
268
239
 
269
240
  val controller =
270
241
  this.cameraController
@@ -409,7 +380,6 @@ class CameraView(plugin: Plugin) {
409
380
 
410
381
  // Clear references
411
382
  lifecycleOwner = null
412
- imageCapture = null
413
383
 
414
384
  // Shutdown executor
415
385
  if (!cameraExecutor.isShutdown) {
@@ -451,33 +421,35 @@ class CameraView(plugin: Plugin) {
451
421
  setupPreviewView(context)
452
422
 
453
423
  currentCameraSelector = if (config.position == "front") {
454
- CameraSelector.DEFAULT_FRONT_CAMERA
455
- } else {
456
- CameraSelector.DEFAULT_BACK_CAMERA
457
- }
424
+ CameraSelector.DEFAULT_FRONT_CAMERA
425
+ } else {
426
+ CameraSelector.DEFAULT_BACK_CAMERA
427
+ }
458
428
 
459
429
  if (config.deviceId != null) {
460
430
  // Prefer specific device id over position
461
431
  currentCameraSelector = CameraSelector.Builder()
462
- .addCameraFilter { cameraInfos ->
463
- cameraInfos.filter { info ->
464
- val cameraId = Camera2CameraInfo.from(info).cameraId
465
- cameraId == config.deviceId
432
+ .addCameraFilter { cameraInfos ->
433
+ cameraInfos.filter { info ->
434
+ val cameraId = Camera2CameraInfo.from(info).cameraId
435
+ cameraId == config.deviceId
436
+ }
466
437
  }
467
- }
468
- .build()
438
+ .build()
469
439
  }
470
440
 
471
441
  // Initialize camera controller
472
- val controller = LifecycleCameraController(context).apply {
473
- cameraSelector = currentCameraSelector
474
- imageCapture = ImageCapture.Builder()
475
- .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
476
- .build()
477
- imageCaptureResolutionSelector = ResolutionSelector.Builder()
478
- .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
479
- .build()
480
- }
442
+ val controller =
443
+ LifecycleCameraController(context).apply {
444
+ cameraSelector = currentCameraSelector
445
+ imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
446
+ imageCaptureResolutionSelector =
447
+ ResolutionSelector.Builder()
448
+ .setAspectRatioStrategy(
449
+ AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
450
+ )
451
+ .build()
452
+ }
481
453
 
482
454
  cameraController = controller
483
455
  previewView?.controller = controller
@@ -1,13 +1,20 @@
1
1
  package com.michaelwolz.capacitorcameraview
2
2
 
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Matrix
3
6
  import android.graphics.Rect
7
+ import android.util.Base64
8
+ import android.view.Surface
4
9
  import android.view.View
5
10
  import android.view.ViewGroup.MarginLayoutParams
11
+ import androidx.camera.core.ImageProxy
6
12
  import androidx.camera.view.PreviewView
7
13
  import com.getcapacitor.PluginCall
8
14
  import com.google.mlkit.vision.barcode.common.Barcode
9
15
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
10
16
  import com.michaelwolz.capacitorcameraview.model.WebBoundingRect
17
+ import java.io.ByteArrayOutputStream
11
18
 
12
19
  /** Converts a barcode format code to a readable string. */
13
20
  fun getBarcodeFormatString(format: Int): String {
@@ -84,3 +91,74 @@ fun sessionConfigFromPluginCall(call: PluginCall): CameraSessionConfiguration {
84
91
  zoomFactor = call.getFloat("zoomFactor") ?: 1.0f
85
92
  )
86
93
  }
94
+
95
+ /**
96
+ * Calculates the image orientation based on the display rotation and sensor rotation degrees.
97
+ *
98
+ * This is because CameraController will set the image orientation based on the device's
99
+ * motion sensor, which may not match the display rotation and in this case not what we actually
100
+ * want.
101
+ *
102
+ * @param displayRotation The current display rotation (0, 1, 2, or 3).
103
+ * @param sensorRotationDegrees The rotation of the camera sensor in degrees (0, 90, 180, or 270).
104
+ * @param isFrontFacing Whether the camera is front-facing or back-facing.
105
+ * @return The calculated image orientation in degrees.
106
+ */
107
+ fun calculateImageRotationBasedOnDisplayRotation(
108
+ displayRotation: Int,
109
+ sensorRotationDegrees: Int,
110
+ isFrontFacing: Boolean
111
+ ): Int {
112
+ val surfaceRotationDegrees = when (displayRotation) {
113
+ Surface.ROTATION_0 -> 0
114
+ Surface.ROTATION_90 -> 90
115
+ Surface.ROTATION_180 -> 180
116
+ Surface.ROTATION_270 -> 270
117
+ else -> 0
118
+ }
119
+
120
+ return if (isFrontFacing) {
121
+ (sensorRotationDegrees + surfaceRotationDegrees) % 360
122
+ } else {
123
+ (sensorRotationDegrees - surfaceRotationDegrees + 360) % 360
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Converts an ImageProxy to a Base64 encoded string and applies rotation if necessary.
129
+ *
130
+ * @param image The ImageProxy to convert.
131
+ * @param quality The JPEG compression quality (0-100).
132
+ * @param rotationDegrees The degrees to rotate the image (0, 90, 180, 270).
133
+ */
134
+ fun imageProxyToBase64(image: ImageProxy, quality: Int, rotationDegrees: Int): String {
135
+ val buffer = image.planes[0].buffer
136
+ val bytes = ByteArray(buffer.remaining())
137
+ buffer.get(bytes)
138
+
139
+ var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
140
+ ?: throw IllegalArgumentException("Failed to decode image")
141
+
142
+ try {
143
+ // Apply rotation if needed
144
+ if (rotationDegrees != 0) {
145
+ val matrix = Matrix().apply {
146
+ postRotate(rotationDegrees.toFloat())
147
+ }
148
+ val rotatedBitmap =
149
+ Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
150
+ // Recycle the original bitmap to prevent memory leaks
151
+ bitmap.recycle()
152
+ bitmap = rotatedBitmap
153
+ }
154
+
155
+ val outputStream = ByteArrayOutputStream()
156
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
157
+ val byteArray = outputStream.toByteArray()
158
+
159
+ return Base64.encodeToString(byteArray, Base64.NO_WRAP)
160
+ } finally {
161
+ // Ensure bitmap is always recycled
162
+ bitmap.recycle()
163
+ }
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capacitor-camera-view",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A Capacitor plugin for embedding a live camera feed directly into your app.",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",