Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback)

cameraFrameProvider.initialiseCamera(
lifecycleOwner = viewLifecycleOwner,
previewView = binding.faceCaptureCamera,
cameraPreviewView = binding.faceCaptureCamera,
target = binding.captureOverlay.circleRect.toRect(),
)
Simber.i("Camera setup finished", tag = FACE_CAPTURE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/face_capture_camera"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
tools:background="#66000000"
tools:ignore="ContentDescription">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/face_capture_camera"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
)
cameraFrameProvider.initialiseCamera(
lifecycleOwner = viewLifecycleOwner,
previewView = binding.preview,
cameraPreviewView = binding.preview,
target = targetRect,
highResolution = ocrConfig.useHighRes,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext
)
cameraFrameProvider.initialiseCamera(
lifecycleOwner = viewLifecycleOwner,
previewView = binding.qrScannerPreview,
cameraPreviewView = binding.qrScannerPreview,
target = targetRect,
) { e ->
Simber.e("Camera not available for QR scanning", e, tag = crashReportTag)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/qrScannerPreview"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/qrScannerPreview"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/qrScannerPreview"
android:layout_width="match_parent"
android:layout_height="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ internal class QrScannerFragment : Fragment(R.layout.fragment_qr_scanner) {
)
cameraFrameProvider.initialiseCamera(
lifecycleOwner = viewLifecycleOwner,
previewView = binding.qrScannerPreview,
cameraPreviewView = binding.qrScannerPreview,
target = targetRect,
) {
finishWithError(QrScannerResult.QrScannerError.CameraNotAvailable)
Expand Down
2 changes: 1 addition & 1 deletion feature/login/src/main/res/layout/fragment_qr_scanner.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.camera.view.PreviewView
<com.simprints.infra.camera.CameraPreviewView
android:id="@+id/qrScannerPreview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
Expand Down
13 changes: 13 additions & 0 deletions infra/camera/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<receiver
android:name=".ImageInjectionReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.simprints.automation.INJECT_FRAME" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.simprints.infra.camera

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.widget.Toast
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.infra.camera.repository.InjectedImageCache
import com.simprints.infra.logging.Simber
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject

@AndroidEntryPoint
@ExcludedFromGeneratedTestCoverageReports("Wrapper for E2E test image injection broadcast receiver")
class ImageInjectionReceiver : BroadcastReceiver() {
@Inject
lateinit var cache: InjectedImageCache

override fun onReceive(
context: Context,
intent: Intent,
) {
val extras = intent.extras
Simber.d("Image injection broadcast received. Extras: ${extras?.keySet()?.joinToString { "$it=${extras[it]}" }}")
val filename = intent.getStringExtra(EXTRA_FILE)
if (filename.isNullOrBlank()) {
cache.injectedImage = null
Simber.d("Image injection broadcast does not contain '$EXTRA_FILE' extra. Image injection cleared")
return
}
val dir = context.applicationContext.getExternalFilesDir(null)
if (dir == null) {
cache.injectedImage = null
Simber.d("External files dir cannot be resolved. Image injection cleared.")
return
}
val bitmap = BitmapFactory.decodeFile(File(dir, filename).absolutePath)
if (bitmap != null) {
cache.injectedImage = bitmap
val message = "Image injected: $filename"
Simber.d(message)
Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show()
} else {
cache.injectedImage = null
Simber.d("failed to decode '$filename'. Image injection cleared")
}
Comment thread
alexandr-simprints marked this conversation as resolved.
}

companion object {
private const val EXTRA_FILE = "frame"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.simprints.infra.camera.repository

import android.graphics.Bitmap
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class InjectedImageCache @Inject constructor() {
@Volatile
var injectedImage: Bitmap? = null
internal set
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.simprints.infra.camera.usecase

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Rect
import androidx.core.graphics.createBitmap
import com.simprints.infra.camera.Frame
import com.simprints.infra.camera.repository.InjectedImageCache
import javax.inject.Inject

internal class FramePreProcessUseCase @Inject constructor(
private val injectedImageCache: InjectedImageCache,
) {
/**
* Wraps the provided parameters with a [Frame] object.
*
* If an injected image is available, it will be used instead of the provided [bitmap].
* Fits injected image centred within [targetRect], preserving aspect ratio, and expanded to [previewRect]. The remaining area is
* filled with black color. This is done so that the injected image is properly displayed on the screen, and post-processing can
* crop the area of interest as if it was a real frame from the camera.
*/
operator fun invoke(
bitmap: Bitmap,
rotation: Int,
previewRect: Rect,
targetRect: Rect,
): Frame {
val injected = injectedImageCache.injectedImage ?: return Frame(
bitmap = bitmap,
rotation = rotation,
previewBounds = previewRect,
targetBounds = targetRect,
)

val result = createBitmap(previewRect.width(), previewRect.height())
val canvas = Canvas(result)
canvas.drawColor(Color.BLACK)

val scale = if (targetRect.width() > targetRect.height()) {
targetRect.height().toFloat() / injected.height
} else {
targetRect.width().toFloat() / injected.width
}
Comment thread
alexandr-simprints marked this conversation as resolved.

val scaledWidth = (injected.width * scale).toInt()
val scaledHeight = (injected.height * scale).toInt()

val left = targetRect.left + (targetRect.width() - scaledWidth) / 2
val top = targetRect.top + (targetRect.height() - scaledHeight) / 2

canvas.drawBitmap(
injected,
null,
Rect(left, top, left + scaledWidth, top + scaledHeight),
null,
)

return Frame(
bitmap = result,
rotation = 0,
previewBounds = previewRect,
targetBounds = targetRect,
isInjected = true,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import android.util.Size
import android.widget.ImageView
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA
import androidx.camera.core.ImageAnalysis
Expand All @@ -16,12 +17,14 @@ import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.lifecycle.awaitInstance
import androidx.camera.view.PreviewView
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.simprints.core.DispatcherBG
import com.simprints.core.DispatcherMain
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.infra.camera.helpers.CameraFocusHelper
import com.simprints.infra.camera.helpers.FrameEmissionHelper
import com.simprints.infra.camera.usecase.FramePreProcessUseCase
import com.simprints.infra.camera.usecase.NormalizeHighResBitmapToPreviewUseCase
import com.simprints.infra.logging.LoggingConstants.CrashReportTag
import com.simprints.infra.logging.Simber
Expand All @@ -42,6 +45,7 @@ class CameraFrameProvider @Inject internal constructor(
@DispatcherMain private val mainDispatcher: CoroutineDispatcher,
private val cameraFocusManagerFactory: CameraFocusHelper.Factory,
private val normalizeHighResBitmapToPreviewUseCase: NormalizeHighResBitmapToPreviewUseCase,
private val framePreProcessUseCase: FramePreProcessUseCase,
) {
private var executor: ExecutorService = Executors.newSingleThreadExecutor()

Expand All @@ -60,6 +64,8 @@ class CameraFrameProvider @Inject internal constructor(
private lateinit var targetRect: Rect

private var previewSurface: PreviewView? = null
private var injectionOverlay: ImageView? = null

private val frameEmissionHelper = FrameEmissionHelper()

fun isInitialised() = camera != null
Expand All @@ -77,13 +83,13 @@ class CameraFrameProvider @Inject internal constructor(
@ExcludedFromGeneratedTestCoverageReports(reason = "Camera API wrapper")
suspend fun initialiseCamera(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
cameraPreviewView: CameraPreviewView,
target: Rect? = null,
highResolution: Boolean = false,
onError: (Throwable) -> Unit = {},
) = withContext(bgDispatcher) {
val previewView = cameraPreviewView.previewView
try {
// Caching to return with frames for post-processing and also to use for injection in future
previewRect = fullPreviewSizeRect(previewView)
targetRect = target ?: previewRect
} catch (e: Exception) {
Expand All @@ -94,6 +100,7 @@ class CameraFrameProvider @Inject internal constructor(

ensureExecutor()
previewSurface = previewView
injectionOverlay = cameraPreviewView.injectionOverlay

frameEmissionHelper.configure(highResolution = highResolution)

Expand Down Expand Up @@ -189,6 +196,7 @@ class CameraFrameProvider @Inject internal constructor(
cameraProvider = null

previewSurface = null
injectionOverlay = null
imageCapture = null
camera = null
frameEmissionHelper.reset()
Expand All @@ -206,7 +214,22 @@ class CameraFrameProvider @Inject internal constructor(
bitmap: Bitmap,
rotation: Int,
) {
frames.tryEmit(Frame(bitmap = bitmap, rotation = rotation, previewBounds = previewRect, targetBounds = targetRect))
val frame = framePreProcessUseCase(
bitmap = bitmap,
rotation = rotation,
previewRect = previewRect,
targetRect = targetRect,
)
displayInjectedImage(frame)
frames.tryEmit(frame)
}

private fun displayInjectedImage(frame: Frame) {
val overlay = injectionOverlay
overlay?.post {
overlay.setImageBitmap(frame.takeIf { it.isInjected }?.bitmap)
overlay.isVisible = frame.isInjected
}
}
Comment thread
alexandr-simprints marked this conversation as resolved.

private fun captureHighResolutionFrame() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.simprints.infra.camera

import android.content.Context
import android.util.AttributeSet
import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.camera.view.PreviewView
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports

@ExcludedFromGeneratedTestCoverageReports("UI class")
class CameraPreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
val previewView: PreviewView = PreviewView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
}

val injectionOverlay: ImageView = ImageView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
scaleType = ImageView.ScaleType.FIT_CENTER
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
visibility = GONE
}

init {
addView(previewView)
addView(injectionOverlay)
}
}
Loading
Loading