BIT-1114 Add code manual entry (#523)

This commit is contained in:
Oleg Semenenko 2024-01-08 11:38:16 -06:00 committed by Álison Fernandes
parent 0ee25a3dd5
commit e863559c12
16 changed files with 747 additions and 37 deletions

View file

@ -18,6 +18,8 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
import com.x8bit.bitwarden.ui.vault.feature.addedit.vaultAddEditDestination
import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem
import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestination
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
@ -59,6 +61,9 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToQrCodeScanScreen = {
navController.navigateToQrCodeScanScreen()
},
onNavigateToManualCodeEntryScreen = {
navController.navigateToManualCodeEntryScreen()
},
onNavigateBack = { navController.popBackStack() },
)
vaultItemDestination(
@ -67,7 +72,22 @@ fun NavGraphBuilder.vaultUnlockedGraph(
navController.navigateToVaultAddEdit(VaultAddEditType.EditItem(it))
},
)
vaultQrCodeScanDestination(onNavigateBack = { navController.popBackStack() })
vaultQrCodeScanDestination(
onNavigateToManualCodeEntryScreen = {
navController.popBackStack()
navController.navigateToManualCodeEntryScreen()
},
onNavigateBack = { navController.popBackStack() },
)
vaultManualCodeEntryDestination(
onNavigateToQrCodeScreen = {
navController.popBackStack()
navController.navigateToQrCodeScanScreen()
},
onNavigateBack = { navController.popBackStack() },
)
addSendDestination(onNavigateBack = { navController.popBackStack() })
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
foldersDestination(onNavigateBack = { navController.popBackStack() })

View file

