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 51f7040d3e..cb55888b8d 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 @@ -9,6 +9,7 @@ import android.provider.Settings import android.util.Size import android.view.View import androidx.activity.result.contract.ActivityResultContracts +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 @@ -24,6 +25,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.simprints.core.domain.permission.PermissionStatus +import com.simprints.core.tools.extentions.hasCameraFlash import com.simprints.core.tools.extentions.hasPermission import com.simprints.core.tools.extentions.permissionFromResult import com.simprints.face.capture.R @@ -62,6 +64,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private lateinit var screenSize: Size private lateinit var targetResolution: Size + private var cameraControl: CameraControl? = null + private val launchPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -103,6 +107,19 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) directions = LiveFeedbackFragmentDirections.actionFaceLiveFeedbackFragmentToFacePreparationFragment(), ) } + + with(binding.captureFlashButton) { + isSelected = false + setOnClickListener { + val torchEnabled = !binding.captureFlashButton.isSelected + toggleTorche(torchEnabled) + } + } + } + + private fun toggleTorche(enabled: Boolean) { + cameraControl?.enableTorch(enabled) + binding.captureFlashButton.isSelected = enabled } /** Initialize CameraX, and prepare to bind the camera use cases */ @@ -132,12 +149,13 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) val preview = Preview.Builder().setTargetResolution(targetResolution).build() val cameraProvider = ProcessCameraProvider.awaitInstance(requireContext()) cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( + val camera = cameraProvider.bindToLifecycle( this@LiveFeedbackFragment, DEFAULT_BACK_CAMERA, preview, imageAnalyzer, ) + 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) @@ -163,6 +181,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } override fun onStop() { + toggleTorche(false) // Shut down our background executor if (::cameraExecutor.isInitialized) { cameraExecutor.shutdown() @@ -171,6 +190,10 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } private fun bindViewModel() { + vm.displayCameraFlashControls.observe(viewLifecycleOwner) { + binding.captureFlashButton.isVisible = it && requireContext().hasCameraFlash + } + vm.currentDetection.observe(viewLifecycleOwner) { renderCurrentDetection(it) } diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt index 25db13cb36..b2dfb41eb3 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt @@ -51,6 +51,9 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( var sortedQualifyingCaptures = listOf() val currentDetection = MutableLiveData() val capturingState = MutableLiveData(CapturingState.NOT_STARTED) + + val displayCameraFlashControls = MutableLiveData(false) + private lateinit var faceDetector: FaceDetector /** @@ -97,6 +100,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( val config = configManager.getProjectConfiguration() qualityThreshold = config.face?.getSdkConfiguration(bioSdk)?.qualityThreshold ?: 0f singleQualityFallbackCaptureRequired = config.experimental().singleQualityFallbackRequired + displayCameraFlashControls.postValue(config.experimental().displayCameraFlashToggle) } } diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragment.kt index 53b5c96f22..e0ad0c1862 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragment.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragment.kt @@ -9,6 +9,7 @@ import android.provider.Settings import android.util.Size import android.view.View import androidx.activity.result.contract.ActivityResultContracts +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 @@ -24,6 +25,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.simprints.core.domain.permission.PermissionStatus +import com.simprints.core.tools.extentions.hasCameraFlash import com.simprints.core.tools.extentions.hasPermission import com.simprints.core.tools.extentions.permissionFromResult import com.simprints.face.capture.R @@ -61,6 +63,8 @@ internal class LiveFeedbackAutoCaptureFragment : Fragment(R.layout.fragment_live private lateinit var screenSize: Size private lateinit var targetResolution: Size + private var cameraControl: CameraControl? = null + private val launchPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -103,6 +107,19 @@ internal class LiveFeedbackAutoCaptureFragment : Fragment(R.layout.fragment_live directions = LiveFeedbackAutoCaptureFragmentDirections.actionFaceLiveFeedbackFragmentToFacePreparationFragment(), ) } + + with(binding.captureFlashButton) { + isSelected = false + setOnClickListener { + val torchEnabled = !binding.captureFlashButton.isSelected + toggleTorche(torchEnabled) + } + } + } + + private fun toggleTorche(enabled: Boolean) { + cameraControl?.enableTorch(enabled) + binding.captureFlashButton.isSelected = enabled } /** Initialize CameraX, and prepare to bind the camera use cases */ @@ -135,12 +152,13 @@ internal class LiveFeedbackAutoCaptureFragment : Fragment(R.layout.fragment_live val preview = Preview.Builder().setTargetResolution(targetResolution).build() val cameraProvider = ProcessCameraProvider.awaitInstance(requireContext()) cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( + val camera = cameraProvider.bindToLifecycle( this@LiveFeedbackAutoCaptureFragment, DEFAULT_BACK_CAMERA, preview, imageAnalyzer, ) + cameraControl = camera.cameraControl // Attach the view's surface provider to preview use case preview.surfaceProvider = binding.faceCaptureCamera.surfaceProvider } @@ -165,6 +183,7 @@ internal class LiveFeedbackAutoCaptureFragment : Fragment(R.layout.fragment_live } override fun onStop() { + toggleTorche(false) // Shut down our background executor if (::cameraExecutor.isInitialized) { cameraExecutor.shutdown() @@ -173,6 +192,10 @@ internal class LiveFeedbackAutoCaptureFragment : Fragment(R.layout.fragment_live } private fun bindViewModel() { + vm.displayCameraFlashControls.observe(viewLifecycleOwner) { + binding.captureFlashButton.isVisible = it && requireContext().hasCameraFlash + } + vm.currentDetection.observe(viewLifecycleOwner) { renderCurrentDetection(it) } diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragmentViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragmentViewModel.kt index 0017653a91..e5fe54b602 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragmentViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedbackautocapture/LiveFeedbackAutoCaptureFragmentViewModel.kt @@ -53,6 +53,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModel @Inject constructor( var sortedQualifyingCaptures = listOf() val currentDetection = MutableLiveData() val capturingState = MutableLiveData(CapturingState.NOT_STARTED) + + val displayCameraFlashControls = MutableLiveData(false) + private var isAutoCaptureHeldOff = true private var autoCaptureImagingTimeoutJob: Job? = null private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT @@ -124,6 +127,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModel @Inject constructor( qualityThreshold = config.face?.getSdkConfiguration(bioSdk)?.qualityThreshold ?: 0f singleQualityFallbackCaptureRequired = config.experimental().singleQualityFallbackRequired autoCaptureImagingDurationMillis = config.experimental().faceAutoCaptureImagingDurationMillis + displayCameraFlashControls.postValue(config.experimental().displayCameraFlashToggle) } } 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 78bff808de..43b9b7ec0e 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 @@ -15,16 +15,16 @@ android:layout_height="36dp" android:layout_margin="@dimen/margin_large" android:background="@drawable/feedback_instructions_outline" + android:gravity="center" + android:includeFontPadding="false" android:paddingHorizontal="@dimen/padding_default" android:paddingVertical="0dp" - android:includeFontPadding="false" - android:gravity="center" android:text="@string/face_capture_instructions_title" android:textColor="@color/feedback_instructions_text" app:backgroundTint="@null" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent"/> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintStart_toStartOf="parent" /> - + - - + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintStart_toStartOf="parent" /> - + - - + + + + app:layout_constraintStart_toStartOf="parent" /> diff --git a/face/capture/src/main/res/layout/fragment_live_feedback_auto_capture.xml b/face/capture/src/main/res/layout/fragment_live_feedback_auto_capture.xml index 6f748f7d76..ddb41db106 100644 --- a/face/capture/src/main/res/layout/fragment_live_feedback_auto_capture.xml +++ b/face/capture/src/main/res/layout/fragment_live_feedback_auto_capture.xml @@ -61,6 +61,20 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/capture_progress" /> + + + app:layout_constraintStart_toStartOf="parent" /> diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt index d0d2e00aaa..584972ec3c 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt @@ -50,6 +50,12 @@ data class ExperimentalProjectConfiguration( ?.let { it as? Boolean } .let { it == true } + val displayCameraFlashToggle: Boolean + get() = customConfig + ?.get(CAMERA_FLASH_CONTROLS_ENABLED) + ?.let { it as? Boolean } + .let { it == true } + companion object { internal const val ENABLE_ID_POOL_VALIDATION = "validateIdentificationPool" internal const val SINGLE_GOOD_QUALITY_FALLBACK_REQUIRED = "singleQualityFallbackRequired" @@ -64,5 +70,7 @@ data class ExperimentalProjectConfiguration( internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX = 60_000L internal const val SAMPLE_UPLOAD_WITH_URL_ENABLED = "sampleUploadWithSignedUrl" + + internal const val CAMERA_FLASH_CONTROLS_ENABLED = "displayCameraFlashToggle" } } diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt index 407fedab14..737d68d664 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt @@ -1,6 +1,7 @@ package com.simprints.infra.config.store.models import com.google.common.truth.Truth.* +import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.CAMERA_FLASH_CONTROLS_ENABLED import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.ENABLE_ID_POOL_VALIDATION import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_ENABLED import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS @@ -127,4 +128,20 @@ internal class ExperimentalProjectConfigurationTest { assertThat(ExperimentalProjectConfiguration(config).sampleUploadWithSignedUrlEnabled).isEqualTo(result) } } + + @Test + fun `check display camera flash flag correctly`() { + mapOf( + // Value not present + emptyMap() to false, + // Value not boolean + mapOf(CAMERA_FLASH_CONTROLS_ENABLED to 1) to false, + // Value present and FALSE + mapOf(CAMERA_FLASH_CONTROLS_ENABLED to false) to false, + // Value present and TRUE + mapOf(CAMERA_FLASH_CONTROLS_ENABLED to true) to true, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).displayCameraFlashToggle).isEqualTo(result) + } + } } diff --git a/infra/core/src/main/java/com/simprints/core/tools/extentions/Context.ext.kt b/infra/core/src/main/java/com/simprints/core/tools/extentions/Context.ext.kt index 0984194360..14c914bd18 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/extentions/Context.ext.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/extentions/Context.ext.kt @@ -28,3 +28,7 @@ val Context.applicationSettingsIntent: Intent Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"), ) + +@ExcludedFromGeneratedTestCoverageReports("UI code") +val Context.hasCameraFlash: Boolean + get() = packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH) diff --git a/infra/resources/src/main/res/drawable/ic_flash.xml b/infra/resources/src/main/res/drawable/ic_flash.xml new file mode 100644 index 0000000000..67eda978ae --- /dev/null +++ b/infra/resources/src/main/res/drawable/ic_flash.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/infra/resources/src/main/res/drawable/ic_flash_off.xml b/infra/resources/src/main/res/drawable/ic_flash_off.xml new file mode 100644 index 0000000000..7d023e6cf6 --- /dev/null +++ b/infra/resources/src/main/res/drawable/ic_flash_off.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/infra/resources/src/main/res/drawable/ic_flash_on.xml b/infra/resources/src/main/res/drawable/ic_flash_on.xml new file mode 100644 index 0000000000..153ba4cd0e --- /dev/null +++ b/infra/resources/src/main/res/drawable/ic_flash_on.xml @@ -0,0 +1,10 @@ + + +