Skip to content

Commit

Permalink
BITAU-84 Show Snackbar the first time a user syncs accounts (#259)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahaisting-livefront authored Oct 30, 2024
1 parent aa42e0a commit bcc7e67
Show file tree
Hide file tree
Showing 18 changed files with 328 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {

Expand All @@ -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()

Expand Down Expand Up @@ -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?>> =
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ object AuthenticatorRepositoryModule {
fileManager: FileManager,
importManager: ImportManager,
totpCodeManager: TotpCodeManager,
settingsRepository: SettingsRepository,
): AuthenticatorRepository = AuthenticatorRepositoryImpl(
authenticatorBridgeManager = authenticatorBridgeManager,
authenticatorDiskSource = authenticatorDiskSource,
Expand All @@ -40,5 +42,6 @@ object AuthenticatorRepositoryModule {
fileManager = fileManager,
importManager = importManager,
totpCodeManager = totpCodeManager,
settingRepository = settingsRepository,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class SettingsRepositoryImpl(
isScreenCaptureAllowed = value,
)
}
override var previouslySyncedBitwardenAccountIds: Set<String> by
settingsDiskSource::previouslySyncedBitwardenAccountIds

override val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
get() = settingsDiskSource.getScreenCaptureAllowedFlow()
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,6 +106,7 @@ fun ItemListingScreen(
shouldShowPermissionDialog = true
}
}
val snackbarHostState = remember { SnackbarHostState() }

EventsEffect(viewModel = viewModel) { event ->
when (event) {
Expand Down Expand Up @@ -139,6 +141,11 @@ fun ItemListingScreen(
ItemListingEvent.NavigateToBitwardenSettings -> {
intentManager.startMainBitwardenAppAccountSettings()
}

is ItemListingEvent.ShowFirstTimeSyncSnackbar -> {
// Message property is overridden by FirstTimeSyncSnackbarHost:
snackbarHostState.showSnackbar("")
}
}
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -340,6 +348,7 @@ private fun ItemListingDialogs(
@Composable
private fun ItemListingContent(
state: ItemListingState.ViewState.Content,
snackbarHostState: SnackbarHostState,
scrollBehavior: TopAppBarScrollBehavior,
onNavigateToSearch: () -> Unit,
onScanQrCodeClick: () -> Unit,
Expand Down Expand Up @@ -406,6 +415,7 @@ private fun ItemListingContent(
)
},
floatingActionButtonPosition = FabPosition.EndOverlay,
snackbarHost = { FirstTimeSyncSnackbarHost(state = snackbarHostState) },
) { paddingValues ->
Column(
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}

/**
Expand Down Expand Up @@ -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()
}

/**
Expand Down
Loading

0 comments on commit bcc7e67

Please sign in to comment.