@ -40,8 +40,9 @@ data class VaultAddEditArgs(
* Add the vault add & edit screen to the nav graph.
*/
fun NavGraphBuilder.vaultAddEditDestination(
onNavigateToQrCodeScanScreen: () -> Unit,
onNavigateBack: () -> Unit,
onNavigateToManualCodeEntryScreen: () -> Unit,
onNavigateToQrCodeScanScreen: () -> Unit,
) {
composableWithSlideTransitions(
route = ADD_EDIT_ITEM_ROUTE,
@ -49,7 +50,11 @@ fun NavGraphBuilder.vaultAddEditDestination(
navArgument(ADD_EDIT_ITEM_TYPE) { type = NavType.StringType },
),
) {
VaultAddEditScreen(onNavigateBack, onNavigateToQrCodeScanScreen)
VaultAddEditScreen(
onNavigateBack = onNavigateBack,
onNavigateToManualCodeEntryScreen = onNavigateToManualCodeEntryScreen,
onNavigateToQrCodeScanScreen = onNavigateToQrCodeScanScreen,
)
}
}

View file

@ -49,6 +49,7 @@ fun VaultAddEditScreen(
viewModel: VaultAddEditViewModel = hiltViewModel(),
permissionsManager: PermissionsManager =
PermissionsManagerImpl(LocalContext.current as Activity),
onNavigateToManualCodeEntryScreen: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@ -60,6 +61,10 @@ fun VaultAddEditScreen(
onNavigateToQrCodeScanScreen()
}
is VaultAddEditEvent.NavigateToManualCodeEntry -> {
onNavigateToManualCodeEntryScreen()
}
is VaultAddEditEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}

View file

@ -424,13 +424,7 @@ class VaultAddEditViewModel @Inject constructor(
if (action.isGranted) {
sendEvent(event = VaultAddEditEvent.NavigateToQrCodeScan)
} else {
// TODO Add manual QR code entry (BIT-1114)
sendEvent(
event = VaultAddEditEvent.ShowToast(
message =
"Permission Not Granted, Manual QR Code Entry Not Implemented".asText(),
),
)
sendEvent(event = VaultAddEditEvent.NavigateToManualCodeEntry)
}
}
@ -1233,6 +1227,11 @@ sealed class VaultAddEditEvent {
* Navigate to the QR code scan screen.
*/
data object NavigateToQrCodeScan : VaultAddEditEvent()
/**
* Navigate to the manual code entry screen.
*/
data object NavigateToManualCodeEntry : VaultAddEditEvent()
}
/**

View file

@ -0,0 +1,34 @@
package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val MANUAL_CODE_ENTRY_ROUTE: String = "manual_code_entry"
/**
* Add the manual code entry screen to the nav graph.
*/
fun NavGraphBuilder.vaultManualCodeEntryDestination(
onNavigateBack: () -> Unit,
onNavigateToQrCodeScreen: () -> Unit,
) {
composableWithSlideTransitions(
route = MANUAL_CODE_ENTRY_ROUTE,
) {
ManualCodeEntryScreen(
onNavigateBack = onNavigateBack,
onNavigateToQrCodeScreen = onNavigateToQrCodeScreen,
)
}
}
/**
* Navigate to the manual code entry screen.
*/
fun NavController.navigateToManualCodeEntryScreen(
navOptions: NavOptions? = null,
) {
this.navigate(MANUAL_CODE_ENTRY_ROUTE, navOptions)
}

View file

@ -0,0 +1,203 @@
package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManager
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManagerImpl
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
/**
* The screen to manually add a totp code.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ManualCodeEntryScreen(
onNavigateBack: () -> Unit,
onNavigateToQrCodeScreen: () -> Unit,
viewModel: ManualCodeEntryViewModel = hiltViewModel(),
intentHandler: IntentHandler = IntentHandler(LocalContext.current),
permissionsManager: PermissionsManager =
PermissionsManagerImpl(LocalContext.current as Activity),
) {
var shouldShowPermissionDialog by rememberSaveable { mutableStateOf(false) }
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val launcher = permissionsManager.getLauncher { isGranted ->
if (isGranted) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
} else {
shouldShowPermissionDialog = true
}
}
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is ManualCodeEntryEvent.NavigateToAppSettings -> {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:" + context.packageName)
intentHandler.startActivity(intent = intent)
}
is ManualCodeEntryEvent.ShowToast -> {
Toast
.makeText(context, event.message.invoke(context.resources), Toast.LENGTH_SHORT)
.show()
}
is ManualCodeEntryEvent.NavigateToQrCodeScreen -> {
onNavigateToQrCodeScreen.invoke()
}
is ManualCodeEntryEvent.NavigateBack -> {
onNavigateBack.invoke()
}
}
}
if (shouldShowPermissionDialog) {
BitwardenTwoButtonDialog(
message = stringResource(id = R.string.enable_camer_permission_to_use_the_scanner),
confirmButtonText = stringResource(id = R.string.settings),
dismissButtonText = stringResource(id = R.string.no_thanks),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.SettingsClick) }
},
onDismissClick = { shouldShowPermissionDialog = false },
onDismissRequest = { shouldShowPermissionDialog = false },
title = null,
)
}
BitwardenScaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.authenticator_key_scanner),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.CloseClick) }
},
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
)
},
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
Text(
text = stringResource(id = R.string.enter_key_manually),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
singleLine = false,
label = stringResource(id = R.string.authenticator_key_scanner),
value = state.code,
onValueChange = remember(viewModel) {
{
viewModel.trySendAction(
ManualCodeEntryAction.CodeTextChange(it),
)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledTonalButton(
label = stringResource(id = R.string.add_totp),
onClick = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Text(
text = stringResource(id = R.string.once_the_key_is_successfully_entered),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(
vertical = 16.dp,
horizontal = 16.dp,
),
)
Text(
text = stringResource(id = R.string.cannot_add_authenticator_key),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(
vertical = 8.dp,
horizontal = 16.dp,
),
)
ClickableText(
text = stringResource(id = R.string.scan_qr_code).toAnnotatedString(),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier
.padding(horizontal = 16.dp),
onClick = remember(viewModel) {
{
if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
} else {
launcher.launch(Manifest.permission.CAMERA)
}
}
},
)
}
}
}

View file

@ -0,0 +1,124 @@
package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* The ViewModel for handling user interactions in the manual code entry screen.
*
*/
@HiltViewModel
class ManualCodeEntryViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
) : BaseViewModel<ManualCodeEntryState, ManualCodeEntryEvent, ManualCodeEntryAction>(
initialState = savedStateHandle[KEY_STATE]
?: ManualCodeEntryState(code = ""),
) {
override fun handleAction(action: ManualCodeEntryAction) {
when (action) {
is ManualCodeEntryAction.CloseClick -> handleCloseClick()
is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action)
is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit()
is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick()
is ManualCodeEntryAction.SettingsClick -> handleSettingsClick()
}
}
private fun handleCloseClick() {
sendEvent(ManualCodeEntryEvent.NavigateBack)
}
private fun handleCodeTextChange(action: ManualCodeEntryAction.CodeTextChange) {
mutableStateFlow.update {
it.copy(code = action.code)
}
}
private fun handleCodeSubmit() {
vaultRepository.emitTotpCode(state.code)
sendEvent(ManualCodeEntryEvent.NavigateBack)
}
private fun handleScanQrCodeTextClick() {
sendEvent(ManualCodeEntryEvent.NavigateToQrCodeScreen)
}
private fun handleSettingsClick() {
sendEvent(ManualCodeEntryEvent.NavigateToAppSettings)
}
}
/**
* Models state of the manual entry screen.
*/
@Parcelize
data class ManualCodeEntryState(
val code: String,
) : Parcelable
/**
* Models events for the [ManualCodeEntryScreen].
*/
sealed class ManualCodeEntryEvent {
/**
* Navigate back.
*/
data object NavigateBack : ManualCodeEntryEvent()
/**
* Navigate to the Qr code screen.
*/
data object NavigateToQrCodeScreen : ManualCodeEntryEvent()
/**
* Navigate to the app settings.
*/
data object NavigateToAppSettings : ManualCodeEntryEvent()
/**
* Show a toast with the given [message].
*/
data class ShowToast(val message: Text) : ManualCodeEntryEvent()
}
/**
* Models actions for the [ManualCodeEntryScreen].
*/
sealed class ManualCodeEntryAction {
/**
* User clicked close.
*/
data object CloseClick : ManualCodeEntryAction()
/**
* The user has submitted a code.
*/
data object CodeSubmit : ManualCodeEntryAction()
/**
* The user has changed the code text.
*/
data class CodeTextChange(val code: String) : ManualCodeEntryAction()
/**
* The text to switch to QR code scanning is clicked.
*/
data object ScanQrCodeTextClick : ManualCodeEntryAction()
/**
* The action for the user clicking the settings button.
*/
data object SettingsClick : ManualCodeEntryAction()
}

