Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions feature/dashboard/src/main/res/values/ids.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<string name="preference_select_fingers_key" translatable="false">select_fingers_preference_key</string>
<string name="preference_sync_info_key" translatable="false">sync_info_preference_key</string>
<string name="preference_update_config_key" translatable="false">preference_update_config_key</string>
<string name="preference_enable_audio_on_scan_complete_key" translatable="false">preference_enable_audio_on_scan_complete_key</string>

<string name="preferences_app_details_key" translatable="false">app_details_key</string>
<string name="preference_app_details_key" translatable="false">app_details_preference_key</string>
Expand Down
6 changes: 6 additions & 0 deletions feature/dashboard/src/main/res/xml/preference_general.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
android:key="@string/preference_update_config_key"
android:summary="@string/dashboard_preference_summary_update_config"
android:title="@string/dashboard_preference_update_config_title" />

<SwitchPreferenceCompat
android:defaultValue="false"
android:key="@string/preference_enable_audio_on_scan_complete_key"
android:summary="@string/dashboard_preference_summary_enable_audio_on_scan_complete"
android:title="@string/dashboard_preference_enable_audio_on_scan_complete" />
</PreferenceCategory>

<PreferenceCategory
Expand Down
1 change: 1 addition & 0 deletions fingerprint/capture/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {

// Android X
implementation(libs.androidX.ui.viewpager2)
implementation(libs.androidX.ui.preference)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need preferences UI in this module?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To access the PrefsManger class as it is part of the androidx prefs lib

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to decouple the actual setting from the UI?


testImplementation(project(":fingerprint:infra:scannermock"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.simprints.core.domain.common.FlowType
Expand Down Expand Up @@ -46,7 +47,9 @@ import com.simprints.infra.uibase.navigation.navigateSafely
import com.simprints.infra.uibase.system.Vibrate
import com.simprints.infra.uibase.viewbinding.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.io.Serializable
import javax.inject.Inject
import com.simprints.infra.resources.R as IDR

@AndroidEntryPoint
Expand All @@ -60,6 +63,9 @@ internal class FingerprintCaptureFragment : Fragment(R.layout.fragment_fingerpri
private var confirmDialog: AlertDialog? = null
private var hasSplashScreenBeenTriggered: Boolean = false

@Inject
lateinit var fingerprintScanCompletionAudioNotifier: FingerprintScanCompletionAudioNotifier

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

Expand Down Expand Up @@ -103,7 +109,9 @@ internal class FingerprintCaptureFragment : Fragment(R.layout.fragment_fingerpri
args.params.fingerprintsToCapture,
args.params.fingerprintSDK,
)

lifecycleScope.launch {
fingerprintScanCompletionAudioNotifier.observeScanStatus()
}
initUI()
}

Expand Down Expand Up @@ -288,6 +296,7 @@ internal class FingerprintCaptureFragment : Fragment(R.layout.fragment_fingerpri

override fun onDestroyView() {
confirmDialog?.dismiss()
fingerprintScanCompletionAudioNotifier.releaseMediaPlayer()
super.onDestroyView()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.simprints.fingerprint.capture.screen

import android.content.Context
import android.media.MediaPlayer
import androidx.preference.PreferenceManager
import com.simprints.fingerprint.capture.R
import com.simprints.fingerprint.infra.scanner.capture.FingerprintScanningStatusTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class FingerprintScanCompletionAudioNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val scanningStatusTracker: FingerprintScanningStatusTracker,
) {
private var mediaPlayer: MediaPlayer? = null

suspend fun observeScanStatus() {
scanningStatusTracker.scanCompleted.collect {
if (isAudioEnabled()) playBeep()
}
}

private fun playBeep() {
if (mediaPlayer == null) {
mediaPlayer = MediaPlayer.create(context, R.raw.beep)
}
mediaPlayer?.start()
}

private fun isAudioEnabled(): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(AUDIO_PREFERENCE_KEY, true)
}

fun releaseMediaPlayer() {
mediaPlayer?.release()
mediaPlayer = null
}

companion object {
private const val AUDIO_PREFERENCE_KEY = "preference_enable_audio_on_scan_complete_key"
}
}
Binary file added fingerprint/capture/src/main/res/raw/beep.mp3
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.simprints.fingerprint.capture.screen

import android.content.Context
import android.content.SharedPreferences
import android.media.MediaPlayer
import androidx.preference.PreferenceManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.simprints.fingerprint.capture.R
import com.simprints.fingerprint.infra.scanner.capture.FingerprintScanningStatusTracker
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class FingerprintScanCompletionAudioNotifierTest {

@MockK
private lateinit var context: Context
private lateinit var scanningStatusTracker: FingerprintScanningStatusTracker

@RelaxedMockK
private lateinit var mediaPlayer: MediaPlayer

@MockK
private lateinit var sharedPreferences: SharedPreferences

private lateinit var notifier: FingerprintScanCompletionAudioNotifier
private val testDispatcher = UnconfinedTestDispatcher()

@Before
fun setup() {
MockKAnnotations.init(this)
mockkStatic(PreferenceManager::class)
mockkStatic(MediaPlayer::class)
scanningStatusTracker = FingerprintScanningStatusTracker()
every { PreferenceManager.getDefaultSharedPreferences(context) } returns sharedPreferences
every { MediaPlayer.create(context, R.raw.beep) } returns mediaPlayer

notifier = FingerprintScanCompletionAudioNotifier(context, scanningStatusTracker)
}

@Test
fun `playBeep should be called when scan completes and audio is enabled`() =
runTest(testDispatcher) {
// Given
every { sharedPreferences.getBoolean(any(), any()) } returns true

// When
val job = launch { notifier.observeScanStatus() }
scanningStatusTracker.notifyScanCompleted()

// Then
verify { mediaPlayer.start() }
job.cancel()

}

@Test
fun `playBeep should not be called when scan completes and audio is disabled`() =
runTest(testDispatcher) {
// Given
every { sharedPreferences.getBoolean(any(), any()) } returns false

// When
val job = launch { notifier.observeScanStatus() }
scanningStatusTracker.notifyScanCompleted()

// Then
verify(exactly = 0) { mediaPlayer.start() }
job.cancel()

}

@Test
fun `releaseMediaPlayer should release the media player`() = runTest(testDispatcher) {
//Given
every { sharedPreferences.getBoolean(any(), any()) } returns true

// When
val job = launch { notifier.observeScanStatus() }
scanningStatusTracker.notifyScanCompleted()
notifier.releaseMediaPlayer()

// Then
verify { mediaPlayer.start() }
verify { mediaPlayer.release() }
job.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@ import com.simprints.fingerprint.infra.scanner.v2.scanner.Scanner as ScannerV2
class FingerprintCaptureWrapperFactory @Inject constructor(
@DispatcherIO private val ioDispatcher: CoroutineDispatcher,
private val scannerUiHelper: ScannerUiHelper,
private val scanningStatusTracker: FingerprintScanningStatusTracker
) {
private var _captureWrapper: FingerprintCaptureWrapper? = null

val captureWrapper: FingerprintCaptureWrapper
get() = _captureWrapper ?: throw NullScannerException()

fun createV1(scannerV1: ScannerV1) {
_captureWrapper = FingerprintCaptureWrapperV1(scannerV1, ioDispatcher)
_captureWrapper =
FingerprintCaptureWrapperV1(scannerV1, ioDispatcher, scanningStatusTracker)
}

fun createV2(scannerV2: ScannerV2) {
_captureWrapper = FingerprintCaptureWrapperV2(scannerV2, scannerUiHelper, ioDispatcher)
_captureWrapper = FingerprintCaptureWrapperV2(
scannerV2, scannerUiHelper, ioDispatcher, scanningStatusTracker
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import kotlin.coroutines.resumeWithException
internal class FingerprintCaptureWrapperV1(
private val scannerV1: Scanner,
private val ioDispatcher: CoroutineDispatcher,
private val scanningStatusTracker: FingerprintScanningStatusTracker
) : FingerprintCaptureWrapper {
override suspend fun acquireFingerprintImage(): AcquireFingerprintImageResponse {
throw UnavailableVero2FeatureException(UnavailableVero2Feature.IMAGE_ACQUISITION)
Expand Down Expand Up @@ -71,6 +72,7 @@ internal class FingerprintCaptureWrapperV1(
scannerV1.imageQuality
)
)
scanningStatusTracker.notifyScanCompleted()
},
failure = {
if (it == SCANNER_ERROR.TIMEOUT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal class FingerprintCaptureWrapperV2(
private val scannerV2: Scanner,
private val scannerUiHelper: ScannerUiHelper,
private val ioDispatcher: CoroutineDispatcher,
private val scanningStatusTracker: FingerprintScanningStatusTracker,
) : FingerprintCaptureWrapper {

override suspend fun acquireImageDistortionMatrixConfiguration(): ByteArray =
Expand Down Expand Up @@ -54,6 +55,7 @@ internal class FingerprintCaptureWrapperV2(
}
// Capture fingerprint and ensure it's OK
scannerV2.captureFingerprint().ensureCaptureResultOkOrError().await()
scanningStatusTracker.notifyScanCompleted()
// Transfer the unprocessed image from the scanner
acquireUnprocessedImage().switchIfEmpty(Single.error(NoFingerDetectedException("Failed to acquire unprocessed image data")))
.wrapErrorsFromScanner().await()
Expand Down Expand Up @@ -83,6 +85,9 @@ internal class FingerprintCaptureWrapperV2(
scannerV2
.captureFingerprint(captureDpi)
.ensureCaptureResultOkOrError()
.andThen(Completable.fromAction {
scanningStatusTracker.notifyScanCompleted()
})
.andThen(scannerV2.getImageQualityScore())
.switchIfEmpty(Single.error(NoFingerDetectedException("Failed to acquire image quality score")))
.setLedStateBasedOnQualityScoreOrInterpretAsNoFingerDetected(qualityThreshold)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.simprints.fingerprint.infra.scanner.capture

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class FingerprintScanningStatusTracker @Inject constructor() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a nitpick, but from the name and usage in the code above, I expected this to handle multiple status values. IMO, this class should either handle all the status changes (at least have an easy way to extend to do so) or have a less generic name.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luhmirin-s in this story I will introduce the idle success and failure flows

private val _scanCompleted = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val scanCompleted: SharedFlow<Unit> get() = _scanCompleted

fun notifyScanCompleted() {
_scanCompleted.tryEmit(Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class FingerprintCaptureWrapperFactoryTest {
@Before
fun setUp() {
fingerprintCaptureWrapperFactory =
FingerprintCaptureWrapperFactory(UnconfinedTestDispatcher(), ScannerUiHelper())
FingerprintCaptureWrapperFactory(
UnconfinedTestDispatcher(), ScannerUiHelper(), mockk(relaxed = true)
)
}

@Test(expected = NullScannerException::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.simprints.fingerprint.infra.scanner.v2.domain.main.message.un20.model
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.UnconfinedTestDispatcher
Expand All @@ -31,7 +32,8 @@ class FingerprintCaptureWrapperV1Test {
@Before
fun setup() {
MockKAnnotations.init(this, relaxed = true)
scannerWrapper = FingerprintCaptureWrapperV1(scanner, UnconfinedTestDispatcher())
scannerWrapper =
FingerprintCaptureWrapperV1(scanner, UnconfinedTestDispatcher(), mockk(relaxed = true))
}

@Test(expected = ScannerOperationInterruptedException::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.simprints.testtools.common.syntax.assertThrows
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.verify
import io.reactivex.Completable
import io.reactivex.Maybe
Expand All @@ -39,7 +40,9 @@ class FingerprintCaptureWrapperV2Test {
fun setup() {
MockKAnnotations.init(this, relaxed = true)
scannerWrapper =
FingerprintCaptureWrapperV2(scannerV2, scannerUiHelper, UnconfinedTestDispatcher())
FingerprintCaptureWrapperV2(
scannerV2, scannerUiHelper, UnconfinedTestDispatcher(), mockk(relaxed = true)
)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.simprints.fingerprint.infra.scanner.capture

import com.google.common.truth.Truth
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class FingerprintScanningStatusTrackerTest {
private val tracker = FingerprintScanningStatusTracker()
private val testDispatcher = UnconfinedTestDispatcher()

@Test
fun `test notifyScanCompleted emits Unit`() = runTest(testDispatcher) {
var emitted = false
val job = launch {
tracker.scanCompleted.collect {
emitted = true
}
}
tracker.notifyScanCompleted()
Truth.assertThat(emitted).isTrue()
job.cancel()
}

@Test
fun `test scanCompleted flow does not replay past emissions`() = runTest(testDispatcher) {
tracker.notifyScanCompleted()

var emitted = false
val job = launch {
tracker.scanCompleted.collect {
emitted = true
}
}
Truth.assertThat(emitted).isFalse()
job.cancel()
}

}
Loading