diff --git a/face/capture/build.gradle.kts b/face/capture/build.gradle.kts index 5392e9a69c..a7b1988206 100644 --- a/face/capture/build.gradle.kts +++ b/face/capture/build.gradle.kts @@ -18,24 +18,21 @@ dependencies { implementation(project(":infra:events")) implementation(project(":infra:images")) implementation(project(":infra:resources")) - implementation(project(":feature:exit-form")) implementation(project(":infra:license")) + implementation(project(":infra:camera")) + + implementation(project(":feature:exit-form")) implementation(project(":feature:alert")) // BIO SDK implementation(project(":face:infra:bio-sdk-resolver")) - implementation(libs.androidX.cameraX.core) - implementation(libs.androidX.cameraX.lifecycle) - implementation(libs.androidX.cameraX.view) implementation(libs.androidX.ui.preference) implementation(libs.workManager.work) implementation(libs.kotlin.serialization) implementation(libs.circleImageView) - runtimeOnly(libs.androidX.cameraX.core) - // ###################################################### // Android test // ###################################################### diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt index 977fb99fa1..dd5dd2eeea 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @HiltViewModel @@ -63,8 +62,6 @@ internal class FaceCaptureViewModel @Inject constructor( var initialised = false lateinit var bioSDK: ModalitySdkType - var shouldCheckCameraPermissions = AtomicBoolean(true) - private var faceDetections = listOf() val recaptureEvent: LiveData diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt deleted file mode 100644 index 00490f35aa..0000000000 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 kotlin.math.max -import kotlin.math.min - -internal class CropToTargetOverlayAnalyzer( - private val previewRect: RectF, - private val overlayWidth: Int, - private val overlayHeight: Int, - private val onImageCropped: (original: Bitmap, cropped: Bitmap) -> Unit, -) : ImageAnalysis.Analyzer { - override fun analyze(image: ImageProxy) { - val (originalBitmap, croppedBitmap) = image.use { - if (previewRect.isEmpty) return - - // 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(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() - - val imageBitmap = it.toBitmap() - imageBitmap to Bitmap.createBitmap( - imageBitmap, - cropLeft, - cropTop, - cropWidth, - cropHeight, - ) - } - onImageCropped(originalBitmap, croppedBitmap) - } - - private fun getSmallerRatio( - cameraWidth: Int, - cameraHeight: Int, - screenWidth: Int, - screenHeight: Int, - ): Float { - val widthRatio = cameraWidth / screenWidth.toFloat() - val heightRatio = cameraHeight / screenHeight.toFloat() - return min(widthRatio, heightRatio) - } -} 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 fced7b4187..937f10eebc 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 @@ -2,24 +2,14 @@ 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 import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes -import androidx.camera.core.CameraControl -import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888 -import androidx.camera.core.Preview -import androidx.camera.core.resolutionselector.ResolutionSelector -import androidx.camera.core.resolutionselector.ResolutionStrategy -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.lifecycle.awaitInstance import androidx.core.content.ContextCompat +import androidx.core.graphics.toRect import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isInvisible @@ -31,14 +21,17 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import com.simprints.core.DispatcherBG import com.simprints.core.domain.permission.PermissionStatus +import com.simprints.core.tools.extensions.getCurrentPermissionStatus import com.simprints.core.tools.extensions.hasCameraFlash -import com.simprints.core.tools.extensions.hasPermission import com.simprints.core.tools.extensions.permissionFromResult import com.simprints.face.capture.R import com.simprints.face.capture.databinding.FragmentLiveFeedbackBinding import com.simprints.face.capture.models.FaceDetection import com.simprints.face.capture.screens.FaceCaptureViewModel +import com.simprints.infra.camera.CameraFrameProvider +import com.simprints.infra.camera.postprocess.FrameCropToTargetUseCase import com.simprints.infra.logging.LoggingConstants.CrashReportTag.FACE_CAPTURE import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber @@ -48,9 +41,12 @@ import com.simprints.infra.uibase.view.awaitLayout import com.simprints.infra.uibase.view.setCheckedWithLeftDrawable import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors +import kotlinx.coroutines.withContext +import javax.inject.Inject import com.simprints.infra.resources.R as IDR /** @@ -61,22 +57,23 @@ import com.simprints.infra.resources.R as IDR */ @AndroidEntryPoint internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) { - /** Blocking camera operations are performed using this executor */ - private lateinit var cameraExecutor: ExecutorService - private val mainVm: FaceCaptureViewModel by activityViewModels() private val vm: LiveFeedbackViewModel by viewModels() private val binding by viewBinding(FragmentLiveFeedbackBinding::bind) 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 + @Inject + lateinit var cameraFrameProvider: CameraFrameProvider + + @Inject + lateinit var frameCropToTargetUseCase: FrameCropToTargetUseCase + + @Inject + @DispatcherBG + lateinit var bgDispatcher: CoroutineDispatcher - private var permissionStatus: PermissionStatus = PermissionStatus.Granted private var finishedHandled = false private val validCaptureProgressColor: Int @@ -90,8 +87,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private val launchPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> - permissionStatus = requireActivity().permissionFromResult(Manifest.permission.CAMERA, granted) - if (permissionStatus == PermissionStatus.Granted) setUpCamera() else renderNoPermission() + vm.onPermissionResult(requireActivity().permissionFromResult(Manifest.permission.CAMERA, granted)) } override fun onViewCreated( @@ -108,12 +104,14 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private fun initFragment() { screenSize = with(resources.displayMetrics) { Size(widthPixels, widthPixels) } bindViewModel() + bindPermissionActions() + setUpFrameProcessing() + binding.captureProgress.max = 1 // normalized progress - // `isAutoCapture` affects major parts of the UI state, so resolve it before wiring the capture button + setUpCaptureButton() viewLifecycleOwner.lifecycleScope.launch { vm.initAutoCapture() - setUpCaptureButton() } // Wait till the views gets its final size then init frame processor and setup the camera @@ -129,6 +127,9 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) directions = LiveFeedbackFragmentDirections.actionFaceLiveFeedbackFragmentToFacePreparationFragment(), ) } + binding.captureFeedbackPermissionButton.setOnClickListener { + vm.onPermissionButtonClicked() + } with(binding.captureFlashButton) { isVisible = requireContext().hasCameraFlash @@ -149,91 +150,31 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } private fun toggleTorch(enabled: Boolean) { - cameraControl?.enableTorch(enabled) + cameraFrameProvider.setTorchEnabled(enabled) binding.captureFlashButton.isSelected = enabled } /** Initialize CameraX, and prepare to bind the camera use cases */ private fun setUpCamera() = viewLifecycleOwner.lifecycleScope.launch { - permissionStatus = PermissionStatus.Granted - - if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) { + if (cameraFrameProvider.isInitialised()) { return@launch } + // Wait for the views to be properly laid out + binding.faceCaptureCamera.awaitLayout() binding.captureOverlay.awaitLayout() - // Initialize our background executor - cameraExecutor = Executors.newSingleThreadExecutor() - // ImageAnalysis - // Todo choose accurate output image resolution that respects quality,performance and face analysis SDKs https://simprints.atlassian.net/browse/CORE-2569 - if (!::targetResolution.isInitialized) { - targetResolution = Size(binding.captureOverlay.width, binding.captureOverlay.height) - } - val resolutionSelector = ResolutionSelector - .Builder() - .setResolutionStrategy( - ResolutionStrategy( - targetResolution, - ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, - ), - ).build() - - imageAnalyzer = ImageAnalysis - .Builder() - .setResolutionSelector(resolutionSelector) - .setOutputImageRotationEnabled(true) - .setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888) - .build() - - 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 = { original, cropped -> analyze(original, cropped) }, - ) - imageAnalyzer.setAnalyzer(cameraExecutor, cropAnalyzer) - - // Preview - preview = Preview - .Builder() - .setResolutionSelector(resolutionSelector) - .build() - val cameraProvider = ProcessCameraProvider.awaitInstance(requireContext()) - cameraProvider.unbindAll() - val camera = cameraProvider.bindToLifecycle( - viewLifecycleOwner, - DEFAULT_BACK_CAMERA, - preview, - imageAnalyzer, + cameraFrameProvider.initialiseCamera( + lifecycleOwner = viewLifecycleOwner, + previewView = binding.faceCaptureCamera, + target = binding.captureOverlay.circleRect.toRect(), ) - cameraControl = camera.cameraControl - // Attach the view's surface provider to preview use case - preview.surfaceProvider = binding.faceCaptureCamera.surfaceProvider Simber.i("Camera setup finished", tag = FACE_CAPTURE) } override fun onResume() { super.onResume() - when { - requireActivity().hasPermission(Manifest.permission.CAMERA) -> { - setUpCamera() - toggleCaptureButtonIfAutoCapture(true) - } - - mainVm.shouldCheckCameraPermissions.getAndSet(false) -> { - // Check permission in onResume() so that if user left the app to go to Settings - // and give the permission, it's reflected when they come back to SID - if (requireActivity().hasPermission(Manifest.permission.CAMERA)) { - setUpCamera() - } else { - permissionStatus = PermissionStatus.Denied - launchPermissionRequest.launch(Manifest.permission.CAMERA) - } - } - - else -> mainVm.shouldCheckCameraPermissions.set(true) - } + vm.onScreenResumed(requireActivity().getCurrentPermissionStatus(Manifest.permission.CAMERA)) } private fun toggleCaptureButtonIfAutoCapture(enabled: Boolean) { @@ -248,43 +189,77 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } override fun onDestroyView() { - if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) { - cameraExecutor.shutdown() - } - if (::imageAnalyzer.isInitialized) { - imageAnalyzer.clearAnalyzer() - } - if (::preview.isInitialized) { - preview.surfaceProvider = null - } + cameraFrameProvider.release() super.onDestroyView() } private fun bindViewModel() { viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - vm.state.collect(::render) + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { vm.state.collect(::render) } + launch { + vm.state + .map { it.permissionStatus } + .distinctUntilChanged() + .collect { permissionStatus -> + if (permissionStatus == PermissionStatus.Granted) { + setUpCamera() + toggleCaptureButtonIfAutoCapture(true) + } + } + } } } } - private fun analyze( - original: Bitmap, - cropped: Bitmap, - ) { - try { - vm.process(originalBitmap = original, croppedBitmap = cropped) - } catch (t: Throwable) { - Simber.e("Image analysis crashed", t, tag = FACE_CAPTURE) - // Image analysis is running in bg thread - lifecycleScope.launch { - mainVm.submitError(t) + private fun bindPermissionActions() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.permissionActions.collect { action -> + when (action) { + LiveFeedbackViewModel.PermissionAction.RequestCameraPermission -> + launchPermissionRequest.launch(Manifest.permission.CAMERA) + + LiveFeedbackViewModel.PermissionAction.OpenAppSettings -> + requireActivity().startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${requireActivity().packageName}".toUri(), + ), + ) + } + } + } + } + } + + private fun setUpFrameProcessing() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + cameraFrameProvider.frames.collect { frame -> + cameraFrameProvider.setFrameEmissionEnabled(false) + try { + withContext(bgDispatcher) { + val cropped = frameCropToTargetUseCase(frame) + vm.process(originalBitmap = frame.bitmap, croppedBitmap = cropped) + } + } catch (t: Throwable) { + Simber.e("Image analysis crashed", t, tag = FACE_CAPTURE) + // submitError updates LiveData, so ensure it happens on the main thread + viewLifecycleOwner.lifecycleScope.launch { mainVm.submitError(t) } + } finally { + cameraFrameProvider.setFrameEmissionEnabled(true) + } + } } } } private fun render(state: LiveFeedbackState) { - if (permissionStatus != PermissionStatus.Granted) return + if (state.permissionStatus != PermissionStatus.Granted) { + renderNoPermission() + return + } renderProgress(state.progress) when (state.phase) { @@ -442,18 +417,6 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackTxtExplanation.setText(IDR.string.face_capture_permission_denied) captureFeedbackBtn.isGone = true captureFeedbackPermissionButton.isVisible = true - captureFeedbackPermissionButton.setOnClickListener { - if (permissionStatus == PermissionStatus.DeniedNeverAskAgain) { - requireActivity().startActivity( - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - "package:${requireActivity().packageName}".toUri(), - ), - ) - } else { - launchPermissionRequest.launch(Manifest.permission.CAMERA) - } - } setManualCaptureButtonClickable(false) } } diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt index 6bb5c39484..6efa2d0e2e 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt @@ -1,5 +1,6 @@ package com.simprints.face.capture.screens.livefeedback +import com.simprints.core.domain.permission.PermissionStatus import com.simprints.face.capture.models.FaceDetection /** @@ -9,6 +10,7 @@ internal data class LiveFeedbackState( val phase: Phase, val feedback: Feedback, val isAutoCapture: Boolean, + val permissionStatus: PermissionStatus, val progress: Progress, val result: List = emptyList(), ) { @@ -26,6 +28,7 @@ internal data class LiveFeedbackState( phase = Phase.NOT_STARTED, feedback = Feedback.NONE, isAutoCapture = isAutoCapture, + permissionStatus = PermissionStatus.Denied, progress = Progress.HIDDEN, ) } diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt index e635cbd8fe..0154893db4 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherBG +import com.simprints.core.domain.permission.PermissionStatus import com.simprints.core.tools.extensions.area import com.simprints.core.tools.time.TimeHelper import com.simprints.face.capture.models.FaceDetection @@ -29,9 +30,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -70,8 +72,11 @@ internal class LiveFeedbackViewModel @Inject constructor( * The single source of truth for the whole screen. * The fragment renders this deterministically; all transitions funnel through [emit]. */ - private val _state = MutableStateFlow(LiveFeedbackState.initial()) - val state: StateFlow = _state.asStateFlow() + + val state: StateFlow + field = MutableStateFlow(LiveFeedbackState.initial()) + val permissionActions: SharedFlow + field = MutableSharedFlow(extraBufferCapacity = 1) var isAutoCapture: Boolean = false private var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED @@ -83,21 +88,24 @@ internal class LiveFeedbackViewModel @Inject constructor( private var autoCaptureImagingTimeoutJob: Job? = null private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT private lateinit var faceDetector: FaceDetector + private var hasAutoRequestedPermission = false private val phase: LiveFeedbackState.Phase - get() = _state.value.phase + get() = state.value.phase private fun emit( - phase: LiveFeedbackState.Phase = _state.value.phase, - feedback: LiveFeedbackState.Feedback = _state.value.feedback, + phase: LiveFeedbackState.Phase = state.value.phase, + feedback: LiveFeedbackState.Feedback = state.value.feedback, + permissionStatus: PermissionStatus = state.value.permissionStatus, detectionForTint: FaceDetection? = null, - result: List = _state.value.result, + result: List = state.value.result, ) { - _state.update { currentState -> + state.update { currentState -> currentState.copy( phase = phase, feedback = feedback, isAutoCapture = isAutoCapture, + permissionStatus = permissionStatus, progress = computeProgress(phase, detectionForTint), result = result, ) @@ -114,6 +122,33 @@ internal class LiveFeedbackViewModel @Inject constructor( emit() // Reset UI state with correct auto-capture value } + fun onScreenResumed(permissionStatus: PermissionStatus) { + emit(permissionStatus = permissionStatus) + if (permissionStatus == PermissionStatus.Granted) { + hasAutoRequestedPermission = false + return + } + if (permissionStatus == PermissionStatus.Denied && !hasAutoRequestedPermission) { + hasAutoRequestedPermission = true + permissionActions.tryEmit(PermissionAction.RequestCameraPermission) + } + } + + fun onPermissionResult(permissionStatus: PermissionStatus) { + emit(permissionStatus = permissionStatus) + if (permissionStatus == PermissionStatus.Granted) { + hasAutoRequestedPermission = false + } + } + + fun onPermissionButtonClicked() { + when (state.value.permissionStatus) { + PermissionStatus.DeniedNeverAskAgain -> permissionActions.tryEmit(PermissionAction.OpenAppSettings) + PermissionStatus.Granted -> Unit + PermissionStatus.Denied -> permissionActions.tryEmit(PermissionAction.RequestCameraPermission) + } + } + fun initCapture( bioSdk: ModalitySdkType, samplesToCapture: Int, @@ -177,7 +212,7 @@ internal class LiveFeedbackViewModel @Inject constructor( faceDetection.detectionEndTime = timeHelper.now() var newPhase = phase - var feedback = _state.value.feedback + var feedback = state.value.feedback if (isAutoCapture) { if (!isAutoCaptureHeldOff) { @@ -448,4 +483,9 @@ internal class LiveFeedbackViewModel @Inject constructor( private const val VALID_ROLL_DELTA = 15f private const val VALID_YAW_DELTA = 30f } + + enum class PermissionAction { + RequestCameraPermission, + OpenAppSettings, + } } diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt deleted file mode 100644 index cf20ce2147..0000000000 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.simprints.face.capture.screens.livefeedback - -import android.graphics.Bitmap -import android.graphics.RectF -import androidx.camera.core.ImageProxy -import androidx.test.ext.junit.runners.* -import com.google.common.truth.Truth.* -import io.mockk.* -import io.mockk.impl.annotations.MockK -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -internal class CropToTargetOverlayAnalyzerTest { - @MockK - lateinit var imageProxy: ImageProxy - - lateinit var analyzer: CropToTargetOverlayAnalyzer - var capturedBitmap: Bitmap? = null - - @Before - fun setUp() { - MockKAnnotations.init(this) - justRun { imageProxy.close() } - capturedBitmap = null - } - - @Test - fun `Skip cropping when target is empty`() { - setupImageSize(1000, 1000) - analyzer = CropToTargetOverlayAnalyzer( - previewRect = RectF(200f, 200f, 200f, 200f), - overlayWidth = 1000, - overlayHeight = 2000, - onImageCropped = { _, cropped -> capturedBitmap = cropped }, - ) - - 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`() { - setupImageSize(1000, 1000) - analyzer = CropToTargetOverlayAnalyzer( - previewRect = RectF(200f, 200f, 800f, 800f), - overlayWidth = 1000, - overlayHeight = 2000, - onImageCropped = { _, cropped -> capturedBitmap = cropped }, - ) - - analyzer.analyze(imageProxy) - - // Cropped should be still square and half the side length of original - assertThat(capturedBitmap?.width).isEqualTo(300) - 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, - onImageCropped = { _, _ -> 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`() { - setupImageSize(1000, 1000) - analyzer = CropToTargetOverlayAnalyzer( - previewRect = RectF(700f, 200f, 1300f, 800f), - overlayWidth = 2000, - overlayHeight = 1000, - onImageCropped = { _, cropped -> capturedBitmap = cropped }, - ) - - analyzer.analyze(imageProxy) - - // Cropped should be still square and half the side length of original - assertThat(capturedBitmap?.width).isEqualTo(300) - assertThat(capturedBitmap?.height).isEqualTo(300) - } - - @Test - fun `Correctly crops when camera resolution is larger than preview in portrait`() { - setupImageSize(2000, 2000) - analyzer = CropToTargetOverlayAnalyzer( - previewRect = RectF(200f, 200f, 800f, 800f), - overlayWidth = 1000, - overlayHeight = 2000, - onImageCropped = { _, cropped -> capturedBitmap = cropped }, - ) - - analyzer.analyze(imageProxy) - - // Cropped should be still square and half the side length of original - assertThat(capturedBitmap?.width).isEqualTo(600) - assertThat(capturedBitmap?.height).isEqualTo(600) - } - - @Test - fun `Correctly crops when camera resolution is larger than preview in landscape`() { - setupImageSize(2000, 2000) - analyzer = CropToTargetOverlayAnalyzer( - previewRect = RectF(700f, 200f, 1300f, 800f), - overlayWidth = 2000, - overlayHeight = 1000, - onImageCropped = { _, cropped -> capturedBitmap = cropped }, - ) - - analyzer.analyze(imageProxy) - - // Cropped should be still square and half the side length of original - assertThat(capturedBitmap?.width).isEqualTo(600) - assertThat(capturedBitmap?.height).isEqualTo(600) - } - - private fun setupImageSize( - width: Int, - height: Int, - ) { - every { imageProxy.toBitmap() } returns Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - every { imageProxy.width } returns width - every { imageProxy.height } returns height - } -} diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt index 3b75ba1e4c..99230a3d5d 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt @@ -5,6 +5,7 @@ import android.graphics.Rect import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.* import com.google.common.truth.Truth.* +import com.simprints.core.domain.permission.PermissionStatus import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.face.capture.usecases.GetSpoofCheckConfigurationUseCase @@ -121,11 +122,41 @@ internal class LiveFeedbackViewModelTest { every { isUsingAutoCapture.invoke(any()) } returns true viewModel.initAutoCapture() + advanceUntilIdle() assertThat(viewModel.state.value.isAutoCapture).isTrue() assertThat(viewModel.isAutoCapture).isTrue() } + @Test + fun `onScreenResumed with denied permission auto-requests only once`() = runTest { + val actions = mutableListOf() + backgroundScope.launch(testCoroutineRule.testCoroutineDispatcher) { + viewModel.permissionActions.toList(actions) + } + + viewModel.onScreenResumed(PermissionStatus.Denied) + viewModel.onScreenResumed(PermissionStatus.Denied) + advanceUntilIdle() + + assertThat(actions).containsExactly(LiveFeedbackViewModel.PermissionAction.RequestCameraPermission) + assertThat(viewModel.state.value.permissionStatus).isEqualTo(PermissionStatus.Denied) + } + + @Test + fun `permission button opens settings when permission is denied forever`() = runTest { + val actions = mutableListOf() + backgroundScope.launch(testCoroutineRule.testCoroutineDispatcher) { + viewModel.permissionActions.toList(actions) + } + + viewModel.onPermissionResult(PermissionStatus.DeniedNeverAskAgain) + viewModel.onPermissionButtonClicked() + advanceUntilIdle() + + assertThat(actions).containsExactly(LiveFeedbackViewModel.PermissionAction.OpenAppSettings) + } + @Test fun `manual - valid face before start keeps NOT_STARTED, shows VALID feedback and stores fallback`() = runTest { every { faceDetector.analyze(frame) } returns getFace()