View file

@ -12,11 +12,15 @@ private const val QR_CODE_SCAN_ROUTE: String = "qr_code_scan"
*/
fun NavGraphBuilder.vaultQrCodeScanDestination(
onNavigateBack: () -> Unit,
onNavigateToManualCodeEntryScreen: () -> Unit,
) {
composableWithSlideTransitions(
route = QR_CODE_SCAN_ROUTE,
) {
QrCodeScanScreen(onNavigateBack)
QrCodeScanScreen(
onNavigateToManualCodeEntryScreen = onNavigateToManualCodeEntryScreen,
onNavigateBack = onNavigateBack,
)
}
}

View file

@ -76,6 +76,7 @@ fun QrCodeScanScreen(
onNavigateBack: () -> Unit,
viewModel: QrCodeScanViewModel = hiltViewModel(),
qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(),
onNavigateToManualCodeEntryScreen: () -> Unit,
) {
qrCodeAnalyzer.onQrCodeScanned = remember(viewModel) {
{ viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(it)) }
@ -100,6 +101,10 @@ fun QrCodeScanScreen(
is QrCodeScanEvent.NavigateBack -> {
onNavigateBack.invoke()
}
is QrCodeScanEvent.NavigateToManualCodeEntry -> {
onNavigateToManualCodeEntryScreen.invoke()
}
}
}

