From c2bfb59812026b3e8a229eec9e139feaaafbacd0 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 11 Aug 2026 14:53:22 +0300 Subject: [PATCH 1/7] [MS-1493] Initial image injection broadcast receiver implementation --- infra/camera/src/debug/AndroidManifest.xml | 13 +++++ .../infra/camera/ImageInjectionReceiver.kt | 49 +++++++++++++++++++ .../infra/camera/CameraFrameProvider.kt | 12 ++++- .../repository/InjectedImageRepository.kt | 12 +++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 infra/camera/src/debug/AndroidManifest.xml create mode 100644 infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt create mode 100644 infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageRepository.kt 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..5685bbef6d --- /dev/null +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt @@ -0,0 +1,49 @@ +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.infra.camera.repository.InjectedImageRepository +import com.simprints.infra.logging.Simber +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject + +@AndroidEntryPoint +class ImageInjectionReceiver : BroadcastReceiver() { + @Inject + lateinit var repository: InjectedImageRepository + + 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 == null) { + Simber.d("Image injection broadcast does not contain '$EXTRA_FILE' extra. Image injection aborted") + return + } + val dir = context.applicationContext.getExternalFilesDir(null) + if (dir == null) { + Simber.d("External files dir cannot be resolved. Image injection aborted.") + return + } + val bitmap = BitmapFactory.decodeFile(File(dir, filename).absolutePath) + if (bitmap != null) { + repository.injectedImage = bitmap + val message = "Image injected: $filename" + Simber.d(message) + Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show() + } else { + Simber.d("failed to decode '$filename'. Image injection aborted") + } + } + + companion object { + private const val EXTRA_FILE = "frame" + } +} 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..9452203405 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 @@ -22,6 +22,7 @@ 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.repository.InjectedImageRepository import com.simprints.infra.camera.usecase.NormalizeHighResBitmapToPreviewUseCase import com.simprints.infra.logging.LoggingConstants.CrashReportTag import com.simprints.infra.logging.Simber @@ -42,6 +43,7 @@ class CameraFrameProvider @Inject internal constructor( @DispatcherMain private val mainDispatcher: CoroutineDispatcher, private val cameraFocusManagerFactory: CameraFocusHelper.Factory, private val normalizeHighResBitmapToPreviewUseCase: NormalizeHighResBitmapToPreviewUseCase, + private val injectedImageRepository: InjectedImageRepository, ) { private var executor: ExecutorService = Executors.newSingleThreadExecutor() @@ -206,7 +208,15 @@ class CameraFrameProvider @Inject internal constructor( bitmap: Bitmap, rotation: Int, ) { - frames.tryEmit(Frame(bitmap = bitmap, rotation = rotation, previewBounds = previewRect, targetBounds = targetRect)) + val frame = injectedImageRepository.injectedImage ?: bitmap + frames.tryEmit( + Frame( + bitmap = frame, + rotation = rotation, + previewBounds = previewRect, + targetBounds = targetRect, + ), + ) } private fun captureHighResolutionFrame() { diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageRepository.kt b/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageRepository.kt new file mode 100644 index 0000000000..4302856b85 --- /dev/null +++ b/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageRepository.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 InjectedImageRepository @Inject constructor() { + @Volatile + var injectedImage: Bitmap? = null + internal set +} From 9d3a710edc9c5594e4a0f146c9f697923cc5f4e9 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 12 Aug 2026 07:59:28 +0300 Subject: [PATCH 2/7] [MS-1493] Image injection broadcast is now correctly scaled to the camera preview size, and is displayed on the UI --- .../livefeedback/LiveFeedbackFragment.kt | 2 +- .../layout-land/fragment_live_feedback.xml | 2 +- .../res/layout/fragment_live_feedback.xml | 2 +- .../ExternalCredentialScanOcrFragment.kt | 2 +- .../ExternalCredentialScanQrFragment.kt | 2 +- .../fragment_external_credential_scan_ocr.xml | 2 +- .../fragment_external_credential_scan_qr.xml | 2 +- .../fragment_external_credential_scan_ocr.xml | 2 +- .../fragment_external_credential_scan_qr.xml | 2 +- .../fragment_external_credential_scan_ocr.xml | 2 +- .../fragment_external_credential_scan_qr.xml | 2 +- .../screens/qrscanner/QrScannerFragment.kt | 2 +- .../main/res/layout/fragment_qr_scanner.xml | 2 +- .../infra/camera/ImageInjectionReceiver.kt | 6 +-- .../infra/camera/CameraFrameProvider.kt | 37 +++++++++++++---- .../infra/camera/CameraPreviewView.kt | 31 ++++++++++++++ ...ageRepository.kt => InjectedImageCache.kt} | 2 +- .../usecase/InjectedImagePreProcessUseCase.kt | 41 +++++++++++++++++++ 18 files changed, 119 insertions(+), 24 deletions(-) create mode 100644 infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt rename infra/camera/src/main/java/com/simprints/infra/camera/repository/{InjectedImageRepository.kt => InjectedImageCache.kt} (80%) create mode 100644 infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt 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/java/com/simprints/infra/camera/ImageInjectionReceiver.kt b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt index 5685bbef6d..5d2b03653f 100644 --- a/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.graphics.BitmapFactory import android.widget.Toast -import com.simprints.infra.camera.repository.InjectedImageRepository +import com.simprints.infra.camera.repository.InjectedImageCache import com.simprints.infra.logging.Simber import dagger.hilt.android.AndroidEntryPoint import java.io.File @@ -14,7 +14,7 @@ import javax.inject.Inject @AndroidEntryPoint class ImageInjectionReceiver : BroadcastReceiver() { @Inject - lateinit var repository: InjectedImageRepository + lateinit var cache: InjectedImageCache override fun onReceive( context: Context, @@ -34,7 +34,7 @@ class ImageInjectionReceiver : BroadcastReceiver() { } val bitmap = BitmapFactory.decodeFile(File(dir, filename).absolutePath) if (bitmap != null) { - repository.injectedImage = bitmap + cache.injectedImage = bitmap val message = "Image injected: $filename" Simber.d(message) Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show() 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 9452203405..16f5d6d23f 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,8 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Rect import android.util.Size +import android.view.View +import android.widget.ImageView import androidx.camera.core.Camera import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA import androidx.camera.core.ImageAnalysis @@ -22,7 +24,8 @@ 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.repository.InjectedImageRepository +import com.simprints.infra.camera.repository.InjectedImageCache +import com.simprints.infra.camera.usecase.InjectedImagePreProcessUseCase import com.simprints.infra.camera.usecase.NormalizeHighResBitmapToPreviewUseCase import com.simprints.infra.logging.LoggingConstants.CrashReportTag import com.simprints.infra.logging.Simber @@ -43,7 +46,8 @@ class CameraFrameProvider @Inject internal constructor( @DispatcherMain private val mainDispatcher: CoroutineDispatcher, private val cameraFocusManagerFactory: CameraFocusHelper.Factory, private val normalizeHighResBitmapToPreviewUseCase: NormalizeHighResBitmapToPreviewUseCase, - private val injectedImageRepository: InjectedImageRepository, + private val injectedImageCache: InjectedImageCache, + private val injectedImagePreProcessUseCase: InjectedImagePreProcessUseCase, ) { private var executor: ExecutorService = Executors.newSingleThreadExecutor() @@ -62,6 +66,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 @@ -79,13 +85,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) { @@ -96,6 +102,7 @@ class CameraFrameProvider @Inject internal constructor( ensureExecutor() previewSurface = previewView + injectionOverlay = cameraPreviewView.injectionOverlay frameEmissionHelper.configure(highResolution = highResolution) @@ -191,6 +198,7 @@ class CameraFrameProvider @Inject internal constructor( cameraProvider = null previewSurface = null + injectionOverlay = null imageCapture = null camera = null frameEmissionHelper.reset() @@ -208,17 +216,32 @@ class CameraFrameProvider @Inject internal constructor( bitmap: Bitmap, rotation: Int, ) { - val frame = injectedImageRepository.injectedImage ?: bitmap + val injected = injectedImageCache.injectedImage + val (frameBitmap: Bitmap, frameRotation: Int) = when (injected) { + null -> bitmap to rotation + else -> injectedImagePreProcessUseCase(injected, previewRect, targetRect) to 0 + } + if (injected != null) { + displayInjectedImage(frameBitmap) + } frames.tryEmit( Frame( - bitmap = frame, - rotation = rotation, + bitmap = frameBitmap, + rotation = frameRotation, previewBounds = previewRect, targetBounds = targetRect, ), ) } + private fun displayInjectedImage(bitmap: Bitmap) { + val overlay = injectionOverlay + overlay?.post { + overlay.setImageBitmap(bitmap) + overlay.visibility = View.VISIBLE + } + } + private fun captureHighResolutionFrame() { val capture = imageCapture if (capture == null) { 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..50b33e5b20 --- /dev/null +++ b/infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt @@ -0,0 +1,31 @@ +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 + +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/repository/InjectedImageRepository.kt b/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageCache.kt similarity index 80% rename from infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageRepository.kt rename to infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageCache.kt index 4302856b85..e260d1473e 100644 --- a/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageRepository.kt +++ b/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageCache.kt @@ -5,7 +5,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class InjectedImageRepository @Inject constructor() { +class InjectedImageCache @Inject constructor() { @Volatile var injectedImage: Bitmap? = null internal set diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt new file mode 100644 index 0000000000..d17a4a414c --- /dev/null +++ b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt @@ -0,0 +1,41 @@ +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 javax.inject.Inject + +internal class InjectedImagePreProcessUseCase @Inject constructor() { + /** + * Fits [injectedImage] 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( + injectedImage: Bitmap, + previewRect: Rect, + targetRect: Rect, + ): Bitmap { + 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() / injectedImage.height + } else { + targetRect.width().toFloat() / injectedImage.width + } + + val scaledWidth = (injectedImage.width * scale).toInt() + val scaledHeight = (injectedImage.height * scale).toInt() + + val left = targetRect.left + (targetRect.width() - scaledWidth) / 2 + val top = targetRect.top + (targetRect.height() - scaledHeight) / 2 + + canvas.drawBitmap(injectedImage, null, Rect(left, top, left + scaledWidth, top + scaledHeight), null) + + return result + } +} From fc6ee6e0d2c33417c98f31bfee512b538513ff8d Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 12 Aug 2026 09:56:49 +0300 Subject: [PATCH 3/7] MS-1493 Limit image cache and pre-processing to debug builds --- .../camera/repository/InjectedImageCache.kt | 0 .../camera/usecase/FramePreProcessUseCase.kt | 67 +++++++++++++++++++ .../infra/camera/CameraFrameProvider.kt | 36 ++++------ .../java/com/simprints/infra/camera/Frame.kt | 1 + .../usecase/InjectedImagePreProcessUseCase.kt | 41 ------------ .../camera/usecase/FramePreProcessUseCase.kt | 23 +++++++ 6 files changed, 104 insertions(+), 64 deletions(-) rename infra/camera/src/{main => debug}/java/com/simprints/infra/camera/repository/InjectedImageCache.kt (100%) create mode 100644 infra/camera/src/debug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt delete mode 100644 infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt create mode 100644 infra/camera/src/release/java/com/simprints/infra/camera/usecase/FramePreProcessUseCase.kt diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageCache.kt b/infra/camera/src/debug/java/com/simprints/infra/camera/repository/InjectedImageCache.kt similarity index 100% rename from infra/camera/src/main/java/com/simprints/infra/camera/repository/InjectedImageCache.kt rename to infra/camera/src/debug/java/com/simprints/infra/camera/repository/InjectedImageCache.kt 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 16f5d6d23f..45d36e5973 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,7 +4,6 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Rect import android.util.Size -import android.view.View import android.widget.ImageView import androidx.camera.core.Camera import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA @@ -18,14 +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.repository.InjectedImageCache -import com.simprints.infra.camera.usecase.InjectedImagePreProcessUseCase +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 @@ -46,8 +45,7 @@ class CameraFrameProvider @Inject internal constructor( @DispatcherMain private val mainDispatcher: CoroutineDispatcher, private val cameraFocusManagerFactory: CameraFocusHelper.Factory, private val normalizeHighResBitmapToPreviewUseCase: NormalizeHighResBitmapToPreviewUseCase, - private val injectedImageCache: InjectedImageCache, - private val injectedImagePreProcessUseCase: InjectedImagePreProcessUseCase, + private val framePreProcessUseCase: FramePreProcessUseCase, ) { private var executor: ExecutorService = Executors.newSingleThreadExecutor() @@ -216,29 +214,21 @@ class CameraFrameProvider @Inject internal constructor( bitmap: Bitmap, rotation: Int, ) { - val injected = injectedImageCache.injectedImage - val (frameBitmap: Bitmap, frameRotation: Int) = when (injected) { - null -> bitmap to rotation - else -> injectedImagePreProcessUseCase(injected, previewRect, targetRect) to 0 - } - if (injected != null) { - displayInjectedImage(frameBitmap) - } - frames.tryEmit( - Frame( - bitmap = frameBitmap, - rotation = frameRotation, - previewBounds = previewRect, - targetBounds = targetRect, - ), + val frame = framePreProcessUseCase( + bitmap = bitmap, + rotation = rotation, + previewRect = previewRect, + targetRect = targetRect, ) + displayInjectedImage(frame) + frames.tryEmit(frame) } - private fun displayInjectedImage(bitmap: Bitmap) { + private fun displayInjectedImage(frame: Frame) { val overlay = injectionOverlay overlay?.post { - overlay.setImageBitmap(bitmap) - overlay.visibility = View.VISIBLE + overlay.setImageBitmap(frame.bitmap) + overlay.isVisible = frame.isInjected } } 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/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt deleted file mode 100644 index d17a4a414c..0000000000 --- a/infra/camera/src/main/java/com/simprints/infra/camera/usecase/InjectedImagePreProcessUseCase.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 javax.inject.Inject - -internal class InjectedImagePreProcessUseCase @Inject constructor() { - /** - * Fits [injectedImage] 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( - injectedImage: Bitmap, - previewRect: Rect, - targetRect: Rect, - ): Bitmap { - 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() / injectedImage.height - } else { - targetRect.width().toFloat() / injectedImage.width - } - - val scaledWidth = (injectedImage.width * scale).toInt() - val scaledHeight = (injectedImage.height * scale).toInt() - - val left = targetRect.left + (targetRect.width() - scaledWidth) / 2 - val top = targetRect.top + (targetRect.height() - scaledHeight) / 2 - - canvas.drawBitmap(injectedImage, null, Rect(left, top, left + scaledWidth, top + scaledHeight), null) - - return result - } -} 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, + ) +} From 9f463916d57656b3ad40a74b934d447a53b87ff4 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 12 Aug 2026 10:00:00 +0300 Subject: [PATCH 4/7] MS-1493 Clear cached injected image when receiving faulty or empty parameter --- .../com/simprints/infra/camera/ImageInjectionReceiver.kt | 5 ++++- .../java/com/simprints/infra/camera/CameraFrameProvider.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 5d2b03653f..f516e0d600 100644 --- a/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt @@ -23,12 +23,14 @@ class ImageInjectionReceiver : BroadcastReceiver() { 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 == null) { + if (filename.isNullOrBlank()) { + cache.injectedImage = null Simber.d("Image injection broadcast does not contain '$EXTRA_FILE' extra. Image injection aborted") return } val dir = context.applicationContext.getExternalFilesDir(null) if (dir == null) { + cache.injectedImage = null Simber.d("External files dir cannot be resolved. Image injection aborted.") return } @@ -39,6 +41,7 @@ class ImageInjectionReceiver : BroadcastReceiver() { Simber.d(message) Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show() } else { + cache.injectedImage = null Simber.d("failed to decode '$filename'. Image injection aborted") } } 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 45d36e5973..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 @@ -227,7 +227,7 @@ class CameraFrameProvider @Inject internal constructor( private fun displayInjectedImage(frame: Frame) { val overlay = injectionOverlay overlay?.post { - overlay.setImageBitmap(frame.bitmap) + overlay.setImageBitmap(frame.takeIf { it.isInjected }?.bitmap) overlay.isVisible = frame.isInjected } } From 0d8fbb4729e74966840d66c246e3760a23d5af27 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 12 Aug 2026 10:20:29 +0300 Subject: [PATCH 5/7] [MS-1493] updating log messages --- .../com/simprints/infra/camera/ImageInjectionReceiver.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index f516e0d600..344bbb6285 100644 --- a/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt @@ -25,13 +25,13 @@ class ImageInjectionReceiver : BroadcastReceiver() { 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 aborted") + 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 aborted.") + Simber.d("External files dir cannot be resolved. Image injection cleared.") return } val bitmap = BitmapFactory.decodeFile(File(dir, filename).absolutePath) @@ -42,7 +42,7 @@ class ImageInjectionReceiver : BroadcastReceiver() { Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show() } else { cache.injectedImage = null - Simber.d("failed to decode '$filename'. Image injection aborted") + Simber.d("failed to decode '$filename'. Image injection cleared") } } From b79aa641a2ebae5f8cd47196cd400b934e429b87 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 12 Aug 2026 11:20:09 +0300 Subject: [PATCH 6/7] [MS-1493] Creating test for injected image pre-processor --- .../infra/camera/ImageInjectionReceiver.kt | 2 + .../usecase/FramePreProcessUseCaseTest.kt | 134 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 infra/camera/src/testDebug/java/com/simprints/infra/camera/usecase/FramePreProcessUseCaseTest.kt 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 index 344bbb6285..8540c97fcb 100644 --- a/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt +++ b/infra/camera/src/debug/java/com/simprints/infra/camera/ImageInjectionReceiver.kt @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,7 @@ 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 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() + } +} From afcbb4e4fae9fdcb88ced817d3b9752ed9efe22b Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 12 Aug 2026 14:49:10 +0300 Subject: [PATCH 7/7] [MS-1493] Annotating the UI class with @ExcludedFromGeneratedTestCoverageReports --- .../main/java/com/simprints/infra/camera/CameraPreviewView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 50b33e5b20..f52ed5bcfa 100644 --- a/infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt +++ b/infra/camera/src/main/java/com/simprints/infra/camera/CameraPreviewView.kt @@ -6,12 +6,13 @@ 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