From 0c3885a956026e06f7c5bacdc643fc50d995ec06 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 6 Sep 2024 14:54:52 +0200 Subject: [PATCH] Add QR code scanner UI --- .../common/mvi/UnidirectionalViewModel.kt | 3 +- feature/migration/qrcode/build.gradle.kts | 10 +- .../qrcode/src/debug/AndroidManifest.xml | 31 ++++ .../qrcode/ui/NoOpQrCodeScannerViewModel.kt | 12 ++ .../ui/PermissionDeniedContentPreview.kt | 18 +++ .../migration/qrcode/ui/QrCodeApplication.kt | 19 +++ .../qrcode/ui/QrCodeScannerActivity.kt | 22 +++ .../qrcode/ui/QrCodeScannerScreenPreview.kt | 43 ++++++ .../qrcode/src/main/AndroidManifest.xml | 7 + .../feature/migration/qrcode/QrCodeModule.kt | 9 ++ .../qrcode/ui/PermissionDeniedContent.kt | 49 +++++++ .../qrcode/ui/QrCodeScannerContent.kt | 46 ++++++ .../qrcode/ui/QrCodeScannerContract.kt | 30 ++++ .../qrcode/ui/QrCodeScannerScreen.kt | 62 ++++++++ .../migration/qrcode/ui/QrCodeScannerView.kt | 14 ++ .../qrcode/ui/QrCodeScannerViewModel.kt | 63 +++++++++ .../qrcode/src/main/res/values/strings.xml | 6 + .../qrcode/ui/FakeQrCodeScannerViewModel.kt | 10 ++ .../qrcode/ui/QrCodeScannerScreenKtTest.kt | 133 ++++++++++++++++++ .../qrcode/ui/QrCodeScannerViewModelTest.kt | 121 ++++++++++++++++ 20 files changed, 705 insertions(+), 3 deletions(-) create mode 100644 feature/migration/qrcode/src/debug/AndroidManifest.xml create mode 100644 feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/NoOpQrCodeScannerViewModel.kt create mode 100644 feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContentPreview.kt create mode 100644 feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeApplication.kt create mode 100644 feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerActivity.kt create mode 100644 feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenPreview.kt create mode 100644 feature/migration/qrcode/src/main/AndroidManifest.xml create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeModule.kt create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContent.kt create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContent.kt create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContract.kt create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreen.kt create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerView.kt create mode 100644 feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModel.kt create mode 100644 feature/migration/qrcode/src/main/res/values/strings.xml create mode 100644 feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/FakeQrCodeScannerViewModel.kt create mode 100644 feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenKtTest.kt create mode 100644 feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModelTest.kt diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt index 5384671276d..73e80936dcd 100644 --- a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.State import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest /** * Interface for a unidirectional view model with side-effects ([EFFECT]). It has a [STATE] and can handle [EVENT]'s. @@ -92,7 +91,7 @@ inline fun UnidirectionalViewModel Unit = { event(it) } LaunchedEffect(key1 = effect) { - effect.collectLatest { + effect.collect { handleEffect(it) } } diff --git a/feature/migration/qrcode/build.gradle.kts b/feature/migration/qrcode/build.gradle.kts index 487f20783b0..a284219850f 100644 --- a/feature/migration/qrcode/build.gradle.kts +++ b/feature/migration/qrcode/build.gradle.kts @@ -1,5 +1,6 @@ plugins { - id(ThunderbirdPlugins.Library.android) + // TODO: Change to ThunderbirdPlugins.Library.androidCompose when integrating the feature into the app. + id(ThunderbirdPlugins.App.androidCompose) } android { @@ -9,6 +10,13 @@ android { dependencies { implementation(projects.core.common) + + implementation(projects.core.ui.compose.designsystem) + debugImplementation(projects.core.ui.compose.theme2.k9mail) + implementation(libs.moshi) implementation(libs.timber) + + testImplementation(projects.core.ui.compose.testing) + testImplementation(projects.core.ui.compose.theme2.k9mail) } diff --git a/feature/migration/qrcode/src/debug/AndroidManifest.xml b/feature/migration/qrcode/src/debug/AndroidManifest.xml new file mode 100644 index 00000000000..42d7c2b0c42 --- /dev/null +++ b/feature/migration/qrcode/src/debug/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/NoOpQrCodeScannerViewModel.kt b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/NoOpQrCodeScannerViewModel.kt new file mode 100644 index 00000000000..56b93e95069 --- /dev/null +++ b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/NoOpQrCodeScannerViewModel.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.migration.qrcode.ui + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State + +class NoOpQrCodeScannerViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), QrCodeScannerContract.ViewModel { + override fun event(event: Event) = Unit +} diff --git a/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContentPreview.kt b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContentPreview.kt new file mode 100644 index 00000000000..34fbd1e3e97 --- /dev/null +++ b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContentPreview.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.migration.qrcode.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface + +@PreviewScreenSizes +@Composable +fun PermissionDeniedContentPreview() { + PreviewWithTheme { + Surface { + PermissionDeniedContent( + onGoToSettingsClick = {}, + ) + } + } +} diff --git a/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeApplication.kt b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeApplication.kt new file mode 100644 index 00000000000..9638bc5a290 --- /dev/null +++ b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeApplication.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.migration.qrcode.ui + +import android.app.Application +import android.content.Context +import app.k9mail.feature.migration.qrcode.qrCodeModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +// TODO: This only exists for manual testing during development. Remove when integrating the feature into the app. +class QrCodeApplication : Application() { + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + + startKoin { + androidContext(this@QrCodeApplication) + modules(qrCodeModule) + } + } +} diff --git a/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerActivity.kt b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerActivity.kt new file mode 100644 index 00000000000..9b54b281bf9 --- /dev/null +++ b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerActivity.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.migration.qrcode.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import app.k9mail.core.ui.compose.common.activity.setActivityContent +import app.k9mail.core.ui.compose.theme2.k9mail.K9MailTheme2 + +// TODO: This only exists for manual testing during development. Remove when integrating the feature into the app. +class QrCodeScannerActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + setActivityContent { + K9MailTheme2 { + QrCodeScannerScreen() + } + } + } +} diff --git a/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenPreview.kt b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenPreview.kt new file mode 100644 index 00000000000..468f2ca76dc --- /dev/null +++ b/feature/migration/qrcode/src/debug/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenPreview.kt @@ -0,0 +1,43 @@ +package app.k9mail.feature.migration.qrcode.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState + +@Preview +@Composable +fun QrCodeScannerScreenPreview_permission_unknown() { + PreviewWithTheme { + QrCodeScannerScreen( + viewModel = NoOpQrCodeScannerViewModel( + initialState = State(cameraPermissionState = UiPermissionState.Unknown), + ), + ) + } +} + +@Preview +@Composable +fun QrCodeScannerScreenPreview_permission_granted() { + PreviewWithTheme { + QrCodeScannerScreen( + viewModel = NoOpQrCodeScannerViewModel( + initialState = State(cameraPermissionState = UiPermissionState.Granted), + ), + ) + } +} + +@Preview +@Composable +fun QrCodeScannerScreenPreview_permission_denied() { + PreviewWithTheme { + QrCodeScannerScreen( + viewModel = NoOpQrCodeScannerViewModel( + initialState = State(cameraPermissionState = UiPermissionState.Denied), + ), + ) + } +} diff --git a/feature/migration/qrcode/src/main/AndroidManifest.xml b/feature/migration/qrcode/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..cdeb4ebf8c0 --- /dev/null +++ b/feature/migration/qrcode/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeModule.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeModule.kt new file mode 100644 index 00000000000..db96fcab2e1 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeModule.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.migration.qrcode + +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val qrCodeModule = module { + viewModel { QrCodeScannerViewModel() } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContent.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContent.kt new file mode 100644 index 00000000000..90191fc4f4b --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/PermissionDeniedContent.kt @@ -0,0 +1,49 @@ +package app.k9mail.feature.migration.qrcode.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveContent +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.migration.qrcode.R + +@Composable +internal fun PermissionDeniedContent( + onGoToSettingsClick: () -> Unit, +) { + ResponsiveContent( + modifier = Modifier.testTag("PermissionDeniedContent"), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxHeight() + .padding(MainTheme.spacings.double), + ) { + TextTitleLarge(text = stringResource(R.string.migration_qrcode_permission_denied_title)) + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + + TextBodyLarge(text = stringResource(R.string.migration_qrcode_permission_denied_message)) + Spacer(modifier = Modifier.height(MainTheme.spacings.triple)) + + ButtonFilled( + text = stringResource(R.string.migration_qrcode_go_to_settings_button_text), + onClick = onGoToSettingsClick, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("GoToSettingsButton"), + ) + } + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContent.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContent.kt new file mode 100644 index 00000000000..39a433b481a --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContent.kt @@ -0,0 +1,46 @@ +package app.k9mail.feature.migration.qrcode.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState + +@Composable +internal fun QrCodeScannerContent( + state: State, + onEvent: (Event) -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + when (state.cameraPermissionState) { + UiPermissionState.Unknown -> { + // Display empty surface while we're waiting for the camera permission request to return a result + } + UiPermissionState.Granted -> { + QrCodeScannerView() + } + UiPermissionState.Denied -> { + PermissionDeniedContent( + onGoToSettingsClick = { onEvent(Event.GoToSettingsClicked) }, + ) + } + UiPermissionState.Waiting -> { + // We've launched Android's app info screen and are now waiting for the user to return to our app. + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + // Once the user has returned to the app, notify the view model about it. + onEvent(Event.ReturnedFromAppInfoScreen) + } + } + } + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContract.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContract.kt new file mode 100644 index 00000000000..88c6bcef54b --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerContract.kt @@ -0,0 +1,30 @@ +package app.k9mail.feature.migration.qrcode.ui + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel + +interface QrCodeScannerContract { + interface ViewModel : UnidirectionalViewModel + + data class State( + val cameraPermissionState: UiPermissionState = UiPermissionState.Unknown, + ) + + sealed interface Event { + data object StartScreen : Event + data class CameraPermissionResult(val success: Boolean) : Event + data object GoToSettingsClicked : Event + data object ReturnedFromAppInfoScreen : Event + } + + sealed interface Effect { + data object RequestCameraPermission : Effect + data object GoToAppInfoScreen : Effect + } + + enum class UiPermissionState { + Unknown, + Granted, + Denied, + Waiting, + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreen.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreen.kt new file mode 100644 index 00000000000..deb4c3a0db1 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreen.kt @@ -0,0 +1,62 @@ +package app.k9mail.feature.migration.qrcode.ui + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import org.koin.androidx.compose.koinViewModel +import timber.log.Timber + +@Composable +fun QrCodeScannerScreen( + viewModel: QrCodeScannerContract.ViewModel = koinViewModel(), +) { + val cameraPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { success -> + viewModel.event(Event.CameraPermissionResult(success)) + } + + val context = LocalContext.current + + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.RequestCameraPermission -> cameraPermissionLauncher.requestCameraPermission() + Effect.GoToAppInfoScreen -> context.goToAppInfoScreen() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.StartScreen) + } + + QrCodeScannerContent( + state = state.value, + onEvent = dispatch, + ) +} + +private fun Context.goToAppInfoScreen() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e(e, "Error opening Android's app settings") + } +} + +private fun ManagedActivityResultLauncher.requestCameraPermission() { + launch(Manifest.permission.CAMERA) +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerView.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerView.kt new file mode 100644 index 00000000000..793089bb4ae --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerView.kt @@ -0,0 +1,14 @@ +package app.k9mail.feature.migration.qrcode.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge + +@Composable +internal fun QrCodeScannerView() { + TextBodyLarge( + text = "TODO: implement", + modifier = Modifier.testTag("QrCodeScannerView"), + ) +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModel.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModel.kt new file mode 100644 index 00000000000..fa2cd8c4f57 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModel.kt @@ -0,0 +1,63 @@ +package app.k9mail.feature.migration.qrcode.ui + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class QrCodeScannerViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), QrCodeScannerContract.ViewModel { + + override fun event(event: Event) { + when (event) { + Event.StartScreen -> handleOneTimeEvent(event, ::handleStartScreen) + is Event.CameraPermissionResult -> handleCameraPermissionResult(event.success) + Event.GoToSettingsClicked -> handleGoToSettingsClicked() + Event.ReturnedFromAppInfoScreen -> handleReturnedFromAndroidSettings() + } + } + + private fun handleStartScreen() { + requestCameraPermission() + } + + private fun handleCameraPermissionResult(success: Boolean) { + updateState { + it.copy(cameraPermissionState = if (success) UiPermissionState.Granted else UiPermissionState.Denied) + } + } + + private fun handleGoToSettingsClicked() { + emitEffect(Effect.GoToAppInfoScreen) + + viewModelScope.launch { + // Delay updating the UI to make sure Android's app settings screen is active and our activity is paused. + // We want to prevent QrCodeScannerContent triggering the permission dialog again before the user had a + // chance to enter Android's app settings screen. + delay(APP_SETTINGS_DELAY) + + updateState { + it.copy(cameraPermissionState = UiPermissionState.Waiting) + } + } + } + + private fun handleReturnedFromAndroidSettings() { + updateState { + it.copy(cameraPermissionState = UiPermissionState.Unknown) + } + + requestCameraPermission() + } + + private fun requestCameraPermission() { + emitEffect(Effect.RequestCameraPermission) + } +} + +private const val APP_SETTINGS_DELAY = 100L diff --git a/feature/migration/qrcode/src/main/res/values/strings.xml b/feature/migration/qrcode/src/main/res/values/strings.xml new file mode 100644 index 00000000000..781f13d6e42 --- /dev/null +++ b/feature/migration/qrcode/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Camera access needed + Go to Android settings, tap permissions, and allow access to the camera. + Go to settings + diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/FakeQrCodeScannerViewModel.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/FakeQrCodeScannerViewModel.kt new file mode 100644 index 00000000000..9f71a39c118 --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/FakeQrCodeScannerViewModel.kt @@ -0,0 +1,10 @@ +package app.k9mail.feature.migration.qrcode.ui + +import app.k9mail.core.ui.compose.testing.BaseFakeViewModel +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State + +class FakeQrCodeScannerViewModel( + initialState: State = State(), +) : BaseFakeViewModel(initialState), QrCodeScannerContract.ViewModel diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenKtTest.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenKtTest.kt new file mode 100644 index 00000000000..589db5721e4 --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerScreenKtTest.kt @@ -0,0 +1,133 @@ +package app.k9mail.feature.migration.qrcode.ui + +import android.Manifest +import android.app.Application +import android.content.Context +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.theme2.k9mail.K9MailTheme2 +import app.k9mail.feature.migration.qrcode.BuildConfig +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import kotlinx.coroutines.test.runTest +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivity + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class QrCodeScannerScreenKtTest { + init { + // Running this test class in the release configuration fails with the following error message: + // Unable to resolve activity for Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] + // cmp=app.k9mail.feature.migration.qrcode/androidx.activity.ComponentActivity } -- see + // https://github.com/robolectric/robolectric/pull/4736 for details + + // So we make sure this test class is only run in the debug configuration. + assumeTrue(BuildConfig.DEBUG) + } + + @get:Rule + val composeTestRule = createComposeRule() + + private val viewModel = FakeQrCodeScannerViewModel() + + @Test + fun `starting screen should emit StartScreen event`() = runTest { + setContentWithTheme { + QrCodeScannerScreen(viewModel) + } + + assertThat(viewModel.events).containsExactly(Event.StartScreen) + } + + @Test + fun `RequestCameraPermission effect should request CAMERA permission`() = runTest { + lateinit var context: Context + setContentWithTheme { + context = LocalContext.current + QrCodeScannerScreen(viewModel) + } + + viewModel.effect(Effect.RequestCameraPermission) + + val shadowActivity = Shadow.extract(context) + assertThat(shadowActivity.lastRequestedPermission?.requestedPermissions?.toList()) + .isNotNull() + .containsExactly(Manifest.permission.CAMERA) + } + + @Test + fun `GoToAppInfoScreen effect should launch intent`() = runTest { + lateinit var context: Context + setContentWithTheme { + context = LocalContext.current + QrCodeScannerScreen(viewModel) + } + + viewModel.effect(Effect.GoToAppInfoScreen) + + val shadowActivity = Shadow.extract(context) + assertThat(shadowActivity.nextStartedActivity?.action) + .isNotNull() + .isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + } + + @Test + fun `UiPermissionState_Granted should show QrCodeScannerView`() = runTest { + setContentWithTheme { + QrCodeScannerScreen(viewModel) + } + + viewModel.applyState(State(cameraPermissionState = UiPermissionState.Granted)) + + composeTestRule.onNodeWithTag("QrCodeScannerView").assertExists() + } + + @Test + fun `UiPermissionState_Denied should show PermissionDeniedContent`() = runTest { + setContentWithTheme { + QrCodeScannerScreen(viewModel) + } + + viewModel.applyState(State(cameraPermissionState = UiPermissionState.Denied)) + + composeTestRule.onNodeWithTag("PermissionDeniedContent").assertExists() + } + + @Test + fun `pressing 'go to settings' button should send GoToSettingsClicked event`() = runTest { + setContentWithTheme { + QrCodeScannerScreen(viewModel) + } + viewModel.events.clear() + viewModel.applyState(State(cameraPermissionState = UiPermissionState.Denied)) + + composeTestRule.onNodeWithTag("GoToSettingsButton").performClick() + + assertThat(viewModel.events).containsExactly(Event.GoToSettingsClicked) + } + + private fun setContentWithTheme(content: @Composable () -> Unit) { + composeTestRule.setContent { + K9MailTheme2 { + content() + } + } + } +} diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModelTest.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModelTest.kt new file mode 100644 index 00000000000..bcc0e41d270 --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/ui/QrCodeScannerViewModelTest.kt @@ -0,0 +1,121 @@ +package app.k9mail.feature.migration.qrcode.ui + +import app.k9mail.core.ui.compose.testing.MainDispatcherRule +import app.k9mail.core.ui.compose.testing.mvi.MviTurbines +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State +import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class QrCodeScannerViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `user grants camera permission`() = runTest { + with(QrCodeScannerScreenRobot(testScope = this)) { + startScreen() + userGrantsCameraPermission() + + ensureThatAllEventsAreConsumed() + } + } + + @Test + fun `user denies camera permission`() = runTest { + with(QrCodeScannerScreenRobot(testScope = this)) { + startScreen() + userDeniesCameraPermission() + + ensureThatAllEventsAreConsumed() + } + } + + @Test + fun `user grants camera permission via android app settings`() = runTest { + with(QrCodeScannerScreenRobot(testScope = this)) { + startScreen() + systemDeniesCameraPermission() + + userClicksGoToSettings() + userReturnsFromAppInfoScreen() + systemGrantsCameraPermission() + + ensureThatAllEventsAreConsumed() + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +private class QrCodeScannerScreenRobot( + private val testScope: TestScope, +) { + private val viewModel = QrCodeScannerViewModel() + private lateinit var turbines: MviTurbines + + private val initialState = State() + + suspend fun startScreen() { + turbines = testScope.turbinesWithInitialStateCheck(viewModel, initialState) + + viewModel.event(Event.StartScreen) + + assertThat(turbines.awaitEffectItem()).isEqualTo(Effect.RequestCameraPermission) + } + + suspend fun userGrantsCameraPermission() { + grantCameraPermission() + } + + suspend fun systemGrantsCameraPermission() { + grantCameraPermission() + } + + private suspend fun grantCameraPermission() { + viewModel.event(Event.CameraPermissionResult(success = true)) + + assertThat(turbines.awaitStateItem()).isEqualTo(State(cameraPermissionState = UiPermissionState.Granted)) + } + + suspend fun userDeniesCameraPermission() { + denyCameraPermission() + } + + suspend fun systemDeniesCameraPermission() { + denyCameraPermission() + } + + private suspend fun denyCameraPermission() { + viewModel.event(Event.CameraPermissionResult(success = false)) + + assertThat(turbines.awaitStateItem()).isEqualTo(State(cameraPermissionState = UiPermissionState.Denied)) + } + + suspend fun userClicksGoToSettings() { + viewModel.event(Event.GoToSettingsClicked) + + assertThat(turbines.awaitEffectItem()).isEqualTo(Effect.GoToAppInfoScreen) + assertThat(turbines.awaitStateItem()).isEqualTo(State(cameraPermissionState = UiPermissionState.Waiting)) + } + + suspend fun userReturnsFromAppInfoScreen() { + viewModel.event(Event.ReturnedFromAppInfoScreen) + + assertThat(turbines.awaitStateItem()).isEqualTo(State(cameraPermissionState = UiPermissionState.Unknown)) + assertThat(turbines.awaitEffectItem()).isEqualTo(Effect.RequestCameraPermission) + } + + fun ensureThatAllEventsAreConsumed() { + turbines.effectTurbine.ensureAllEventsConsumed() + turbines.stateTurbine.ensureAllEventsConsumed() + } +}