View file

@ -3,7 +3,6 @@ package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -21,8 +20,8 @@ class QrCodeScanViewModel @Inject constructor(
when (action) {
is QrCodeScanAction.CloseClick -> handleCloseClick()
is QrCodeScanAction.ManualEntryTextClick -> handleManualEntryTextClick()
is QrCodeScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive()
is QrCodeScanAction.QrCodeScanReceive -> handleQrCodeScanReceive(action)
is QrCodeScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive(action)
}
}
@ -33,11 +32,8 @@ class QrCodeScanViewModel @Inject constructor(
}
private fun handleManualEntryTextClick() {
// TODO: Implement Manual Entry Screen (BIT-1114)
sendEvent(
QrCodeScanEvent.ShowToast(
message = "Not yet implemented.".asText(),
),
QrCodeScanEvent.NavigateToManualCodeEntry,
)
}
@ -46,14 +42,9 @@ class QrCodeScanViewModel @Inject constructor(
sendEvent(QrCodeScanEvent.NavigateBack)
}
private fun handleCameraErrorReceive(
action: QrCodeScanAction.CameraSetupErrorReceive,
) {
// TODO: Implement Manual Entry Screen (BIT-1114)
private fun handleCameraErrorReceive() {
sendEvent(
QrCodeScanEvent.ShowToast(
message = "Not yet implemented.".asText(),
),
QrCodeScanEvent.NavigateToManualCodeEntry,
)
}
}
@ -68,6 +59,11 @@ sealed class QrCodeScanEvent {
*/
data object NavigateBack : QrCodeScanEvent()
/**
* Navigate to manual code entry screen.
*/
data object NavigateToManualCodeEntry : QrCodeScanEvent()
/**
* Show a toast with the given [message].
*/

View file

@ -54,6 +54,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateQrCodeScanScreenCalled = false
private var onNavigateToManualCodeEntryScreenCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<VaultAddEditEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN)
@ -69,12 +70,15 @@ class VaultAddEditScreenTest : BaseComposeTest() {
fun setup() {
composeTestRule.setContent {
VaultAddEditScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
permissionsManager = fakePermissionManager,
onNavigateToQrCodeScanScreen = {
onNavigateQrCodeScanScreenCalled = true
},
onNavigateToManualCodeEntryScreen = {
onNavigateToManualCodeEntryScreenCalled = true
},
viewModel = viewModel,
permissionsManager = fakePermissionManager,
)
}
}
@ -92,6 +96,13 @@ class VaultAddEditScreenTest : BaseComposeTest() {
assertTrue(onNavigateQrCodeScanScreenCalled)
}
@Suppress("MaxLineLength")
@Test
fun `on NavigateToManualCodeEntry event should invoke NavigateToManualCodeEntry`() {
mutableEventFlow.tryEmit(VaultAddEditEvent.NavigateToManualCodeEntry)
assertTrue(onNavigateToManualCodeEntryScreenCalled)
}
@Test
fun `clicking close button should send CloseClick action`() {
composeTestRule
@ -1429,6 +1440,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
.onNodeWithTextAfterScroll(text = "Security code")
.assertTextContains("123")
}
@Test
fun `clicking New Custom Field button should allow creation of Linked type`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN

View file

@ -558,7 +558,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `SetupTotpClick should emit ShowToast with permission not granted when isGranted is false`() =
fun `SetupTotpClick should emit NavigateToManualCodeEntry when isGranted is false`() =
runTest {
val viewModel = createAddVaultItemViewModel()
@ -568,10 +568,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
isGranted = false,
),
)
assertEquals(
VaultAddEditEvent.ShowToast("Permission Not Granted, Manual QR Code Entry Not Implemented".asText()),
awaitItem(),
)
assertEquals(VaultAddEditEvent.NavigateToManualCodeEntry, awaitItem())
}
}

View file

