diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index c9d53fef8..370a6a19b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.other.navigateToOther import com.x8bit.bitwarden.ui.platform.feature.settings.other.otherDestination import com.x8bit.bitwarden.ui.platform.feature.settings.vault.navigateToVaultSettings import com.x8bit.bitwarden.ui.platform.feature.settings.vault.vaultSettingsDestination +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay const val SETTINGS_GRAPH_ROUTE: String = "settings_graph" private const val SETTINGS_ROUTE: String = "settings" @@ -35,7 +36,7 @@ fun NavGraphBuilder.settingsGraph( onNavigateToPendingRequests: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { navigation( startDestination = SETTINGS_ROUTE, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt index 5657d25f5..9f25be808 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt @@ -4,6 +4,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay private const val VAULT_SETTINGS_ROUTE = "vault_settings" @@ -14,7 +15,7 @@ fun NavGraphBuilder.vaultSettingsDestination( onNavigateBack: () -> Unit, onNavigateToExportVault: () -> Unit, onNavigateToFolders: () -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { composableWithPushTransitions( route = VAULT_SETTINGS_ROUTE, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt index 7ef8505ac..a8e91c748 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt @@ -33,9 +33,12 @@ import com.x8bit.bitwarden.ui.platform.components.card.actionCardExitAnimation import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay /** * Displays the vault settings screen. @@ -47,12 +50,13 @@ fun VaultSettingsScreen( onNavigateBack: () -> Unit, onNavigateToExportVault: () -> Unit, onNavigateToFolders: () -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, viewModel: VaultSettingsViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val snackbarHostState = rememberBitwardenSnackbarHostState() val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> when (event) { @@ -65,11 +69,13 @@ fun VaultSettingsScreen( is VaultSettingsEvent.NavigateToImportVault -> { if (state.isNewImportLoginsFlowEnabled) { - onNavigateToImportLogins() + onNavigateToImportLogins(SnackbarRelay.VAULT_SETTINGS_RELAY) } else { intentManager.launchUri(event.url.toUri()) } } + + is VaultSettingsEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } } @@ -89,6 +95,11 @@ fun VaultSettingsScreen( }, ) }, + snackbarHost = { + BitwardenSnackbarHost( + bitwardenHostState = snackbarHostState, + ) + }, ) { innerPadding -> Column( Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt index 07d99b15b..9b3f88e00 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt @@ -7,6 +7,10 @@ import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.toBaseWebVaultImportUrl import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -22,6 +26,7 @@ import javax.inject.Inject class VaultSettingsViewModel @Inject constructor( environmentRepository: EnvironmentRepository, featureFlagManager: FeatureFlagManager, + snackbarRelayManager: SnackbarRelayManager, private val firstTimeActionManager: FirstTimeActionManager, ) : BaseViewModel( initialState = run { @@ -53,6 +58,14 @@ class VaultSettingsViewModel @Inject constructor( } .onEach(::sendAction) .launchIn(viewModelScope) + + snackbarRelayManager + .getSnackbarDataFlow(SnackbarRelay.VAULT_SETTINGS_RELAY) + .map { + VaultSettingsAction.Internal.SnackbarDataReceived(it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: VaultSettingsAction): Unit = when (action) { @@ -74,9 +87,19 @@ class VaultSettingsViewModel @Inject constructor( is VaultSettingsAction.Internal.UserFirstTimeStateChanged -> { handleUserFirstTimeStateChanged(action) } + + is VaultSettingsAction.Internal.SnackbarDataReceived -> { + handleSnackbarDataReceived(action) + } } } + private fun handleSnackbarDataReceived( + action: VaultSettingsAction.Internal.SnackbarDataReceived, + ) { + sendEvent(VaultSettingsEvent.ShowSnackbar(action.data)) + } + private fun handleImportLoginsCardDismissClicked() { if (!state.shouldShowImportCard) return firstTimeActionManager.storeShowImportLoginsSettingsBadge(showBadge = false) @@ -166,6 +189,11 @@ sealed class VaultSettingsEvent { data class ShowToast( val message: String, ) : VaultSettingsEvent() + + /** + * Shows a snackbar with the given [data]. + */ + data class ShowSnackbar(val data: BitwardenSnackbarData) : VaultSettingsEvent(), BackgroundEvent } /** @@ -220,5 +248,10 @@ sealed class VaultSettingsAction { data class UserFirstTimeStateChanged( val showImportLoginsCard: Boolean, ) : Internal() + + /** + * Indicates that the snackbar data has been received. + */ + data class SnackbarDataReceived(val data: BitwardenSnackbarData) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 04b58e5dd..d9264faca 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -106,7 +106,9 @@ fun NavGraphBuilder.vaultUnlockedGraph( }, onNavigateToSetupUnlockScreen = { navController.navigateToSetupUnlockScreen() }, onNavigateToSetupAutoFillScreen = { navController.navigateToSetupAutoFillScreen() }, - onNavigateToImportLogins = { navController.navigateToImportLoginsScreen() }, + onNavigateToImportLogins = { + navController.navigateToImportLoginsScreen(snackbarRelay = it) + }, ) deleteAccountDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 5a028635e..af86915c2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import com.x8bit.bitwarden.ui.platform.base.util.composableWithStayTransitions import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType /** @@ -38,7 +39,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { composableWithStayTransitions( route = VAULT_UNLOCKED_NAV_BAR_ROUTE, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 4467d5686..1a3b029cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph import com.x8bit.bitwarden.ui.platform.feature.settings.settingsGraph import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders import com.x8bit.bitwarden.ui.tools.feature.generator.generatorGraph @@ -77,7 +78,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -171,7 +172,7 @@ private fun VaultUnlockedNavBarScaffold( navigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { var shouldDimNavBar by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt index 98803b644..60ccdea8f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt @@ -5,6 +5,8 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManagerImpl +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -32,4 +34,8 @@ class PlatformUiManagerModule { @Singleton fun provideResourceManager(@ApplicationContext context: Context): ResourceManager = ResourceManagerImpl(context = context) + + @Provides + @Singleton + fun provideSnackbarRelayManager(): SnackbarRelayManager = SnackbarRelayManagerImpl() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt new file mode 100644 index 000000000..a0f788488 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.ui.platform.manager.snackbar + +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData + +/** + * Models a relay key to be mapped to an instance of [BitwardenSnackbarData] being sent + * between producers and consumers of the data. + */ +enum class SnackbarRelay { + VAULT_SETTINGS_RELAY, + MY_VAULT_RELAY, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt new file mode 100644 index 000000000..cf341c3c4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.ui.platform.manager.snackbar + +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import kotlinx.coroutines.flow.Flow + +/** + * Manager responsible for relaying snackbar data between a producer and consumer who may + * communicate with reference to a specific [SnackbarRelay]. + */ +interface SnackbarRelayManager { + /** + * Called from a producer to send snackbar data to a consumer, the producer must + * specify the [relay] to send the data to. + */ + fun sendSnackbarData(data: BitwardenSnackbarData, relay: SnackbarRelay) + + /** + * Called from a consumer to receive snackbar data from a producer, the consumer must specify + * the [relay] to receive the data from. + */ + fun getSnackbarDataFlow(relay: SnackbarRelay): Flow +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt new file mode 100644 index 000000000..83cfae46f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.ui.platform.manager.snackbar + +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onCompletion + +/** + * The default implementation of the [SnackbarRelayManager] interface. + */ +class SnackbarRelayManagerImpl : SnackbarRelayManager { + private val mutableSnackbarRelayMap = + mutableMapOf>() + + override fun sendSnackbarData(data: BitwardenSnackbarData, relay: SnackbarRelay) { + getSnackbarDataFlowInternal(relay).tryEmit(data) + } + + override fun getSnackbarDataFlow(relay: SnackbarRelay): Flow = + getSnackbarDataFlowInternal(relay) + .onCompletion { + // when the subscription is ended, remove the relay from the map. + mutableSnackbarRelayMap.remove(relay) + } + .filterNotNull() + + private fun getSnackbarDataFlowInternal( + relay: SnackbarRelay, + ): MutableSharedFlow = + mutableSnackbarRelayMap.getOrPut(relay) { + bufferedMutableSharedFlow(replay = 1) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt index ba6ed19a5..a8eed9795 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt @@ -1,17 +1,39 @@ package com.x8bit.bitwarden.ui.vault.feature.importlogins +import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay -private const val IMPORT_LOGINS_ROUTE = "import-logins" +private const val IMPORT_LOGINS_PREFIX = "import-logins" +private const val IMPORT_LOGINS_NAV_ARG = "snackbarRelay" +private const val IMPORT_LOGINS_ROUTE = "$IMPORT_LOGINS_PREFIX/{$IMPORT_LOGINS_NAV_ARG}" + +/** + * Arguments for the [ImportLoginsScreen] using [SavedStateHandle]. + */ +@OmitFromCoverage +data class ImportLoginsArgs(val snackBarRelay: SnackbarRelay) { + constructor(savedStateHandle: SavedStateHandle) : this( + snackBarRelay = SnackbarRelay.valueOf( + requireNotNull(savedStateHandle[IMPORT_LOGINS_NAV_ARG]), + ), + ) +} /** * Helper function to navigate to the import logins screen. */ -fun NavController.navigateToImportLoginsScreen(navOptions: NavOptions? = null) { - navigate(route = IMPORT_LOGINS_ROUTE, navOptions = navOptions) +fun NavController.navigateToImportLoginsScreen( + snackbarRelay: SnackbarRelay, + navOptions: NavOptions? = null, +) { + navigate(route = "$IMPORT_LOGINS_PREFIX/$snackbarRelay", navOptions = navOptions) } /** @@ -22,6 +44,12 @@ fun NavGraphBuilder.importLoginsScreenDestination( ) { composableWithSlideTransitions( route = IMPORT_LOGINS_ROUTE, + arguments = listOf( + navArgument(IMPORT_LOGINS_NAV_ARG) { + type = NavType.StringType + nullable = false + }, + ), ) { ImportLoginsScreen( onNavigateBack = onNavigateBack, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt index 9c993d270..4afe42fa8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt @@ -58,6 +58,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.vault.feature.importlogins.components.ImportLoginsInstructionStep import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHandler @@ -627,6 +628,7 @@ private class ImportLoginsDialogContentPreviewProvider : isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = "vault.bitwarden.com", + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), ImportLoginsState( dialogState = ImportLoginsState.DialogState.ImportLater, @@ -634,6 +636,7 @@ private class ImportLoginsDialogContentPreviewProvider : isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = "vault.bitwarden.com", + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt index 37977e0a1..1f92a504a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.importlogins +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager @@ -10,6 +11,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult 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 com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -21,9 +25,11 @@ import javax.inject.Inject @Suppress("TooManyFunctions") @HiltViewModel class ImportLoginsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val vaultRepository: VaultRepository, private val firstTimeActionManager: FirstTimeActionManager, private val environmentRepository: EnvironmentRepository, + private val snackbarRelayManager: SnackbarRelayManager, ) : BaseViewModel( initialState = run { @@ -36,6 +42,7 @@ class ImportLoginsViewModel @Inject constructor( showBottomSheet = false, // attempt to trim the scheme of the vault url currentWebVaultUrl = vaultUrl.toUriOrNull()?.host ?: vaultUrl, + snackbarRelay = ImportLoginsArgs(savedStateHandle).snackBarRelay, ) }, ) { @@ -70,6 +77,13 @@ class ImportLoginsViewModel @Inject constructor( showBottomSheet = false, ) } + // instead of doing inline, this approach to avoid "MaxLineLength" suppression. + val snackbarData = BitwardenSnackbarData( + messageHeader = R.string.logins_imported.asText(), + message = R.string.remember_to_delete_your_imported_password_file_from_your_computer + .asText(), + ) + snackbarRelayManager.sendSnackbarData(data = snackbarData, relay = state.snackbarRelay) sendEvent(ImportLoginsEvent.NavigateBack) } @@ -213,6 +227,7 @@ data class ImportLoginsState( val isVaultSyncing: Boolean, val showBottomSheet: Boolean, val currentWebVaultUrl: String, + val snackbarRelay: SnackbarRelay, ) { /** * Dialog states for the [ImportLoginsViewModel]. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index 905ba88a1..8b4a052fa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.navigation import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListing import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestination import com.x8bit.bitwarden.ui.vault.feature.verificationcode.navigateToVerificationCodeScreen @@ -24,7 +25,7 @@ fun NavGraphBuilder.vaultGraph( onNavigateToVaultEditItemScreen: (vaultItemId: String) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { navigation( route = VAULT_GRAPH_ROUTE, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index 201ee264e..3f87d6f85 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import com.x8bit.bitwarden.ui.platform.base.util.composableWithRootPushTransitions import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType const val VAULT_ROUTE: String = "vault" @@ -21,7 +22,7 @@ fun NavGraphBuilder.vaultDestination( onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { composableWithRootPushTransitions( route = VAULT_ROUTE, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 4ab87e99a..9d165acc8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -55,6 +55,9 @@ import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHostState +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager @@ -63,6 +66,7 @@ import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType @@ -83,7 +87,7 @@ fun VaultScreen( onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, - onNavigateToImportLogins: () -> Unit, + onNavigateToImportLogins: (SnackbarRelay) -> Unit, exitManager: ExitManager = LocalExitManager.current, intentManager: IntentManager = LocalIntentManager.current, permissionsManager: PermissionsManager = LocalPermissionsManager.current, @@ -97,6 +101,7 @@ fun VaultScreen( { viewModel.trySendAction(VaultAction.RefreshPull) } }, ) + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen() @@ -124,7 +129,11 @@ fun VaultScreen( .show() } - VaultEvent.NavigateToImportLogins -> onNavigateToImportLogins() + VaultEvent.NavigateToImportLogins -> { + onNavigateToImportLogins(SnackbarRelay.MY_VAULT_RELAY) + } + + is VaultEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } } val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) } @@ -137,6 +146,7 @@ fun VaultScreen( pullToRefreshState = pullToRefreshState, vaultHandlers = vaultHandlers, onDimBottomNavBarRequest = onDimBottomNavBarRequest, + snackbarHostState = snackbarHostState, ) } @@ -173,6 +183,7 @@ private fun VaultScreenScaffold( pullToRefreshState: BitwardenPullToRefreshState, vaultHandlers: VaultHandlers, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, + snackbarHostState: BitwardenSnackbarHostState, ) { var accountMenuVisible by rememberSaveable { mutableStateOf(false) @@ -261,6 +272,11 @@ private fun VaultScreenScaffold( }, ) }, + snackbarHost = { + BitwardenSnackbarHost( + bitwardenHostState = snackbarHostState, + ) + }, floatingActionButton = { AnimatedVisibility( visible = state.viewState.hasFab && !accountMenuVisible, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 4d1e12018..526898b32 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat @@ -33,6 +34,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.hexToColor import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconRes +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -71,8 +75,9 @@ class VaultViewModel @Inject constructor( private val policyManager: PolicyManager, private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, - private val featureFlagManager: FeatureFlagManager, private val firstTimeActionManager: FirstTimeActionManager, + featureFlagManager: FeatureFlagManager, + snackbarRelayManager: SnackbarRelayManager, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) @@ -143,6 +148,14 @@ class VaultViewModel @Inject constructor( } .onEach(::sendAction) .launchIn(viewModelScope) + + snackbarRelayManager + .getSnackbarDataFlow(SnackbarRelay.MY_VAULT_RELAY) + .map { + VaultAction.Internal.SnackbarDataReceive(it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: VaultAction) { @@ -458,9 +471,15 @@ class VaultViewModel @Inject constructor( is VaultAction.Internal.ValidatePasswordResultReceive -> { handleValidatePasswordResultReceive(action) } + + is VaultAction.Internal.SnackbarDataReceive -> handleSnackbarDataReceive(action) } } + private fun handleSnackbarDataReceive(action: VaultAction.Internal.SnackbarDataReceive) { + sendEvent(VaultEvent.ShowSnackbar(action.data)) + } + private fun handleGenerateTotpResultReceive( action: VaultAction.Internal.GenerateTotpResultReceive, ) { @@ -1011,6 +1030,11 @@ sealed class VaultEvent { * Show a toast with the given [message]. */ data class ShowToast(val message: Text) : VaultEvent() + + /** + * Show a snackbar with the given [data]. + */ + data class ShowSnackbar(val data: BitwardenSnackbarData) : VaultEvent(), BackgroundEvent } /** @@ -1217,6 +1241,13 @@ sealed class VaultAction { val overflowAction: ListingItemOverflowAction.VaultAction, val result: ValidatePasswordResult, ) : Internal() + + /** + * Indicates that a snackbar data was received. + */ + data class SnackbarDataReceive( + val data: BitwardenSnackbarData, + ) : Internal() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 205b70daf..963b2c569 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1073,4 +1073,6 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreenTest.kt index d550c3a97..0d48a3516 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreenTest.kt @@ -11,7 +11,10 @@ import androidx.compose.ui.test.performScrollTo import androidx.core.net.toUri 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.asText +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -19,6 +22,7 @@ import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -56,7 +60,10 @@ class VaultSettingsScreenTest : BaseComposeTest() { onNavigateToExportVault = { onNavigateToExportVaultCalled = true }, onNavigateToFolders = { onNavigateToFoldersCalled = true }, intentManager = intentManager, - onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true }, + onNavigateToImportLogins = { + onNavigateToImportLoginsCalled = true + assertEquals(SnackbarRelay.VAULT_SETTINGS_RELAY, it) + }, ) } } @@ -213,4 +220,11 @@ class VaultSettingsScreenTest : BaseComposeTest() { viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardCtaClick) } } + + @Test + fun `when ShowSnackbar is sent snackbar should be displayed`() { + val data = BitwardenSnackbarData("message".asText()) + mutableEventFlow.tryEmit(VaultSettingsEvent.ShowSnackbar(data)) + composeTestRule.onNodeWithText("message").assertIsDisplayed() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModelTest.kt index 24b9bf354..8b6613ce7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModelTest.kt @@ -7,6 +7,10 @@ import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -34,6 +38,8 @@ class VaultSettingsViewModelTest : BaseViewModelTest() { every { storeShowImportLoginsSettingsBadge(any()) } just runs } + private val snackbarRelayManager = SnackbarRelayManagerImpl() + @Test fun `BackClick should emit NavigateBack`() = runTest { val viewModel = createViewModel() @@ -136,10 +142,24 @@ class VaultSettingsViewModelTest : BaseViewModelTest() { } } + @Test + fun `SnackbarDataReceived action should send snackbar event`() = runTest { + val viewModel = createViewModel() + val expectedSnackbarData = BitwardenSnackbarData(message = "test message".asText()) + viewModel.eventFlow.test { + snackbarRelayManager.sendSnackbarData( + data = expectedSnackbarData, + relay = SnackbarRelay.VAULT_SETTINGS_RELAY, + ) + assertEquals(VaultSettingsEvent.ShowSnackbar(expectedSnackbarData), awaitItem()) + } + } + private fun createViewModel(): VaultSettingsViewModel = VaultSettingsViewModel( environmentRepository = environmentRepository, featureFlagManager = featureFlagManager, firstTimeActionManager = firstTimeActionManager, + snackbarRelayManager = snackbarRelayManager, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt new file mode 100644 index 000000000..49e6639c2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt @@ -0,0 +1,105 @@ +package com.x8bit.bitwarden.ui.platform.manager.snackbar + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class SnackbarRelayManagerTest { + + @Test + fun `Relay is completed successfully when consumer registers first and event is sent`() = + runTest { + val relayManager = SnackbarRelayManagerImpl() + val relay = SnackbarRelay.MY_VAULT_RELAY + val expectedData = BitwardenSnackbarData(message = "Test message".asText()) + val consumer = relayManager.getSnackbarDataFlow(relay) + + consumer.test { + relayManager.sendSnackbarData(data = expectedData, relay = relay) + assertEquals( + expectedData, + awaitItem(), + ) + } + } + + @Test + fun `Relay is completed successfully when consumer registers second and event is sent`() = + runTest { + val relayManager = SnackbarRelayManagerImpl() + val relay = SnackbarRelay.MY_VAULT_RELAY + val expectedData = BitwardenSnackbarData(message = "Test message".asText()) + // producer code + relayManager.sendSnackbarData(data = expectedData, relay = relay) + relayManager.getSnackbarDataFlow(relay).test { + assertEquals( + expectedData, + awaitItem(), + ) + } + } + + @Test + fun `When relay is specified by producer only send data to that relay`() = + runTest { + val relayManager = SnackbarRelayManagerImpl() + val relay1 = SnackbarRelay.MY_VAULT_RELAY + val relay2 = SnackbarRelay.VAULT_SETTINGS_RELAY + val expectedData = BitwardenSnackbarData(message = "Test message".asText()) + turbineScope { + val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + val consumer2 = relayManager.getSnackbarDataFlow(relay2).testIn(backgroundScope) + relayManager.sendSnackbarData(data = expectedData, relay = relay1) + consumer2.expectNoEvents() + assertEquals( + expectedData, + consumer1.awaitItem(), + ) + } + } + + @Test + fun `When multiple consumers are registered to the same relay, send data to all consumers`() = + runTest { + val relayManager = SnackbarRelayManagerImpl() + val relay1 = SnackbarRelay.MY_VAULT_RELAY + val expectedData = BitwardenSnackbarData(message = "Test message".asText()) + turbineScope { + val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + relayManager.sendSnackbarData(data = expectedData, relay = relay1) + assertEquals( + expectedData, + consumer1.awaitItem(), + ) + val consumer2 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + assertEquals( + expectedData, + consumer2.awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `When multiple consumers are register to the same relay, and one is completed before the other the second consumer registers should not receive any emissions`() = + runTest { + val relayManager = SnackbarRelayManagerImpl() + val relay1 = SnackbarRelay.MY_VAULT_RELAY + val expectedData = BitwardenSnackbarData(message = "Test message".asText()) + turbineScope { + val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + relayManager.sendSnackbarData(data = expectedData, relay = relay1) + assertEquals( + expectedData, + consumer1.awaitItem(), + ) + consumer1.cancel() + val consumer2 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + consumer2.expectNoEvents() + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt index 0bf3b4825..344c1fbdc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt @@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every import io.mockk.just @@ -491,4 +492,5 @@ private val DEFAULT_STATE = ImportLoginsState( isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = "vault.bitwarden.com", + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt index c0d2ce8f9..6a03790c6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.importlogins import android.net.Uri +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager @@ -10,15 +11,18 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.runs -import io.mockk.verify import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -55,6 +59,10 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { unmockkStatic(Uri::parse) } + private val snackbarRelayManager: SnackbarRelayManagerImpl = mockk() { + coEvery { sendSnackbarData(any(), any()) } just runs + } + @Test fun `initial state is correct`() { val viewModel = createViewModel() @@ -75,6 +83,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -91,6 +100,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -112,6 +122,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -123,6 +134,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -146,6 +158,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), stateFlow.awaitItem(), ) @@ -157,6 +170,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), stateFlow.awaitItem(), ) @@ -187,6 +201,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -198,6 +213,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -239,6 +255,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -255,6 +272,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -271,6 +289,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -291,6 +310,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -312,6 +332,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = true, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -340,6 +361,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = true, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -360,6 +382,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = true, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -390,6 +413,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = true, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -400,6 +424,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), awaitItem(), ) @@ -418,6 +443,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -441,6 +467,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ), viewModel.stateFlow.value, ) @@ -451,52 +478,76 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `SuccessfulSyncAcknowledged should hide bottom sheet and send NavigateBack`() = runTest { - val viewModel = createViewModel() - viewModel.stateEventFlow(backgroundScope = backgroundScope) { stateFlow, eventFlow -> - // Initial state - assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) - viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) - assertEquals( - ImportLoginsState( - dialogState = null, - viewState = ImportLoginsState.ViewState.InitialContent, - isVaultSyncing = true, - showBottomSheet = false, - currentWebVaultUrl = DEFAULT_VAULT_URL, - ), - stateFlow.awaitItem(), + fun `SuccessfulSyncAcknowledged should hide bottom sheet and send NavigateBack event and send Snackbar data through snackbar manager`() = + runTest { + val viewModel = createViewModel() + viewModel.stateEventFlow(backgroundScope = backgroundScope) { stateFlow, eventFlow -> + // Initial state + assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) + viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = true, + showBottomSheet = false, + currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, + ), + stateFlow.awaitItem(), + ) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = false, + showBottomSheet = true, + currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, + ), + stateFlow.awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.SuccessfulSyncAcknowledged) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = false, + showBottomSheet = false, + currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, + ), + stateFlow.awaitItem(), + ) + assertEquals(ImportLoginsEvent.NavigateBack, eventFlow.awaitItem()) + } + val expectedSnackbarData = BitwardenSnackbarData( + messageHeader = R.string.logins_imported.asText(), + message = R.string.remember_to_delete_your_imported_password_file_from_your_computer + .asText(), ) - assertEquals( - ImportLoginsState( - dialogState = null, - viewState = ImportLoginsState.ViewState.InitialContent, - isVaultSyncing = false, - showBottomSheet = true, - currentWebVaultUrl = DEFAULT_VAULT_URL, - ), - stateFlow.awaitItem(), - ) - viewModel.trySendAction(ImportLoginsAction.SuccessfulSyncAcknowledged) - assertEquals( - ImportLoginsState( - dialogState = null, - viewState = ImportLoginsState.ViewState.InitialContent, - isVaultSyncing = false, - showBottomSheet = false, - currentWebVaultUrl = DEFAULT_VAULT_URL, - ), - stateFlow.awaitItem(), - ) - assertEquals(ImportLoginsEvent.NavigateBack, eventFlow.awaitItem()) + verify { + snackbarRelayManager.sendSnackbarData( + data = expectedSnackbarData, + relay = SnackbarRelay.MY_VAULT_RELAY, + ) + } } - } - private fun createViewModel(): ImportLoginsViewModel = ImportLoginsViewModel( + private fun createViewModel( + snackbarRelay: SnackbarRelay = SnackbarRelay.MY_VAULT_RELAY, + ): ImportLoginsViewModel = ImportLoginsViewModel( + savedStateHandle = SavedStateHandle( + mapOf( + "snackbarRelay" to snackbarRelay.name, + ), + ), vaultRepository = vaultRepository, firstTimeActionManager = firstTimeActionManager, environmentRepository = environmentRepository, + snackbarRelayManager = snackbarRelayManager, ) } @@ -508,4 +559,5 @@ private val DEFAULT_STATE = ImportLoginsState( isVaultSyncing = false, showBottomSheet = false, currentWebVaultUrl = DEFAULT_VAULT_URL, + snackbarRelay = SnackbarRelay.MY_VAULT_RELAY, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 399fb1632..157d01688 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -24,9 +24,11 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertNoDialogExists @@ -91,7 +93,10 @@ class VaultScreenTest : BaseComposeTest() { onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true }, onNavigateToVerificationCodeScreen = { onNavigateToVerificationCodeScreen = true }, onNavigateToSearchVault = { onNavigateToSearchScreen = true }, - onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true }, + onNavigateToImportLogins = { + onNavigateToImportLoginsCalled = true + assertEquals(SnackbarRelay.MY_VAULT_RELAY, it) + }, exitManager = exitManager, intentManager = intentManager, permissionsManager = permissionsManager, @@ -1195,6 +1200,13 @@ class VaultScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(VaultEvent.NavigateToImportLogins) assertTrue(onNavigateToImportLoginsCalled) } + + @Test + fun `when ShowSnackbar is sent snackbar should be displayed`() { + val data = BitwardenSnackbarData("message".asText()) + mutableEventFlow.tryEmit(VaultEvent.ShowSnackbar(data)) + composeTestRule.onNodeWithText("message").assertIsDisplayed() + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index a33e6f6e5..509b3bba5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -33,6 +33,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -62,6 +65,8 @@ class VaultViewModelTest : BaseViewModelTest() { ZoneOffset.UTC, ) + private val snackbarRelayManager = SnackbarRelayManagerImpl() + private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(any()) } just runs } @@ -1619,6 +1624,19 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `when SnackbarRelay flow updates, snackbar is shown`() = runTest { + val viewModel = createViewModel() + val expectedSnackbarData = BitwardenSnackbarData(message = "test message".asText()) + viewModel.eventFlow.test { + snackbarRelayManager.sendSnackbarData( + data = expectedSnackbarData, + relay = SnackbarRelay.MY_VAULT_RELAY, + ) + assertEquals(VaultEvent.ShowSnackbar(expectedSnackbarData), awaitItem()) + } + } + private fun createViewModel(): VaultViewModel = VaultViewModel( authRepository = authRepository, @@ -1630,6 +1648,7 @@ class VaultViewModelTest : BaseViewModelTest() { organizationEventManager = organizationEventManager, featureFlagManager = featureFlagManager, firstTimeActionManager = firstTimeActionManager, + snackbarRelayManager = snackbarRelayManager, ) }