capacitor-camera-view 1.0.1 → 1.0.3

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,22 +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
25
  import androidx.camera.view.LifecycleCameraController
23
26
  import androidx.camera.view.PreviewView
24
27
  import androidx.core.content.ContextCompat
25
- import androidx.exifinterface.media.ExifInterface
26
28
  import androidx.lifecycle.LifecycleOwner
27
29
  import com.getcapacitor.Plugin
28
30
  import com.google.mlkit.vision.barcode.BarcodeScanner
@@ -34,8 +36,6 @@ import com.michaelwolz.capacitorcameraview.model.CameraDevice
34
36
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
35
37
  import com.michaelwolz.capacitorcameraview.model.ZoomFactors
36
38
  import java.io.ByteArrayOutputStream
37
- import java.io.File
38
- import java.util.UUID
39
39
  import java.util.concurrent.ExecutorService
40
40
  import java.util.concurrent.Executors
41
41
 
@@ -52,9 +52,6 @@ class CameraView(plugin: Plugin) {
52
52
  private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
53
53
  private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
54
54
 
55
- // Camera use cases
56
- private var imageCapture: ImageCapture? = null
57
-
58
55
  // Plugin context
59
56
  private var lifecycleOwner: LifecycleOwner? = null
60
57
  private var pluginDelegate: Plugin = plugin
@@ -94,8 +91,6 @@ class CameraView(plugin: Plugin) {
94
91
  cameraController?.unbind()
95
92
 
96
93
  try {
97
- imageCapture = null
98
-
99
94
  previewView?.let { view ->
100
95
  try {
101
96
  (webView.parent as? ViewGroup)?.removeView(view)
@@ -124,30 +119,46 @@ class CameraView(plugin: Plugin) {
124
119
  }
125
120
 
126
121
  /** Capture a photo with the current camera configuration */
122
+ @OptIn(ExperimentalZeroShutterLag::class)
127
123
  fun capturePhoto(quality: Int, callback: (String?, Exception?) -> Unit) {
128
- val controller = this.cameraController
124
+ val startTime = System.currentTimeMillis()
125
+ val controller =
126
+ this.cameraController
127
+ ?: run {
128
+ callback(null, Exception("Camera controller not initialized"))
129
+ return
130
+ }
131
+
132
+ val preview = previewView
129
133
  ?: run {
130
- callback(null, Exception("Camera controller not initialized"))
134
+ callback(null, Exception("Camera preview not initialized"))
131
135
  return
132
136
  }
133
137
 
134
138
  mainHandler.post {
135
- try {
136
- // Create temporary file for the captured image
137
- val tempFile =
138
- File.createTempFile(UUID.randomUUID().toString(), ".jpg", context.cacheDir)
139
- val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
139
+ val cameraInfo = controller.cameraInfo
140
+ val isFrontFacing = controller.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
141
+ val sensorRotationDegrees = cameraInfo?.sensorRotationDegrees ?: 0
142
+ val displayRotationDegrees = preview.display?.rotation ?: Surface.ROTATION_0
143
+ val imageRotationDegrees = calculateImageRotationBasedOnDisplayRotation(
144
+ displayRotationDegrees,
145
+ sensorRotationDegrees,
146
+ isFrontFacing
147
+ )
140
148
 
149
+ try {
141
150
  controller.takePicture(
142
- outputOptions,
143
151
  cameraExecutor,
144
- object : ImageCapture.OnImageSavedCallback {
145
- override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
146
- handleImageSaved(tempFile, quality, callback)
152
+ object : ImageCapture.OnImageCapturedCallback() {
153
+ override fun onCaptureSuccess(image: ImageProxy) {
154
+ Log.d(
155
+ TAG,
156
+ "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
157
+ )
158
+ handleCaptureSuccess(image, quality, imageRotationDegrees, callback)
147
159
  }
148
160
 
149
161
  override fun onError(exception: ImageCaptureException) {
150
- tempFile.delete()
151
162
  Log.e(TAG, "Error capturing image", exception)
152
163
  callback(null, exception)
153
164
  }
@@ -161,57 +172,25 @@ class CameraView(plugin: Plugin) {
161
172
  }
162
173
 
163
174
  /**
164
- * Handles the image saved callback, re-encodes the JPEG if quality is specified
165
- * and returns the Base64 encoded string as the callback result.
175
+ * Handles the successful capture of an image, converting it to a Base64 string
166
176
  */
167
- private fun handleImageSaved(
168
- tempFile: File,
177
+ fun handleCaptureSuccess(
178
+ image: ImageProxy,
169
179
  quality: Int,
180
+ rotationDegrees: Int,
170
181
  callback: (String?, Exception?) -> Unit
171
182
  ) {
172
183
  val startTime = System.currentTimeMillis()
173
184
  try {
174
- val jpegBytes = tempFile.readBytes()
175
- val base64String = if (quality == 100) {
176
- // If quality is 100, return the original JPEG without re-encoding
177
- Log.d(TAG, "Encoding original JPEG (quality 100)")
178
- Base64.encodeToString(jpegBytes, Base64.NO_WRAP)
179
- } else {
180
- // Otherwise, re-encode the JPEG with the specified quality
181
- // which is a little bit more expensive
182
- Log.d(TAG, "Re-encoding JPEG with quality $quality")
183
- val originalExif = ExifInterface(tempFile.absolutePath)
184
- val orientation = originalExif.getAttributeInt(
185
- ExifInterface.TAG_ORIENTATION,
186
- ExifInterface.ORIENTATION_UNDEFINED
187
- )
188
-
189
- val bitmap =
190
- android.graphics.BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
191
- val compressedFile =
192
- File.createTempFile(UUID.randomUUID().toString(), ".jpg", context.cacheDir)
193
- val outputStream = compressedFile.outputStream()
194
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
195
- outputStream.close()
196
-
197
- val newExif = ExifInterface(compressedFile.absolutePath)
198
- newExif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
199
- newExif.saveAttributes()
200
-
201
- val compressedBytes = compressedFile.readBytes()
202
- compressedFile.delete()
203
- Base64.encodeToString(compressedBytes, Base64.NO_WRAP)
204
- }
205
- val endTime = System.currentTimeMillis()
206
- Log.d(
207
- TAG,
208
- "Image processing took ${endTime - startTime} ms (quality: ${quality ?: 100})"
209
- )
210
- tempFile.delete()
185
+ // Turn the image into a Base64 encoded string and apply rotation if necessary
186
+ val base64String = imageProxyToBase64(image, quality, rotationDegrees)
187
+ Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
211
188
  callback(base64String, null)
212
189
  } catch (e: Exception) {
213
- tempFile.delete()
190
+ Log.e(TAG, "Error processing captured image", e)
214
191
  callback(null, e)
192
+ } finally {
193
+ image.close()
215
194
  }
216
195
  }
217
196
 
@@ -219,7 +198,7 @@ class CameraView(plugin: Plugin) {
219
198
  * Capture a frame directly from the preview without using the full photo pipeline which is
220
199
  * faster but has lower quality.
221
200
  */
222
- fun captureSampleFromPreview(quality: Int?, callback: (String?, Exception?) -> Unit) {
201
+ fun captureSampleFromPreview(quality: Int, callback: (String?, Exception?) -> Unit) {
223
202
  val previewView =
224
203
  this.previewView
225
204
  ?: run {
@@ -238,7 +217,7 @@ class CameraView(plugin: Plugin) {
238
217
  }
239
218
 
240
219
  // Convert bitmap to Base64
241
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality ?: 90, outputStream)
220
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
242
221
  val byteArray = outputStream.toByteArray()
243
222
  val base64String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
244
223
 
@@ -255,9 +234,9 @@ class CameraView(plugin: Plugin) {
255
234
  /** Flip between front and back cameras */
256
235
  fun flipCamera(callback: (Exception?) -> Unit) {
257
236
  currentCameraSelector = when (currentCameraSelector) {
258
- CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
259
- else -> CameraSelector.DEFAULT_FRONT_CAMERA
260
- }
237
+ CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
238
+ else -> CameraSelector.DEFAULT_FRONT_CAMERA
239
+ }
261
240
 
262
241
  val controller =
263
242
  this.cameraController
@@ -402,7 +381,6 @@ class CameraView(plugin: Plugin) {
402
381
 
403
382
  // Clear references
404
383
  lifecycleOwner = null
405
- imageCapture = null
406
384
 
407
385
  // Shutdown executor
408
386
  if (!cameraExecutor.isShutdown) {
@@ -434,7 +412,7 @@ class CameraView(plugin: Plugin) {
434
412
  (webView.parent as? ViewGroup)?.addView(previewView, 0)
435
413
  }
436
414
 
437
- @OptIn(ExperimentalCamera2Interop::class)
415
+ @OptIn(ExperimentalCamera2Interop::class, ExperimentalZeroShutterLag::class)
438
416
  private fun initializeCamera(
439
417
  context: Context,
440
418
  lifecycleOwner: LifecycleOwner,
@@ -444,32 +422,35 @@ class CameraView(plugin: Plugin) {
444
422
  setupPreviewView(context)
445
423
 
446
424
  currentCameraSelector = if (config.position == "front") {
447
- CameraSelector.DEFAULT_FRONT_CAMERA
448
- } else {
449
- CameraSelector.DEFAULT_BACK_CAMERA
450
- }
425
+ CameraSelector.DEFAULT_FRONT_CAMERA
426
+ } else {
427
+ CameraSelector.DEFAULT_BACK_CAMERA
428
+ }
451
429
 
452
430
  if (config.deviceId != null) {
453
431
  // Prefer specific device id over position
454
432
  currentCameraSelector = CameraSelector.Builder()
455
- .addCameraFilter { cameraInfos ->
456
- cameraInfos.filter { info ->
457
- val cameraId = Camera2CameraInfo.from(info).cameraId
458
- cameraId == config.deviceId
433
+ .addCameraFilter { cameraInfos ->
434
+ cameraInfos.filter { info ->
435
+ val cameraId = Camera2CameraInfo.from(info).cameraId
436
+ cameraId == config.deviceId
437
+ }
459
438
  }
460
- }
461
- .build()
439
+ .build()
462
440
  }
463
441
 
464
442
  // Initialize camera controller
465
- val controller = LifecycleCameraController(context).apply {
466
- cameraSelector = currentCameraSelector
467
- imageCaptureResolutionSelector = ResolutionSelector.Builder()
468
- .setAspectRatioStrategy(
469
- AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
470
- )
471
- .build()
472
- }
443
+ val controller =
444
+ LifecycleCameraController(context).apply {
445
+ cameraSelector = currentCameraSelector
446
+ imageCaptureMode = ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG
447
+ imageCaptureResolutionSelector =
448
+ ResolutionSelector.Builder()
449
+ .setAspectRatioStrategy(
450
+ AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
451
+ )
452
+ .build()
453
+ }
473
454
 
474
455
  cameraController = controller
475
456
  previewView?.controller = controller
@@ -1,6 +1,7 @@
1
1
  package com.michaelwolz.capacitorcameraview
2
2
 
3
3
  import android.Manifest
4
+ import android.util.Log
4
5
  import com.getcapacitor.JSArray
5
6
  import com.getcapacitor.JSObject
6
7
  import com.getcapacitor.PermissionState
@@ -73,6 +74,7 @@ class CameraViewPlugin : Plugin() {
73
74
 
74
75
  @PluginMethod
75
76
  fun capture(call: PluginCall) {
77
+ val timeStart = System.currentTimeMillis();
76
78
  val quality = call.getInt("quality") ?: 90
77
79
 
78
80
  if (quality !in 0..100) {
@@ -86,12 +88,14 @@ class CameraViewPlugin : Plugin() {
86
88
  photo == null -> call.reject("No image data")
87
89
  else -> call.resolve(JSObject().apply { put("photo", photo) })
88
90
  }
91
+ Log.d(TAG, "capture took ${System.currentTimeMillis() - timeStart}ms")
89
92
  }
90
93
  }
91
94
 
92
95
  @PluginMethod
93
96
  fun captureSample(call: PluginCall) {
94
- val quality = call.getInt("quality", 90)
97
+ val timeStart = System.currentTimeMillis();
98
+ val quality = call.getInt("quality") ?: 90
95
99
 
96
100
  if (quality !in 0..100) {
97
101
  call.reject("Quality must be between 0 and 100")
@@ -104,6 +108,7 @@ class CameraViewPlugin : Plugin() {
104
108
  photo == null -> call.reject("No frame data")
105
109
  else -> call.resolve(JSObject().apply { put("photo", photo) })
106
110
  }
111
+ Log.d(TAG, "captureSample took ${System.currentTimeMillis() - timeStart}ms")
107
112
  }
108
113
  }
109
114
 
@@ -224,4 +229,8 @@ class CameraViewPlugin : Plugin() {
224
229
  implementation.cleanup()
225
230
  super.handleOnDestroy()
226
231
  }
232
+
233
+ companion object {
234
+ private const val TAG = "CameraViewPlugin"
235
+ }
227
236
  }
@@ -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.1",
3
+ "version": "1.0.3",
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",