@ -0,0 +1,204 @@
package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.FakePermissionManager
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class ManualCodeEntryScreenTests : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToScanQrCodeCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<ManualCodeEntryEvent>()
private val mutableStateFlow =
MutableStateFlow(ManualCodeEntryState(""))
private val fakePermissionManager: FakePermissionManager = FakePermissionManager()
private val intentHandler = mockk<IntentHandler>(relaxed = true)
private val viewModel = mockk<ManualCodeEntryViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
ManualCodeEntryScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
onNavigateToQrCodeScreen = {
onNavigateToScanQrCodeCalled = true
},
permissionsManager = fakePermissionManager,
intentHandler = intentHandler,
)
}
}
@Test
fun `on NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(ManualCodeEntryEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on NavigateToScanQrCode event should invoke NavigateToScanQrCode`() {
mutableEventFlow.tryEmit(ManualCodeEntryEvent.NavigateToQrCodeScreen)
assertTrue(onNavigateToScanQrCodeCalled)
}
@Test
fun `on NavigateToAppSettings event should invoke intent handler`() {
mutableEventFlow.tryEmit(ManualCodeEntryEvent.NavigateToAppSettings)
val uri = Uri.parse(
"package:" +
ApplicationProvider
.getApplicationContext<Application>()
.packageName,
)
val intentSlot = slot<Intent>()
verify { intentHandler.startActivity(capture(intentSlot)) }
assertEquals(
uri,
intentSlot.captured.data,
)
}
@Suppress("MaxLineLength")
@Test
fun `clicking on manual text should send ScanQrCodeTextClick if camera permission is granted`() {
fakePermissionManager.checkPermissionResult = true
composeTestRule
.onNodeWithText("Scan QR Code")
.performClick()
verify {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
}
}
@Test
fun `dialog should be dismissed on dismiss click in settings dialog`() {
fakePermissionManager.checkPermissionResult = false
composeTestRule
.onNodeWithText("Scan QR Code")
.performClick()
composeTestRule
.onAllNodesWithText("Enable camera permission to use the scanner")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("No thanks")
.performClick()
composeTestRule
.onAllNodesWithText("Enable camera permission to use the scanner")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsNotDisplayed()
}
@Test
fun `settings dialog should call SettingsClick action on confirm click`() {
fakePermissionManager.checkPermissionResult = false
composeTestRule
.onNodeWithText("Scan QR Code")
.performClick()
composeTestRule
.onAllNodesWithText("Enable camera permission to use the scanner")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Settings")
.performClick()
verify {
viewModel.trySendAction(ManualCodeEntryAction.SettingsClick)
}
}
@Test
fun `CodeTextChanged will be sent when text for code is updated`() {
composeTestRule
.onAllNodesWithText(text = "Authenticator key")
.onFirst()
.assertTextEquals("Authenticator key", "")
composeTestRule
.onAllNodesWithText(text = "Authenticator key")
.onFirst()
.performTextInput(text = "TestCode")
verify {
viewModel.trySendAction(
ManualCodeEntryAction.CodeTextChange("TestCode"),
)
}
}
@Test
fun `Authenticator key text should display the text provided by the state`() {
composeTestRule
.onAllNodesWithText(text = "Authenticator key")
.onFirst()
.assertTextEquals("Authenticator key", "")
mutableStateFlow.update {
it.copy(code = "TestCode")
}
composeTestRule
.onAllNodesWithText(text = "Authenticator key")
.onFirst()
.assertTextEquals("Authenticator key", "TestCode")
}
@Test
fun `clicking Add TOTP button should send CodeSubmit action`() {
composeTestRule
.onNodeWithText(text = "Add TOTP")
.performClick()
verify {
viewModel.trySendAction(
ManualCodeEntryAction.CodeSubmit,
)
}
}
}

View file

