BITAU-84 Show Snackbar the first time a user syncs accounts (#259)

This commit is contained in:
Andrew Haisting
2024-10-30 11:12:47 -05:00
committed by GitHub
parent aa42e0ad18
commit bcc7e6756e
18 changed files with 328 additions and 7 deletions
@@ -84,4 +84,10 @@ interface AuthenticatorRepository {
format: ImportFileFormat,
fileData: IntentManager.FileData,
): ImportDataResult
/**
* Flow that emits `Unit` each time an account is synced from the main Bitwarden app for
* the first time.
*/
val firstTimeAccountSyncFlow: Flow<Unit>
}
@@ -22,6 +22,7 @@ import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat
import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.data.platform.repository.util.map
@@ -31,6 +32,7 @@ import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -44,6 +46,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -66,6 +69,7 @@ class AuthenticatorRepositoryImpl @Inject constructor(
private val totpCodeManager: TotpCodeManager,
private val fileManager: FileManager,
private val importManager: ImportManager,
private val settingRepository: SettingsRepository,
dispatcherManager: DispatcherManager,
) : AuthenticatorRepository {
@@ -77,6 +81,9 @@ class AuthenticatorRepositoryImpl @Inject constructor(
private val mutableTotpCodeResultFlow =
bufferedMutableSharedFlow<TotpCodeResult>()
private val firstTimeAccountSyncChannel: Channel<Unit> =
Channel(capacity = Channel.UNLIMITED)
override val totpCodeFlow: Flow<TotpCodeResult>
get() = mutableTotpCodeResultFlow.asSharedFlow()
@@ -125,6 +132,11 @@ class AuthenticatorRepositoryImpl @Inject constructor(
mutableCiphersStateFlow.value = DataState.Loaded(it.sortAlphabetically())
}
.launchIn(unconfinedScope)
authenticatorBridgeManager
.accountSyncStateFlow
.onEach { emitFirstTimeSyncIfNeeded(it) }
.launchIn(unconfinedScope)
}
override fun getItemStateFlow(itemId: String): StateFlow<DataState<AuthenticatorItemEntity?>> =
@@ -284,6 +296,9 @@ class AuthenticatorRepositoryImpl @Inject constructor(
onFailure = { ImportDataResult.Error() },
)
override val firstTimeAccountSyncFlow: Flow<Unit>
get() = firstTimeAccountSyncChannel.receiveAsFlow()
private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult {
val headerLine =
"folder,favorite,type,name,login_uri,login_totp"
@@ -342,4 +357,30 @@ class AuthenticatorRepositoryImpl @Inject constructor(
),
favorite = false,
)
private fun emitFirstTimeSyncIfNeeded(state: AccountSyncState) {
when (state) {
AccountSyncState.AppNotInstalled,
AccountSyncState.Error,
AccountSyncState.Loading,
AccountSyncState.OsVersionNotSupported,
AccountSyncState.SyncNotEnabled,
-> Unit
is AccountSyncState.Success -> {
val previouslySyncedAccounts = settingRepository.previouslySyncedBitwardenAccountIds
val fistTimeSyncedAccounts = state
.accounts
.map { it.userId }
.filterNot { previouslySyncedAccounts.contains(it) }
// If there are fist time synced accounts, emit to the first time sync channel
// and store the new account IDs:
if (fistTimeSyncedAccounts.isNotEmpty()) {
firstTimeAccountSyncChannel.trySend(Unit)
settingRepository.previouslySyncedBitwardenAccountIds =
previouslySyncedAccounts + fistTimeSyncedAccounts
}
}
}
}
}
@@ -8,6 +8,7 @@ import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRe
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import dagger.Module
import dagger.Provides
@@ -32,6 +33,7 @@ object AuthenticatorRepositoryModule {
fileManager: FileManager,
importManager: ImportManager,
totpCodeManager: TotpCodeManager,
settingsRepository: SettingsRepository,
): AuthenticatorRepository = AuthenticatorRepositoryImpl(
authenticatorBridgeManager = authenticatorBridgeManager,
authenticatorDiskSource = authenticatorDiskSource,
@@ -40,5 +42,6 @@ object AuthenticatorRepositoryModule {
fileManager = fileManager,
importManager = importManager,
totpCodeManager = totpCodeManager,
settingRepository = settingsRepository,
)
}
@@ -6,7 +6,7 @@ import androidx.core.content.edit
/**
* Base class for simplifying interactions with [SharedPreferences].
*/
@Suppress("UnnecessaryAbstractClass")
@Suppress("UnnecessaryAbstractClass", "TooManyFunctions")
abstract class BaseDiskSource(
private val sharedPreferences: SharedPreferences,
) {
@@ -121,6 +121,18 @@ abstract class BaseDiskSource(
.forEach { sharedPreferences.edit { remove(it) } }
}
protected fun putStringSet(
key: String,
value: Set<String>?,
): Unit = sharedPreferences.edit {
putStringSet(key, value)
}
protected fun getStringSet(
key: String,
default: Set<String>?,
): Set<String>? = sharedPreferences.getStringSet(key, default)
@Suppress("UndocumentedPublicClass")
companion object {
const val BASE_KEY: String = "bwPreferencesStorage"
@@ -40,6 +40,11 @@ interface SettingsDiskSource {
*/
var hasSeenWelcomeTutorial: Boolean
/**
* A set of Bitwarden account IDs that have previously been synced.
*/
var previouslySyncedBitwardenAccountIds: Set<String>
/**
* Emits update that track [hasSeenWelcomeTutorial]
*/
@@ -22,6 +22,8 @@ private const val HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY =
"$BASE_KEY:hasUserDismissedDownloadBitwardenCard"
private const val HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY =
"$BASE_KEY:hasUserDismissedSyncWithBitwardenCard"
private const val PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY =
"$BASE_KEY:previouslySyncedBitwardenAccountIds"
private const val DEFAULT_ALERT_THRESHOLD_SECONDS = 7
/**
@@ -102,6 +104,18 @@ class SettingsDiskSourceImpl(
mutableFirstLaunchFlow.tryEmit(hasSeenWelcomeTutorial)
}
override var previouslySyncedBitwardenAccountIds: Set<String>
get() = getStringSet(
key = PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY,
default = emptySet(),
) ?: emptySet()
set(value) {
putStringSet(
key = PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY,
value = value,
)
}
override val hasSeenWelcomeTutorialFlow: Flow<Boolean>
get() = mutableFirstLaunchFlow.onSubscription { emit(hasSeenWelcomeTutorial) }
@@ -62,6 +62,11 @@ interface SettingsRepository {
*/
var isScreenCaptureAllowed: Boolean
/**
* A set of Bitwarden account IDs that have previously been synced.
*/
var previouslySyncedBitwardenAccountIds: Set<String>
/**
* Whether or not screen capture is allowed for the current user.
*/
@@ -89,6 +89,8 @@ class SettingsRepositoryImpl(
isScreenCaptureAllowed = value,
)
}
override var previouslySyncedBitwardenAccountIds: Set<String> by
settingsDiskSource::previouslySyncedBitwardenAccountIds
override val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
get() = settingsDiskSource.getScreenCaptureAllowedFlow()
@@ -0,0 +1,69 @@
package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitwarden.authenticator.R
/**
* Show a snackbar that says "Account synced from Bitwarden app" with a close action.
*
* @param state Snackbar state used to show/hide. The message and title from this state are unused.
*/
@Composable
fun FirstTimeSyncSnackbarHost(
state: SnackbarHostState,
) {
SnackbarHost(
hostState = state,
snackbar = {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.inverseSurface,
shape = RoundedCornerShape(8.dp),
)
.shadow(elevation = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.padding(16.dp)
.weight(1f, fill = true),
text = stringResource(R.string.account_synced_from_bitwarden_app),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
IconButton(
onClick = { state.currentSnackbarData?.dismiss() },
) {
Icon(
painter = painterResource(id = R.drawable.ic_close),
contentDescription = stringResource(id = R.string.close),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.size(24.dp),
)
}
}
},
)
}
@@ -24,6 +24,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
@@ -105,6 +106,7 @@ fun ItemListingScreen(
shouldShowPermissionDialog = true
}
}
val snackbarHostState = remember { SnackbarHostState() }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
@@ -139,6 +141,11 @@ fun ItemListingScreen(
ItemListingEvent.NavigateToBitwardenSettings -> {
intentManager.startMainBitwardenAppAccountSettings()
}
is ItemListingEvent.ShowFirstTimeSyncSnackbar -> {
// Message property is overridden by FirstTimeSyncSnackbarHost:
snackbarHostState.showSnackbar("")
}
}
}
@@ -177,8 +184,9 @@ fun ItemListingScreen(
when (val currentState = state.viewState) {
is ItemListingState.ViewState.Content -> {
ItemListingContent(
currentState,
scrollBehavior,
state = currentState,
snackbarHostState = snackbarHostState,
scrollBehavior = scrollBehavior,
onNavigateToSearch = remember(viewModel) {
{
viewModel.trySendAction(
@@ -340,6 +348,7 @@ private fun ItemListingDialogs(
@Composable
private fun ItemListingContent(
state: ItemListingState.ViewState.Content,
snackbarHostState: SnackbarHostState,
scrollBehavior: TopAppBarScrollBehavior,
onNavigateToSearch: () -> Unit,
onScanQrCodeClick: () -> Unit,
@@ -406,6 +415,7 @@ private fun ItemListingContent(
)
},
floatingActionButtonPosition = FabPosition.EndOverlay,
snackbarHost = { FirstTimeSyncSnackbarHost(state = snackbarHostState) },
) { paddingValues ->
Column(
modifier = Modifier
@@ -88,6 +88,12 @@ class ItemListingViewModel @Inject constructor(
.map { ItemListingAction.Internal.TotpCodeReceive(totpResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
authenticatorRepository
.firstTimeAccountSyncFlow
.map { ItemListingAction.Internal.FirstTimeUserSyncReceive }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ItemListingAction) {
@@ -249,9 +255,17 @@ class ItemListingViewModel @Inject constructor(
is ItemListingAction.Internal.AppThemeChangeReceive -> {
handleAppThemeChangeReceive(internalAction.appTheme)
}
ItemListingAction.Internal.FirstTimeUserSyncReceive -> {
handleFirstTimeUserSync()
}
}
}
private fun handleFirstTimeUserSync() {
sendEvent(ItemListingEvent.ShowFirstTimeSyncSnackbar)
}
private fun handleAppThemeChangeReceive(appTheme: AppTheme) {
mutableStateFlow.update {
it.copy(appTheme = appTheme)
@@ -790,6 +804,11 @@ sealed class ItemListingEvent {
data class ShowToast(
val message: Text,
) : ItemListingEvent()
/**
* Show a Snackbar letting the user know accounts have synced.
*/
data object ShowFirstTimeSyncSnackbar : ItemListingEvent()
}
/**
@@ -902,6 +921,11 @@ sealed class ItemListingAction {
* Indicates app theme change has been received.
*/
data class AppThemeChangeReceive(val appTheme: AppTheme) : Internal()
/**
* Indicates that a user synced with Bitwarden for the first time.
*/
data object FirstTimeUserSyncReceive : Internal()
}
/**
+1 -1
View File
@@ -30,7 +30,7 @@
<color name="on_surface_variant">@color/grey_45464F</color>
<color name="outline">@color/grey_757780</color>
<color name="outline_variant">@color/white_C5C6D0</color>
<color name="inverse_surface">@color/grey_303034</color>
<color name="inverse_surface">@color/blue_020F66</color>
<color name="inverse_on_surface">@color/grey_F2F0F4</color>
<color name="inverse_primary">@color/blue_B2C5FF</color>
<color name="scrim">@color/black_000000</color>
+1
View File
@@ -144,4 +144,5 @@
<string name="verification_code_created">Verification code created</string>
<string name="choose_save_location_message">Save this authenticator key here, or add it to a login in your Bitwarden app.</string>
<string name="save_option_as_default">Save option as default</string>
<string name="account_synced_from_bitwarden_app">Account synced from Bitwarden app</string>
</resources>
@@ -13,13 +13,16 @@ import com.bitwarden.authenticator.data.platform.base.FakeDispatcherManager
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager
import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,6 +48,9 @@ class AuthenticatorRepositoryTest {
private val mockFeatureFlagManager = mockk<FeatureFlagManager> {
every { getFeatureFlag(LocalFeatureFlag.PasswordManagerSync) } returns true
}
private val settingsRepository: SettingsRepository = mockk {
every { previouslySyncedBitwardenAccountIds } returns emptySet()
}
private val authenticatorRepository = AuthenticatorRepositoryImpl(
authenticatorDiskSource = fakeAuthenticatorDiskSource,
@@ -54,6 +60,7 @@ class AuthenticatorRepositoryTest {
fileManager = mockFileManager,
importManager = mockImportManager,
dispatcherManager = mockDispatcherManager,
settingRepository = settingsRepository,
)
@BeforeEach
@@ -89,12 +96,12 @@ class AuthenticatorRepositoryTest {
fileManager = mockFileManager,
importManager = mockImportManager,
dispatcherManager = mockDispatcherManager,
settingRepository = settingsRepository,
)
assertEquals(
SharedVerificationCodesState.FeatureNotEnabled,
repository.sharedCodesStateFlow.value,
)
verify(exactly = 0) { mockAuthenticatorBridgeManager.accountSyncStateFlow }
}
@Test
@@ -121,6 +128,7 @@ class AuthenticatorRepositoryTest {
fileManager = mockFileManager,
importManager = mockImportManager,
dispatcherManager = mockDispatcherManager,
settingRepository = settingsRepository,
)
repository.sharedCodesStateFlow.test {
assertEquals(
@@ -181,7 +189,7 @@ class AuthenticatorRepositoryTest {
@Test
fun `sharedCodesStateFlow should emit Success when authenticatorBridgeManager emits Success`() =
runTest {
val sharedAccounts = mockk<List<SharedAccountData.Account>>()
val sharedAccounts = emptyList<SharedAccountData.Account>()
val authenticatorItems = mockk<List<AuthenticatorItem>>()
val verificationCodes = mockk<List<VerificationCodeItem>>()
every { sharedAccounts.toAuthenticatorItems() } returns authenticatorItems
@@ -194,4 +202,45 @@ class AuthenticatorRepositoryTest {
assertEquals(SharedVerificationCodesState.Success(verificationCodes), awaitItem())
}
}
@Test
@Suppress("MaxLineLength")
fun `firstTimeAccountSyncFlow should emit the first time an account syncs and update SettingsRepository`() =
runTest {
every { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1") } just runs
val sharedAccounts = listOf(
SharedAccountData.Account(
userId = "1",
name = null,
email = "test@test.com",
environmentLabel = "bitwarden.com",
totpUris = emptyList(),
),
)
authenticatorRepository.firstTimeAccountSyncFlow.test {
mutableAccountSyncStateFlow.value = AccountSyncState.Success(sharedAccounts)
awaitItem()
}
verify { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1") }
}
@Test
@Suppress("MaxLineLength")
fun `firstTimeAccountSyncFlow should not emit if a synced account is already in previouslySyncedBitwardenAccountIds`() =
runTest {
every { settingsRepository.previouslySyncedBitwardenAccountIds } returns setOf("1")
val sharedAccounts = listOf(
SharedAccountData.Account(
userId = "1",
name = null,
email = "test@test.com",
environmentLabel = "bitwarden.com",
totpUris = emptyList(),
),
)
authenticatorRepository.firstTimeAccountSyncFlow.test {
mutableAccountSyncStateFlow.value = AccountSyncState.Success(sharedAccounts)
expectNoEvents()
}
}
}
@@ -95,4 +95,25 @@ class SettingDiskSourceTest {
settingDiskSource.defaultSaveOption,
)
}
@Test
fun `previouslySyncedBitwardenAccountIds should read and write from shared preferences`() {
val sharedPrefsKey = "bwPreferencesStorage:previouslySyncedBitwardenAccountIds"
// Disk source should read value from shared preferences:
sharedPreferences.edit {
putStringSet(sharedPrefsKey, setOf("a"))
}
assertEquals(
setOf("a"),
settingDiskSource.previouslySyncedBitwardenAccountIds,
)
// Updating the disk source should update shared preferences:
settingDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2")
assertEquals(
setOf("1", "2"),
settingDiskSource.previouslySyncedBitwardenAccountIds,
)
}
}
@@ -92,4 +92,22 @@ class SettingsRepositoryTest {
settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP
verify { settingsDiskSource.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP }
}
@Test
fun `previouslySyncedBitwardenAccountIds should pull from and update SettingsDiskSource`() {
// Reading from repository should read from disk source:
every { settingsDiskSource.previouslySyncedBitwardenAccountIds } returns emptySet()
assertEquals(
emptySet<String>(),
settingsRepository.previouslySyncedBitwardenAccountIds,
)
verify { settingsDiskSource.previouslySyncedBitwardenAccountIds }
// Writing to repository should write to disk source:
every {
settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
} just runs
settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
verify { settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") }
}
}
@@ -23,8 +23,10 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -38,11 +40,14 @@ class ItemListViewModelTest : BaseViewModelTest() {
MutableStateFlow<DataState<List<VerificationCodeItem>>>(DataState.Loading)
private val mutableSharedCodesFlow =
MutableStateFlow<SharedVerificationCodesState>(SharedVerificationCodesState.Loading)
private val firstTimeAccountSyncChannel: Channel<Unit> =
Channel(capacity = Channel.UNLIMITED)
private val authenticatorRepository: AuthenticatorRepository = mockk {
every { totpCodeFlow } returns emptyFlow()
every { getLocalVerificationCodesFlow() } returns mutableVerificationCodesFlow
every { sharedCodesStateFlow } returns mutableSharedCodesFlow
every { firstTimeAccountSyncFlow } returns firstTimeAccountSyncChannel.receiveAsFlow()
}
private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk()
private val clipboardManager: BitwardenClipboardManager = mockk()
@@ -409,6 +414,15 @@ class ItemListViewModelTest : BaseViewModelTest() {
verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUriString) }
}
@Test
fun `on FirstTimeUserSyncReceive should emit ShowFirstTimeSyncSnackbar`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
firstTimeAccountSyncChannel.send(Unit)
assertEquals(ItemListingEvent.ShowFirstTimeSyncSnackbar, awaitItem())
}
}
private fun createViewModel() = ItemListingViewModel(
authenticatorRepository = authenticatorRepository,
authenticatorBridgeManager = authenticatorBridgeManager,
@@ -1,13 +1,13 @@
package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.room.util.copy
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem
@@ -22,6 +22,7 @@ import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
@@ -239,6 +240,32 @@ class ItemListingScreenTest : BaseComposeTest() {
.onNodeWithText("Move to Bitwarden")
.assertDoesNotExist()
}
@Test
fun `on ShowFirstTimeSyncSnackbar receive should show snackbar`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = emptyList(),
itemList = emptyList(),
sharedItems = SharedCodesDisplayState.Codes(emptyList()),
),
)
}
// Make sure the snackbar isn't showing:
composeTestRule
.onNodeWithText("Account synced from Bitwarden app")
.assertIsNotDisplayed()
// Send ShowFirstTimeSyncSnackbar event
mutableEventFlow.tryEmit(ItemListingEvent.ShowFirstTimeSyncSnackbar)
// Make sure the snackbar is showing:
composeTestRule
.onNodeWithText("Account synced from Bitwarden app")
.assertIsDisplayed()
}
}
private val APP_THEME = AppTheme.DEFAULT