diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index 937f10eebc..e1f1d9bcd1 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt @@ -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) diff --git a/face/capture/src/main/res/layout-land/fragment_live_feedback.xml b/face/capture/src/main/res/layout-land/fragment_live_feedback.xml index 43b9b7ec0e..3c684b0c22 100644 --- a/face/capture/src/main/res/layout-land/fragment_live_feedback.xml +++ b/face/capture/src/main/res/layout-land/fragment_live_feedback.xml @@ -26,7 +26,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - Simber.e("Camera not available for QR scanning", e, tag = crashReportTag) diff --git a/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml b/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml index 5b81ea335b..428c198fc1 100644 --- a/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml +++ b/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - - diff --git a/infra/camera/src/debug/AndroidManifest.xml b/infra/camera/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..feff3e362f --- /dev/null +++ b/infra/camera/src/debug/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt new file mode 100644 index 0000000000..8540c97fcb --- /dev/null +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt @@ -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") + } + } + + companion object { + private const val EXTRA_FILE = "frame" + } +} diff --git a/infra/camera/src/debug/java/com/simprints/infra/camera/repository/InjectedImageCache.kt b/infra/camera/src/debug/java/com/simprints/infra/camera/repository/InjectedImageCache.kt new file mode 100644 index 0000000000..e260d1473e --- /dev/null +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/repository/InjectedImageCache.kt @@ -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 +} diff --git a/infra/camera/src/debug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt b/infra/camera/src/debug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt new file mode 100644 index 0000000000..2f6b412faa --- /dev/null +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt @@ -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 + } + + 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, + ) + } +} diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt b/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt index a336a7c08d..9e88e3724e 100644 --- a/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt +++ b/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt @@ -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 @@ -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 @@ -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() @@ -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 @@ -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) { @@ -94,6 +100,7 @@ class CameraFrameProvider @Inject internal constructor( ensureExecutor() previewSurface = previewView + injectionOverlay = cameraPreviewView.injectionOverlay frameEmissionHelper.configure(highResolution = highResolution) @@ -189,6 +196,7 @@ class CameraFrameProvider @Inject internal constructor( cameraProvider = null previewSurface = null + injectionOverlay = null imageCapture = null camera = null frameEmissionHelper.reset() @@ -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 + } } private fun captureHighResolutionFrame() { diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt b/infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt new file mode 100644 index 0000000000..f52ed5bcfa --- /dev/null +++ b/infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt @@ -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) + } +} diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt b/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt index 1a7865e2f5..be1794a93e 100644 --- a/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt +++ b/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt @@ -8,4 +8,5 @@ data class Frame( val rotation: Int, val targetBounds: Rect, val previewBounds: Rect, + val isInjected: Boolean = false, ) diff --git a/infra/camera/src/release/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt b/infra/camera/src/release/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt new file mode 100644 index 0000000000..6b9f0beccc --- /dev/null +++ b/infra/camera/src/release/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt @@ -0,0 +1,23 @@ +package com.simprints.infra.camera.usecase + +import android.graphics.Bitmap +import android.graphics.Rect +import com.simprints.infra.camera.Frame +import javax.inject.Inject + +internal class FramePreProcessUseCase @Inject constructor() { + /** + * Wraps the provided arguments into a [Frame] object. + */ + operator fun invoke( + bitmap: Bitmap, + rotation: Int, + previewRect: Rect, + targetRect: Rect, + ): Frame = Frame( + bitmap = bitmap, + rotation = rotation, + previewBounds = previewRect, + targetBounds = targetRect, + ) +} diff --git a/infra/camera/src/testDebug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCaseTest.kt b/infra/camera/src/testDebug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCaseTest.kt new file mode 100644 index 0000000000..193a059f67 --- /dev/null +++ b/infra/camera/src/testDebug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCaseTest.kt @@ -0,0 +1,134 @@ +package com.simprints.infra.camera.usecase + +import android.graphics.Bitmap +import android.graphics.Rect +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.camera.repository.InjectedImageCache +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class FramePreProcessUseCaseTest { + private lateinit var cache: InjectedImageCache + private lateinit var useCase: FramePreProcessUseCase + + companion object { + private val PREVIEW_RECT = Rect(0, 0, 600, 1200) + private val TARGET_RECT_PORTRAIT = Rect(100, 200, 500, 1000) + private val TARGET_RECT_LANDSCAPE = Rect(100, 400, 700, 800) + private const val ROTATION = 90 + } + + @Before + fun setUp() { + cache = InjectedImageCache() + useCase = FramePreProcessUseCase(cache) + } + + @Test + fun `returns original frame unchanged when no image is injected`() { + val bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888) + + val frame = useCase(bitmap, ROTATION, PREVIEW_RECT, TARGET_RECT_PORTRAIT) + + assertThat(frame.bitmap).isEqualTo(bitmap) + assertThat(frame.rotation).isEqualTo(ROTATION) + assertThat(frame.previewBounds).isEqualTo(PREVIEW_RECT) + assertThat(frame.targetBounds).isEqualTo(TARGET_RECT_PORTRAIT) + assertThat(frame.isInjected).isFalse() + } + + @Test + fun `returns original frame after injected image is cleared from cache`() { + val bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888) + cache.injectedImage = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + cache.injectedImage = null + + val frame = useCase(bitmap, ROTATION, PREVIEW_RECT, TARGET_RECT_PORTRAIT) + + assertThat(frame.bitmap).isEqualTo(bitmap) + assertThat(frame.isInjected).isFalse() + } + + @Test + fun `produces frame at preview dimensions when image is injected`() { + cache.injectedImage = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + val frame = useCase( + Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888), + ROTATION, + PREVIEW_RECT, + TARGET_RECT_PORTRAIT, + ) + + assertThat(frame.bitmap.width).isEqualTo(PREVIEW_RECT.width()) + assertThat(frame.bitmap.height).isEqualTo(PREVIEW_RECT.height()) + } + + @Test + fun `marks injected frame as injected with zero rotation`() { + cache.injectedImage = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + val frame = useCase( + Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888), + ROTATION, + PREVIEW_RECT, + TARGET_RECT_PORTRAIT, + ) + + assertThat(frame.isInjected).isTrue() + assertThat(frame.rotation).isEqualTo(0) + } + + @Test + fun `preserves preview and target bounds in injected frame`() { + cache.injectedImage = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + val frame = useCase( + Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888), + ROTATION, + PREVIEW_RECT, + TARGET_RECT_PORTRAIT, + ) + + assertThat(frame.previewBounds).isEqualTo(PREVIEW_RECT) + assertThat(frame.targetBounds).isEqualTo(TARGET_RECT_PORTRAIT) + } + + @Test + fun `scales injected image by target height when target is wider than tall`() { + cache.injectedImage = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + val previewRect = Rect(0, 0, 1000, 1000) + + val frame = useCase( + Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888), + 0, + previewRect, + TARGET_RECT_LANDSCAPE, + ) + + // Result is always sized to preview. Scale path selected by target aspect ratio + assertThat(frame.bitmap.width).isEqualTo(1000) + assertThat(frame.bitmap.height).isEqualTo(1000) + assertThat(frame.isInjected).isTrue() + } + + @Test + fun `scales injected image by target width when target is taller than wide`() { + cache.injectedImage = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + val previewRect = Rect(0, 0, 1000, 2000) + + val frame = useCase( + Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888), + 0, + previewRect, + TARGET_RECT_PORTRAIT, + ) + + assertThat(frame.bitmap.width).isEqualTo(1000) + assertThat(frame.bitmap.height).isEqualTo(2000) + assertThat(frame.isInjected).isTrue() + } +}