@ -0,0 +1,92 @@
package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ManualCodeEntryViewModelTests : BaseViewModelTest() {
private val totpTestCodeFlow: Flow<String> = bufferedMutableSharedFlow()
private val vaultRepository: VaultRepository = mockk {
every { totpCodeFlow } returns totpTestCodeFlow
every { emitTotpCode(any()) } just runs
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel(initialState = ManualCodeEntryState(""))
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(ManualCodeEntryAction.CloseClick)
assertEquals(ManualCodeEntryEvent.NavigateBack, awaitItem())
}
}
@Test
fun `CodeSubmit should emit new code and NavigateBack`() = runTest {
val viewModel =
createViewModel(initialState = ManualCodeEntryState("TestCode"))
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(ManualCodeEntryAction.CodeSubmit)
verify(exactly = 1) { vaultRepository.emitTotpCode("TestCode") }
assertEquals(ManualCodeEntryEvent.NavigateBack, awaitItem())
}
}
@Test
fun `CodeTextChange should update state with new value`() = runTest {
val viewModel =
createViewModel(initialState = ManualCodeEntryState("TestCode"))
val expectedState = ManualCodeEntryState("NewCode")
viewModel.actionChannel.trySend(ManualCodeEntryAction.CodeTextChange("NewCode"))
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `SettingsClick should emit NavigateToAppSettings and update state`() = runTest {
val viewModel = createViewModel(initialState = ManualCodeEntryState(""))
val expectedState = ManualCodeEntryState("")
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(ManualCodeEntryAction.SettingsClick)
assertEquals(ManualCodeEntryEvent.NavigateToAppSettings, awaitItem())
assertEquals(expectedState, viewModel.stateFlow.value)
}
}
@Test
fun `ScanQrTextCodeClick should emit NavigateToQrCodeScreen`() = runTest {
val viewModel = createViewModel(initialState = ManualCodeEntryState(""))
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(ManualCodeEntryAction.ScanQrCodeTextClick)
assertEquals(ManualCodeEntryEvent.NavigateToQrCodeScreen, awaitItem())
}
}
private fun createViewModel(initialState: ManualCodeEntryState): ManualCodeEntryViewModel =
ManualCodeEntryViewModel(
vaultRepository = vaultRepository,
savedStateHandle = SavedStateHandle(
initialState = mapOf("state" to initialState),
),
)
}

View file

@ -17,6 +17,7 @@ import org.robolectric.annotation.Config
class QrCodeScanScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToManualCodeEntryScreenCalled = false
private val imageProxy: ImageProxy = mockk()
private val qrCodeAnalyzer = FakeQrCodeAnalyzer()
@ -31,9 +32,12 @@ class QrCodeScanScreenTest : BaseComposeTest() {
fun setup() {
composeTestRule.setContent {
QrCodeScanScreen(
qrCodeAnalyzer = qrCodeAnalyzer,
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
qrCodeAnalyzer = qrCodeAnalyzer,
onNavigateToManualCodeEntryScreen = {
onNavigateToManualCodeEntryScreenCalled = true
},
)
}
}
@ -44,6 +48,12 @@ class QrCodeScanScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
fun `on NavigateToManualCodeEntry event should invoke onNavigateToManualCodeEntryScreen`() {
mutableEventFlow.tryEmit(QrCodeScanEvent.NavigateToManualCodeEntry)
assertTrue(onNavigateToManualCodeEntryScreenCalled)
}
@Test
fun `when unable to setup camera CameraErrorReceive will be sent`() = runTest {
// Because the camera is not set up in the tests, this will always be triggered

View file

@ -4,7 +4,6 @@ import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -34,13 +33,13 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
}
@Test
fun `CameraErrorReceive should emit ShowToast`() = runTest {
fun `CameraErrorReceive should emit NavigateToManualCodeEntry`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(QrCodeScanAction.CameraSetupErrorReceive)
assertEquals(
QrCodeScanEvent.ShowToast("Not yet implemented.".asText()),
QrCodeScanEvent.NavigateToManualCodeEntry,
awaitItem(),
)
}
@ -53,7 +52,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(QrCodeScanAction.ManualEntryTextClick)
assertEquals(
QrCodeScanEvent.ShowToast("Not yet implemented.".asText()),
QrCodeScanEvent.NavigateToManualCodeEntry,
awaitItem(),
)
}