Skip to content

Commit b97626b

Browse files
authored
Implement KMP Selfie Check Composable with the flow View Model. (#1062)
Test: All Unit tests are passing (bar KI). Test: Manually tested via samples/testapp on Android and iOS. Signed-off-by: koukarine <koukarine@google.com>
1 parent 0dc56c4 commit b97626b

20 files changed

Lines changed: 1894 additions & 130 deletions

File tree

multipaz-compose/src/androidMain/kotlin/org/multipaz/compose/Util.android.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,22 @@ actual fun cropRotateScaleImage(
4646
cx: Double,
4747
cy: Double,
4848
angleDegrees: Double,
49-
outputWidth: Int,
50-
outputHeight: Int,
51-
targetWidth: Int
49+
outputWidthPx: Int,
50+
outputHeightPx: Int,
51+
targetWidthPx: Int
5252
): ImageBitmap {
5353
val androidBitmap = frameData.cameraImage.imageProxy.toBitmap()
54-
val finalScale = targetWidth.toFloat() / outputWidth.toFloat()
55-
val finalOutputHeight = (outputHeight * finalScale).toInt()
54+
val finalScale = targetWidthPx.toFloat() / outputWidthPx.toFloat()
55+
val finalOutputHeight = (outputHeightPx * finalScale).toInt()
5656
val matrix = Matrix() // Use Android's Matrix
5757

5858
matrix.postTranslate(-cx.toFloat(), -cy.toFloat())
5959
matrix.postRotate(angleDegrees.toFloat())
60-
matrix.postTranslate((outputWidth / 2).toFloat(), (outputHeight / 2).toFloat())
60+
matrix.postTranslate((outputWidthPx / 2).toFloat(), (outputHeightPx / 2).toFloat())
6161
matrix.postScale(finalScale, finalScale)
6262

6363
// Create the output bitmap with the final scaled dimensions.
64-
val resultBitmap = createBitmap(targetWidth, finalOutputHeight, androidBitmap.config ?: Bitmap.Config.ARGB_8888)
64+
val resultBitmap = createBitmap(targetWidthPx, finalOutputHeight, androidBitmap.config ?: Bitmap.Config.ARGB_8888)
6565
Canvas(resultBitmap).drawBitmap(androidBitmap, matrix, paint)
6666

6767
return resultBitmap.asImageBitmap()

multipaz-compose/src/androidMain/kotlin/org/multipaz/compose/camera/Camera.android.kt

Lines changed: 99 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.camera.core.resolutionselector.ResolutionStrategy
1313
import androidx.camera.lifecycle.ProcessCameraProvider
1414
import androidx.camera.view.PreviewView
1515
import androidx.camera.view.TransformExperimental
16+
import androidx.compose.foundation.layout.add
1617
import androidx.compose.runtime.Composable
1718
import androidx.compose.runtime.DisposableEffect
1819
import androidx.compose.runtime.getValue
@@ -31,6 +32,8 @@ import kotlinx.coroutines.runBlocking
3132
import kotlinx.coroutines.withContext
3233
import org.multipaz.util.Logger
3334
import java.util.concurrent.Executors
35+
import kotlin.collections.isNotEmpty
36+
import kotlin.collections.toTypedArray
3437

3538
private const val TAG = "Camera"
3639

@@ -47,7 +50,7 @@ actual fun Camera(
4750
val lifecycleOwner = LocalLifecycleOwner.current
4851
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
4952
val executor = remember { Executors.newSingleThreadExecutor() }
50-
val previewView = remember { mutableStateOf<PreviewView?>(if (showCameraPreview) PreviewView(context) else null) }
53+
var activePreviewView by remember { mutableStateOf<PreviewView?>(null) }
5154
var currentDisplayRotation by remember { mutableIntStateOf(Surface.ROTATION_0) }
5255

5356
DisposableEffect(Unit) { // Keyed by Unit to run once and clean up with the composable
@@ -73,81 +76,113 @@ actual fun Camera(
7376
}
7477
}
7578

76-
DisposableEffect(cameraSelection) {
77-
var preview: Preview? = null
78-
if (showCameraPreview) {
79-
preview = Preview.Builder().build()
80-
// COMPATIBLE is needed b/c we're using `outputTransform.matrix`
81-
previewView.value!!.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
82-
preview.setSurfaceProvider(previewView.value!!.surfaceProvider)
83-
}
84-
79+
DisposableEffect(cameraSelection, showCameraPreview, activePreviewView, currentDisplayRotation) {
8580
val cameraProvider = cameraProviderFuture.get()
8681

87-
val resolutionStrategy = ResolutionStrategy(
88-
captureResolution.getDimensions(),
89-
ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
90-
)
91-
val resolutionSelector = ResolutionSelector.Builder()
92-
.setResolutionStrategy(resolutionStrategy)
93-
.build()
94-
95-
val imageAnalysis = ImageAnalysis.Builder()
96-
.setResolutionSelector(resolutionSelector)
97-
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
98-
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
99-
.build()
100-
101-
imageAnalysis.setAnalyzer(executor) { imageProxy ->
102-
runBlocking {
103-
val transformationProxy = if (showCameraPreview) {
104-
withContext(Dispatchers.Main) {
105-
getCorrectionMatrix(imageProxy, previewView.value!!)
82+
if (!cameraProviderFuture.isDone) {
83+
onDispose { /** Not bound. */ }
84+
} else {
85+
cameraProvider.unbindAll()
86+
87+
val resolutionStrategy = ResolutionStrategy(
88+
captureResolution.getDimensions(),
89+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
90+
)
91+
val resolutionSelector = ResolutionSelector.Builder()
92+
.setResolutionStrategy(resolutionStrategy)
93+
.build()
94+
95+
val imageAnalysis = ImageAnalysis.Builder()
96+
.setResolutionSelector(resolutionSelector)
97+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
98+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
99+
.setTargetRotation(currentDisplayRotation)
100+
.build()
101+
102+
imageAnalysis.setAnalyzer(executor) { imageProxy ->
103+
runBlocking {
104+
val transformationProxy = if (showCameraPreview && activePreviewView != null) {
105+
withContext(Dispatchers.Main) {
106+
if (activePreviewView?.surfaceProvider != null && activePreviewView?.outputTransform != null) {
107+
getCorrectionMatrix(imageProxy, activePreviewView!!)
108+
} else {
109+
Matrix() // Fallback if preview is not ready.
110+
}
111+
}
112+
} else {
113+
Matrix()
106114
}
107-
} else {
108-
Matrix()
115+
val frame = CameraFrame(
116+
cameraImage = CameraImage(imageProxy),
117+
width = imageProxy.width,
118+
height = imageProxy.height,
119+
rotation = calculateDetectorAngle(currentDisplayRotation, cameraSelection),
120+
previewTransformation = transformationProxy
121+
)
122+
onFrameCaptured(frame)
109123
}
110-
val frame = CameraFrame(
111-
cameraImage = CameraImage(imageProxy),
112-
width = imageProxy.width,
113-
height = imageProxy.height,
114-
rotation = calculateDetectorAngle(currentDisplayRotation, cameraSelection),
115-
previewTransformation = transformationProxy
116-
)
117-
onFrameCaptured(frame)
124+
imageProxy.close()
118125
}
119-
imageProxy.close()
120-
}
121126

122-
cameraProviderFuture.get().unbindAll()
123-
if (showCameraPreview) {
124-
cameraProviderFuture.get().bindToLifecycle(
125-
lifecycleOwner,
126-
cameraSelection.toAndroidCameraSelector(),
127-
preview,
128-
imageAnalysis
129-
)
130-
} else {
131-
cameraProviderFuture.get().bindToLifecycle(
132-
lifecycleOwner,
133-
cameraSelection.toAndroidCameraSelector(),
134-
imageAnalysis
135-
)
136-
}
127+
try {
128+
if (showCameraPreview && activePreviewView != null) {
129+
val preview = Preview.Builder()
130+
.build()
131+
.also {
132+
activePreviewView!!.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
133+
it.setSurfaceProvider(activePreviewView!!.surfaceProvider)
134+
}
135+
cameraProvider.bindToLifecycle(
136+
lifecycleOwner,
137+
cameraSelection.toAndroidCameraSelector(),
138+
preview,
139+
imageAnalysis
140+
)
141+
} else {
142+
// Image Analysis only if preview is not shown.
143+
cameraProvider.bindToLifecycle(
144+
lifecycleOwner,
145+
cameraSelection.toAndroidCameraSelector(),
146+
imageAnalysis
147+
)
148+
}
149+
} catch (exc: Exception) {
150+
Logger.e(TAG, "Use case binding failed", exc)
151+
}
137152

138-
onDispose {
139-
cameraProvider.unbindAll()
140-
executor.shutdown()
153+
onDispose {
154+
cameraProvider.unbindAll()
155+
}
141156
}
142157
}
143158

144159
if (showCameraPreview) {
145160
AndroidView(
146161
modifier = modifier,
147-
factory = { context ->
148-
previewView.value!!
162+
factory = { ctx ->
163+
PreviewView(ctx).apply {
164+
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
165+
activePreviewView = this
166+
}
167+
},
168+
onRelease = {
169+
activePreviewView = null
149170
}
150171
)
172+
} else {
173+
if (activePreviewView != null) {
174+
// This ensures the DisposableEffect re-runs if the view was previously shown.
175+
activePreviewView = null
176+
}
177+
}
178+
179+
DisposableEffect(Unit) {
180+
onDispose {
181+
Logger.d(TAG, "Shutting down camera executor.")
182+
if (!executor.isShutdown) {
183+
executor.shutdown()
184+
}
185+
}
151186
}
152187
}
153188

@@ -177,7 +212,7 @@ private fun CameraSelection.toAndroidCameraSelector() =
177212
}
178213

179214
@OptIn(TransformExperimental::class)
180-
private fun getCorrectionMatrix(imageProxy: ImageProxy, previewView: PreviewView) : Matrix {
215+
private fun getCorrectionMatrix(imageProxy: ImageProxy, previewView: PreviewView): Matrix {
181216

182217
// This matrix maps (-1, -1) -> (1, 1) space to preview-coordinate system. This includes
183218
// any scaling, rotation, or cropping that's done in the preview.
@@ -188,8 +223,8 @@ private fun getCorrectionMatrix(imageProxy: ImageProxy, previewView: PreviewView
188223
// By the scale and translate below, we modify the matrix so it maps
189224
// (0, 0) -> (width, height) of the frame to analyze to the preview
190225
// coordinate system
191-
composeMatrix.scale(2f/imageProxy.width, 2f/imageProxy.height, 1f)
192-
composeMatrix.translate(-0.5f*imageProxy.width, -0.5f*imageProxy.height, 1.0f)
226+
composeMatrix.scale(2f / imageProxy.width, 2f / imageProxy.height, 1f)
227+
composeMatrix.translate(-0.5f * imageProxy.width, -0.5f * imageProxy.height, 1.0f)
193228

194229
return composeMatrix
195230
}

multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/Util.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ expect fun getApplicationInfo(appId: String): ApplicationInfo
2929
expect fun decodeImage(encodedData: ByteArray): ImageBitmap
3030

3131
/**
32-
* Encodes a bitmap to PNG
32+
* Encodes a bitmap to PNG.
3333
*
3434
* @param image the image to encode.
3535
* @return a [ByteString] with the encoded data.
@@ -82,7 +82,7 @@ expect fun cropRotateScaleImage(
8282
cx: Double,
8383
cy: Double,
8484
angleDegrees: Double,
85-
outputWidth: Int,
86-
outputHeight: Int,
87-
targetWidth: Int // You might want to make targetWidth/Height nullable or handle aspect ratio
85+
outputWidthPx: Int,
86+
outputHeightPx: Int,
87+
targetWidthPx: Int
8888
): ImageBitmap

multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/camera/CameraFrame.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ data class CameraFrame(
3636
* If preview is disabled, this is the identity matrix.
3737
*/
3838
val previewTransformation: Matrix
39-
)
39+
40+
) {
41+
/** Determine if the rotation angle indicates the camera was used from a landscape phone orientation mode. */
42+
val isLandscape: Boolean = (rotation == 90 || rotation == 270)
43+
}

0 commit comments

Comments
 (0)