mirror of
https://github.com/bitwarden/android.git
synced 2026-08-25 17:09:55 -05:00
BITAU-84 Show Snackbar the first time a user syncs accounts (#259)
This commit is contained in:
+6
@@ -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>
|
||||
}
|
||||
|
||||
+41
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -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"
|
||||
|
||||
+5
@@ -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]
|
||||
*/
|
||||
|
||||
+14
@@ -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) }
|
||||
|
||||
|
||||
+5
@@ -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.
|
||||
*/
|
||||
|
||||
+2
@@ -89,6 +89,8 @@ class SettingsRepositoryImpl(
|
||||
isScreenCaptureAllowed = value,
|
||||
)
|
||||
}
|
||||
override var previouslySyncedBitwardenAccountIds: Set<String> by
|
||||
settingsDiskSource::previouslySyncedBitwardenAccountIds
|
||||
|
||||
override val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
|
||||
get() = settingsDiskSource.getScreenCaptureAllowedFlow()
|
||||
|
||||
+69
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+12
-2
@@ -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
|
||||
|
||||
+24
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+51
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+28
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user