From bcc7e6756ea81d2a1ee4ec790f0336503deb80b8 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Wed, 30 Oct 2024 11:12:47 -0500 Subject: [PATCH] BITAU-84 Show Snackbar the first time a user syncs accounts (#259) --- .../repository/AuthenticatorRepository.kt | 6 ++ .../repository/AuthenticatorRepositoryImpl.kt | 41 +++++++++++ .../di/AuthenticatorRepositoryModule.kt | 3 + .../datasource/disk/BaseDiskSource.kt | 14 +++- .../datasource/disk/SettingsDiskSource.kt | 5 ++ .../datasource/disk/SettingsDiskSourceImpl.kt | 14 ++++ .../platform/repository/SettingsRepository.kt | 5 ++ .../repository/SettingsRepositoryImpl.kt | 2 + .../itemlisting/FirstTimeSyncSnackbarHost.kt | 69 +++++++++++++++++++ .../feature/itemlisting/ItemListingScreen.kt | 14 +++- .../itemlisting/ItemListingViewModel.kt | 24 +++++++ app/src/main/res/values/colors.xml | 2 +- app/src/main/res/values/strings.xml | 1 + .../repository/AuthenticatorRepositoryTest.kt | 53 +++++++++++++- .../datasource/disk/SettingDiskSourceTest.kt | 21 ++++++ .../repository/SettingsRepositoryTest.kt | 18 +++++ .../itemlisting/ItemListViewModelTest.kt | 14 ++++ .../itemlisting/ItemListingScreenTest.kt | 29 +++++++- 18 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt index 451f72598..80db0f401 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt @@ -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 } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt index 42b238550..be6f1a18f 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt @@ -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() + private val firstTimeAccountSyncChannel: Channel = + Channel(capacity = Channel.UNLIMITED) + override val totpCodeFlow: Flow 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> = @@ -284,6 +296,9 @@ class AuthenticatorRepositoryImpl @Inject constructor( onFailure = { ImportDataResult.Error() }, ) + override val firstTimeAccountSyncFlow: Flow + 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 + } + } + } + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt index 2e09b6ae3..8ea96f15e 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt index e925eadd6..d5d7b6323 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/BaseDiskSource.kt @@ -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?, + ): Unit = sharedPreferences.edit { + putStringSet(key, value) + } + + protected fun getStringSet( + key: String, + default: Set?, + ): Set? = sharedPreferences.getStringSet(key, default) + @Suppress("UndocumentedPublicClass") companion object { const val BASE_KEY: String = "bwPreferencesStorage" diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt index bcac29bf4..9780ecc41 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -40,6 +40,11 @@ interface SettingsDiskSource { */ var hasSeenWelcomeTutorial: Boolean + /** + * A set of Bitwarden account IDs that have previously been synced. + */ + var previouslySyncedBitwardenAccountIds: Set + /** * Emits update that track [hasSeenWelcomeTutorial] */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 83092e36f..7f434f691 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -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 + 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 get() = mutableFirstLaunchFlow.onSubscription { emit(hasSeenWelcomeTutorial) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index e5f6e53c1..5d0b1ac1c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -62,6 +62,11 @@ interface SettingsRepository { */ var isScreenCaptureAllowed: Boolean + /** + * A set of Bitwarden account IDs that have previously been synced. + */ + var previouslySyncedBitwardenAccountIds: Set + /** * Whether or not screen capture is allowed for the current user. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 94f5f6c15..2c979dd57 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -89,6 +89,8 @@ class SettingsRepositoryImpl( isScreenCaptureAllowed = value, ) } + override var previouslySyncedBitwardenAccountIds: Set by + settingsDiskSource::previouslySyncedBitwardenAccountIds override val isScreenCaptureAllowedStateFlow: StateFlow get() = settingsDiskSource.getScreenCaptureAllowedFlow() diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt new file mode 100644 index 000000000..a832e9468 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/FirstTimeSyncSnackbarHost.kt @@ -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), + ) + } + } + }, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index 4dca21b75..b38f8b064 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index c92e67077..07dbc0b87 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -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() } /** diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 988ee374f..88a112415 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -30,7 +30,7 @@ @color/grey_45464F @color/grey_757780 @color/white_C5C6D0 - @color/grey_303034 + @color/blue_020F66 @color/grey_F2F0F4 @color/blue_B2C5FF @color/black_000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33174abf2..57447fa51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -144,4 +144,5 @@ Verification code created Save this authenticator key here, or add it to a login in your Bitwarden app. Save option as default + Account synced from Bitwarden app diff --git a/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt index 8cb19e14d..21e07a84b 100644 --- a/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryTest.kt @@ -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 { 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>() + val sharedAccounts = emptyList() val authenticatorItems = mockk>() val verificationCodes = mockk>() 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() + } + } } diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt index b6cacdc38..1d30308dd 100644 --- a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt @@ -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, + ) + } } diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt index 46062149d..8f21ceafb 100644 --- a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -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(), + 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") } + } } diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt index 16033a11f..21f392d88 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt @@ -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.Loading) private val mutableSharedCodesFlow = MutableStateFlow(SharedVerificationCodesState.Loading) + private val firstTimeAccountSyncChannel: Channel = + 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, diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt index 0e6136006..cf7d21560 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt @@ -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