Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f9cd676
Update dev version name to 2026.2.0
meladRaouf May 9, 2026
1f3aa1f
Merge pull request #1661 from Simprints/update-version-name
meladRaouf May 18, 2026
084e942
MS-1431 Replace blocking sync use case with async listening for the s…
luhmirin-s May 18, 2026
08aee66
MS-1431 Reset the pool validation to SyncInProgress state when called…
luhmirin-s May 18, 2026
ce8fc1a
MS-1411 Use timestamp model for last location time
luhmirin-s May 18, 2026
7daa689
[MS-1036] Close ImageProxy even when preview is empty to avoid leaks
BurningAXE May 19, 2026
fe1dcd0
Merge pull request #1667 from Simprints/fixup/MS-1431-sync-during-poo…
luhmirin-s May 19, 2026
63048d8
Merge pull request #1666 from Simprints/fixup/MS-1411-timestamp-format
luhmirin-s May 19, 2026
adf5125
Merge pull request #1668 from Simprints/MS-1036-ImageProxy-leak
BurningAXE May 19, 2026
bb87640
MS-1433 Persist data for MFID analytics events across activity recrea…
luhmirin-s May 19, 2026
f09af30
Merge pull request #1669 from Simprints/fixup/MS-1433-mfid-state-rest…
luhmirin-s May 19, 2026
de0bab1
[MS-1442] Use viewLifecycleOwner for camera binding and move executor…
meladRaouf May 22, 2026
c449571
[MS-1443] Fix RecyclerView adapter and animation leaks
meladRaouf May 22, 2026
a7ccdf5
[MS-1442] Refactor CropToTargetOverlayAnalyzer to decouple from View …
meladRaouf May 22, 2026
43d0835
Simplify LiveData observation in SyncInfoFragment by removing redunda…
meladRaouf May 22, 2026
feed934
Refactor `CropToTargetOverlayAnalyzer` to use explicit dimensions and…
meladRaouf May 22, 2026
964a633
Add unit tests for FragmentViewBindingDelegate
meladRaouf May 22, 2026
0865129
Merge pull request #1670 from Simprints/fix-leak
meladRaouf May 25, 2026
040f208
MS-1441 Force an update of selected modules list after sync completion
luhmirin-s May 25, 2026
a9e6a63
MS-1435 Add a generic MFID skip reason string set
luhmirin-s May 25, 2026
2f0c2d6
Merge pull request #1675 from Simprints/fixup/MS-1441-module-refresh
luhmirin-s May 25, 2026
6c71482
Merge pull request #1676 from Simprints/fixup/MS-1435-mfid-skip-text-fix
luhmirin-s May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build-logic/build_properties.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extra.apply {
* Dev version >= 2025.3.0 is required to receive smaples and structured down sync configuration
* Dev version >= 2026.2.0 is required to track events in "Device" scope
*/
set("VERSION_NAME", "2026.1.0")
set("VERSION_NAME", "2026.2.0")

/**
* Build type. The version code describes which build type was used for the build.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,51 +1,51 @@
package com.simprints.face.capture.screens.livefeedback

import android.graphics.Bitmap
import android.graphics.RectF
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.simprints.face.capture.screens.livefeedback.views.CameraTargetOverlay
import kotlin.math.max
import kotlin.math.min

internal class CropToTargetOverlayAnalyzer(
private val targetOverlay: CameraTargetOverlay,
private val previewRect: RectF,
private val overlayWidth: Int,
private val overlayHeight: Int,
private val onImageCropped: (Bitmap) -> Unit,
) : ImageAnalysis.Analyzer {
override fun analyze(image: ImageProxy) {
val previewRect = targetOverlay.circleRect
if (previewRect.isEmpty) return
val croppedBitmap = image.use {
if (previewRect.isEmpty) return

// Adjust overlay size to be fit-center with the image size
val scale = getSmallerRatio(
image.width,
image.height,
targetOverlay.width,
targetOverlay.height,
)
val scaledWidth = (targetOverlay.width * scale).toInt()
val scaledHeight = (targetOverlay.height * scale).toInt()
// Adjust overlay size to be fit-center with the image size
val scale = getSmallerRatio(
it.width,
it.height,
overlayWidth,
overlayHeight,
)
val scaledWidth = (overlayWidth * scale).toInt()
val scaledHeight = (overlayHeight * scale).toInt()

// Find the offsets caused by fit-center scaling
val offsetX = (max(image.width, scaledWidth) - min(image.width, scaledWidth)) / 2
val offsetY = (max(image.height, scaledHeight) - min(image.height, scaledHeight)) / 2
// Find the offsets caused by fit-center scaling
val offsetX = (max(it.width, scaledWidth) - min(it.width, scaledWidth)) / 2
val offsetY = (max(it.height, scaledHeight) - min(it.height, scaledHeight)) / 2

// Scale the preview target to the new scale and offset
val cropLeft = offsetX + (previewRect.left * scale).toInt()
val cropWidth = (previewRect.width() * scale).toInt()
val cropTop = offsetY + (previewRect.top * scale).toInt()
val cropHeight = (previewRect.height() * scale).toInt()
// Scale the preview target to the new scale and offset
val cropLeft = offsetX + (previewRect.left * scale).toInt()
val cropWidth = (previewRect.width() * scale).toInt()
val cropTop = offsetY + (previewRect.top * scale).toInt()
val cropHeight = (previewRect.height() * scale).toInt()

onImageCropped(
image.use {
Bitmap.createBitmap(
it.toBitmap(),
cropLeft,
cropTop,
cropWidth,
cropHeight,
)
},
)
Bitmap.createBitmap(
it.toBitmap(),
cropLeft,
cropTop,
cropWidth,
cropHeight,
)
}
onImageCropped(croppedBitmap)
}

private fun getSmallerRatio(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.simprints.face.capture.screens.livefeedback
import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.RectF
import android.os.Bundle
import android.provider.Settings
import android.util.Size
Expand Down Expand Up @@ -67,6 +68,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback)

private lateinit var screenSize: Size
private lateinit var targetResolution: Size
private lateinit var imageAnalyzer: ImageAnalysis
private lateinit var preview: Preview

private var cameraControl: CameraControl? = null

Expand Down Expand Up @@ -166,25 +169,31 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback)
),
).build()

val imageAnalyzer = ImageAnalysis
imageAnalyzer = ImageAnalysis
.Builder()
.setResolutionSelector(resolutionSelector)
.setOutputImageRotationEnabled(true)
.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888)
.build()
val cropAnalyzer = CropToTargetOverlayAnalyzer(binding.captureOverlay, ::analyze)

val cropAnalyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(binding.captureOverlay.circleRect), // create a new instance to avoid threading issues
overlayWidth = binding.captureOverlay.width,
overlayHeight = binding.captureOverlay.height,
onImageCropped = ::analyze,
)

imageAnalyzer.setAnalyzer(cameraExecutor, cropAnalyzer)

// Preview
val preview = Preview
preview = Preview
.Builder()
.setResolutionSelector(resolutionSelector)
.build()
val cameraProvider = ProcessCameraProvider.awaitInstance(requireContext())
cameraProvider.unbindAll()
val camera = cameraProvider.bindToLifecycle(
this@LiveFeedbackFragment,
viewLifecycleOwner,
DEFAULT_BACK_CAMERA,
preview,
imageAnalyzer,
Expand Down Expand Up @@ -227,11 +236,21 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback)

override fun onStop() {
toggleTorche(false)
// Shut down our background executor
if (::cameraExecutor.isInitialized) {
super.onStop()
}

override fun onDestroyView() {
if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) {
cameraExecutor.shutdown()
}
super.onStop()
if (::imageAnalyzer.isInitialized) {
imageAnalyzer.clearAnalyzer()
}

if (::preview.isInitialized) {
preview.surfaceProvider = null
}
super.onDestroyView()
}

private fun bindViewModel() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,17 @@ import android.graphics.RectF
import androidx.camera.core.ImageProxy
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.simprints.face.capture.screens.livefeedback.views.CameraTargetOverlay
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.justRun
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
internal class CropToTargetOverlayAnalyzerTest {
@MockK
lateinit var targetOverlay: CameraTargetOverlay

@MockK
lateinit var imageProxy: ImageProxy

Expand All @@ -29,31 +26,34 @@ internal class CropToTargetOverlayAnalyzerTest {
fun setUp() {
MockKAnnotations.init(this)
justRun { imageProxy.close() }

capturedBitmap = null
analyzer = CropToTargetOverlayAnalyzer(targetOverlay) { capturedBitmap = it }
}

@Test
fun `Skip cropping when target is empty`() {
// Target is a square 600x600px with 200px from top bounds
setupScreenSize(1000, 2000)
every { targetOverlay.circleRect } returns RectF(200f, 200f, 200f, 200f)
setupImageSize(1000, 1000)
analyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(200f, 200f, 200f, 200f),
overlayWidth = 1000,
overlayHeight = 2000,
) { capturedBitmap = it }

analyzer.analyze(imageProxy)

// Cropped should be still square and half the side length of original
assertThat(capturedBitmap?.width).isNull()
assertThat(capturedBitmap?.height).isNull()
verify(exactly = 1) { imageProxy.close() }
}

@Test
fun `Correctly crops when camera resolution is smaller than preview in portrait`() {
// Target is a square 600x600px with 200px from top bounds
setupScreenSize(1000, 2000)
every { targetOverlay.circleRect } returns RectF(200f, 200f, 800f, 800f)
setupImageSize(1000, 1000)
analyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(200f, 200f, 800f, 800f),
overlayWidth = 1000,
overlayHeight = 2000,
) { capturedBitmap = it }

analyzer.analyze(imageProxy)

Expand All @@ -62,12 +62,33 @@ internal class CropToTargetOverlayAnalyzerTest {
assertThat(capturedBitmap?.height).isEqualTo(300)
}

@Test
fun `Closes ImageProxy before invoking cropped callback`() {
setupImageSize(1000, 1000)
var closed = false
every { imageProxy.close() } answers { closed = true }
var closedBeforeCallback = false

val analyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(200f, 200f, 800f, 800f),
overlayWidth = 1000,
overlayHeight = 2000,
) { closedBeforeCallback = closed }

analyzer.analyze(imageProxy)

assertThat(closedBeforeCallback).isTrue()
verify(exactly = 1) { imageProxy.close() }
}

@Test
fun `Correctly crops when camera resolution is smaller than preview in landscape`() {
// Target is a square 600x600px with 200px from top bounds
setupScreenSize(2000, 1000)
every { targetOverlay.circleRect } returns RectF(700f, 200f, 1300f, 800f)
setupImageSize(1000, 1000)
analyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(700f, 200f, 1300f, 800f),
overlayWidth = 2000,
overlayHeight = 1000,
) { capturedBitmap = it }

analyzer.analyze(imageProxy)

Expand All @@ -78,10 +99,12 @@ internal class CropToTargetOverlayAnalyzerTest {

@Test
fun `Correctly crops when camera resolution is larger than preview in portrait`() {
// Target is a square 600x600px with 200px from top bounds
setupScreenSize(1000, 2000)
every { targetOverlay.circleRect } returns RectF(200f, 200f, 800f, 800f)
setupImageSize(2000, 2000)
analyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(200f, 200f, 800f, 800f),
overlayWidth = 1000,
overlayHeight = 2000,
) { capturedBitmap = it }

analyzer.analyze(imageProxy)

Expand All @@ -92,10 +115,12 @@ internal class CropToTargetOverlayAnalyzerTest {

@Test
fun `Correctly crops when camera resolution is larger than preview in landscape`() {
// Target is a square 600x600px with 200px from top bounds
setupScreenSize(2000, 1000)
every { targetOverlay.circleRect } returns RectF(700f, 200f, 1300f, 800f)
setupImageSize(2000, 2000)
analyzer = CropToTargetOverlayAnalyzer(
previewRect = RectF(700f, 200f, 1300f, 800f),
overlayWidth = 2000,
overlayHeight = 1000,
) { capturedBitmap = it }

analyzer.analyze(imageProxy)

Expand All @@ -104,14 +129,6 @@ internal class CropToTargetOverlayAnalyzerTest {
assertThat(capturedBitmap?.height).isEqualTo(600)
}

private fun setupScreenSize(
width: Int,
height: Int,
) {
every { targetOverlay.width } returns width
every { targetOverlay.height } returns height
}

private fun setupImageSize(
width: Int,
height: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.simprints.core.tools.utils.TimeUtils
Expand All @@ -31,7 +28,6 @@ import com.simprints.infra.uibase.view.applySystemBarInsets
import com.simprints.infra.uibase.view.setPulseAnimation
import com.simprints.infra.uibase.viewbinding.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import com.simprints.infra.resources.R as IDR

@AndroidEntryPoint
Expand Down Expand Up @@ -74,6 +70,13 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) {
)
}

override fun onDestroyView() {
binding.selectedModulesView.adapter = null
binding.eventSyncProgressBar.setPulseAnimation(isEnabled = false)
binding.imageSyncProgressBar.setPulseAnimation(isEnabled = false)
super.onDestroyView()
}

private fun setupClickListeners() {
binding.buttonSelectModules.setOnClickListener {
findNavController().navigate(R.id.moduleSelectionFragment)
Expand Down Expand Up @@ -126,15 +129,10 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) {
}

private fun observeUI() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
renderSyncInfo(SyncInfo(), syncInfoConfig)
viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo ->
renderSyncInfo(syncInfo, syncInfoConfig)
}
}
renderSyncInfo(SyncInfo(), syncInfoConfig)
viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo ->
renderSyncInfo(syncInfo, syncInfoConfig)
}

viewModel.loginNavigationEventLiveData.observe(viewLifecycleOwner) { loginParams ->
findNavController().navigate(com.simprints.feature.login.R.id.graph_login, loginParams.toBundle())
}
Expand Down Expand Up @@ -316,16 +314,14 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) {

moduleCountAdapter.submitList(modules.moduleCounts)

// RecyclerView height fix (wrong height may be caused by ConstraintLayout in parent views)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
val itemHeight = resources.getDimensionPixelSize(R.dimen.module_item_height)
val itemCount = modules.moduleCounts.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS)
binding.selectedModulesView.apply {
layoutParams = layoutParams.apply {
height = itemHeight * itemCount
}
val itemHeight = resources.getDimensionPixelSize(R.dimen.module_item_height)
val itemCount = modules.moduleCounts.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS)
binding.selectedModulesView.let { recyclerView ->
recyclerView.post {
recyclerView.layoutParams = recyclerView.layoutParams.apply {
height = itemHeight * itemCount
}
recyclerView.requestLayout()
}
}
}
Expand Down
Loading
Loading