This commit is contained in:
Matt Gibson 2024-10-24 11:19:43 -07:00
parent 5c62290a3d
commit cee686f92e
No known key found for this signature in database
GPG key ID: 7CBCA182C13B0912
12 changed files with 159 additions and 35 deletions

View file

@ -164,6 +164,34 @@ fun <T1, T2, T3, T4, R> combineDataStates(
transform(t1t2t3Triple.first, t1t2t3Triple.second, t1t2t3Triple.third, t3)
}
/**
* Combines the [dataState1], [dataState2], [dataState3], and [dataState4] [DataState]s together
* using the provided [transform].
*
* See [combineDataStates] for details.
*
* I'm not proud of this...
*/
@OmitFromCoverage
fun <T1, T2, T3, T4, T5, R> combineDataStates(
dataState1: DataState<T1>,
dataState2: DataState<T2>,
dataState3: DataState<T3>,
dataState4: DataState<T4>,
dataState5: DataState<T5>,
transform: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5) -> R,
): DataState<R> =
dataState1
.combineDataStatesWith(dataState2) { t1, t2 -> t1 to t2 }
.combineDataStatesWith(dataState3) { t1t2Pair, t3 ->
Triple(t1t2Pair.first, t1t2Pair.second, t3)
}
.combineDataStatesWith(dataState4) { t1t2t3Triple, t4 -> t1t2t3Triple to t4 }
.combineDataStatesWith(dataState5) { t1t2t3Triplet4Pair, t5 ->
transform(t1t2t3Triplet4Pair.first.first, t1t2t3Triplet4Pair.first.second, t1t2t3Triplet4Pair.first.third, t1t2t3Triplet4Pair.second, t5)
}
/**
* Combines [dataState2] with the given [DataState] using the provided [transform].
*

View file

@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.disk
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.vault.datasource.network.model.OfflineCipherJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.coroutines.flow.Flow
@ -21,6 +21,11 @@ interface VaultDiskSource {
*/
suspend fun saveCipher(userId: String, cipher: SyncResponseJson.Cipher)
/**
* Retrieves all ciphers from the offline cache for a given [userId]
*/
fun getOfflineCiphers(userId: String): Flow<List<OfflineCipherJson>>
/**
* Retrieves all ciphers from the data source for a given [userId].
*/

View file

@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.DomainsEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.OfflineCipherEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
import com.x8bit.bitwarden.data.vault.datasource.network.model.OfflineCipherJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipher
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipherJson
@ -46,6 +47,7 @@ class VaultDiskSourceImpl(
private val dispatcherManager: DispatcherManager,
) : VaultDiskSource {
private val forceOfflineCiphersFlow = bufferedMutableSharedFlow<List<OfflineCipherJson>>()
private val forceCiphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
private val forceCollectionsFlow =
bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
@ -59,7 +61,9 @@ class VaultDiskSourceImpl(
id = cipher.id ?: "create_${UUID.randomUUID()}",
userId = userId,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher.toOfflineCipher().toOfflineCipherJson()),
cipherJson = json.encodeToString(
cipher.toOfflineCipher().toOfflineCipherJson()
),
),
),
)
@ -78,6 +82,29 @@ class VaultDiskSourceImpl(
)
}
override fun getOfflineCiphers(
userId: String,
): Flow<List<OfflineCipherJson>> =
merge(
forceOfflineCiphersFlow,
offlineCiphersDao
.getAllCiphers(userId = userId)
.map { entities ->
withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromString<OfflineCipherJson>(
string = entity.cipherJson,
)
}
}
.awaitAll()
}
},
)
override fun getCiphers(
userId: String,
): Flow<List<SyncResponseJson.Cipher>> =

View file

@ -100,6 +100,11 @@ interface CipherManager {
collectionIds: List<String>,
): ShareCipherResult
suspend fun updateOfflineCipher(
cipherId: String,
cipherView: CipherView,
): UpdateCipherResult
/**
* Attempt to update a cipher.
*/

View file

@ -208,6 +208,26 @@ class CipherManagerImpl(
)
}
override suspend fun updateOfflineCipher(
cipherId: String,
cipherView: CipherView,
): UpdateCipherResult {
val userId = activeUserId ?: return UpdateCipherResult.Error(errorMessage = null)
return vaultSdkSource.encryptCipher(
userId = userId,
cipherView = cipherView
)
.map {
vaultDiskSource.saveOfflineCipher(userId = userId, cipher = it)
UpdateCipherResult.Success
}
.fold(
onFailure = { UpdateCipherResult.Error(errorMessage = null) },
onSuccess = { it },
)
}
override suspend fun updateCipher(
cipherId: String,
cipherView: CipherView,

View file

@ -56,6 +56,13 @@ interface VaultRepository : CipherManager, VaultLockManager {
*/
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Flow that represents all ciphers stored in the offline cache for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val offlineCiphersStateFlow: StateFlow<DataState<List<CipherView>>>
/**
* Flow that represents all ciphers for the active user.
*

View file

@ -72,6 +72,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabetically
import com.x8bit.bitwarden.data.vault.repository.util.toCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend
@ -156,6 +157,9 @@ class VaultRepositoryImpl(
private val mutableSendDataStateFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading)
private val mutableOfflineCiphersStateFlow =
MutableStateFlow<DataState<List<CipherView>>>(DataState.Loading)
private val mutableCiphersStateFlow =
MutableStateFlow<DataState<List<CipherView>>>(DataState.Loading)
@ -172,18 +176,21 @@ class VaultRepositoryImpl(
override val vaultDataStateFlow: StateFlow<DataState<VaultData>> =
combine(
offlineCiphersStateFlow,
ciphersStateFlow,
foldersStateFlow,
collectionsStateFlow,
sendDataStateFlow,
) { ciphersDataState, foldersDataState, collectionsDataState, sendsDataState ->
) { offlineCiphersDataState, ciphersDataState, foldersDataState, collectionsDataState, sendsDataState ->
combineDataStates(
offlineCiphersDataState,
ciphersDataState,
foldersDataState,
collectionsDataState,
sendsDataState,
) { ciphersData, foldersData, collectionsData, sendsData ->
) { offlineCiphersData, ciphersData, foldersData, collectionsData, sendsData ->
VaultData(
offlineCipherViewList = offlineCiphersData,
cipherViewList = ciphersData,
fido2CredentialAutofillViewList = null,
folderViewList = foldersData,
@ -201,6 +208,9 @@ class VaultRepositoryImpl(
override val totpCodeFlow: Flow<TotpCodeResult>
get() = mutableTotpCodeResultFlow.asSharedFlow()
override val offlineCiphersStateFlow: StateFlow<DataState<List<CipherView>>>
get() = mutableOfflineCiphersStateFlow.asStateFlow();
override val ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
get() = mutableCiphersStateFlow.asStateFlow()
@ -234,6 +244,11 @@ class VaultRepositoryImpl(
}
.launchIn(unconfinedScope)
// Setup offline ciphers MutableStateFlow
mutableOfflineCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
observeVaultDiskOfflineCiphers(activeUserId)
}.launchIn(unconfinedScope)
// Setup ciphers MutableStateFlow
mutableCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
@ -302,6 +317,7 @@ class VaultRepositoryImpl(
}
private fun clearUnlockedData() {
mutableOfflineCiphersStateFlow.update { DataState.Loading }
mutableCiphersStateFlow.update { DataState.Loading }
mutableDomainsStateFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading }
@ -318,6 +334,7 @@ class VaultRepositoryImpl(
override fun sync() {
val userId = activeUserId ?: return
if (!syncJob.isCompleted) return
mutableOfflineCiphersStateFlow.updateToPendingOrLoading()
mutableCiphersStateFlow.updateToPendingOrLoading()
mutableDomainsStateFlow.updateToPendingOrLoading()
mutableFoldersStateFlow.updateToPendingOrLoading()
@ -926,6 +943,25 @@ class VaultRepositoryImpl(
)
}
private fun observeVaultDiskOfflineCiphers(
userId: String,
): Flow<DataState<List<CipherView>>> =
vaultDiskSource.getOfflineCiphers(userId = userId)
.onStart { mutableOfflineCiphersStateFlow.updateToPendingOrLoading() }
.map {
waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCipherList(
userId = userId,
cipherList = it.toCipherList(),
)
.fold(
onSuccess = { ciphers -> DataState.Loaded(ciphers.sortAlphabetically()) },
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.onEach { mutableOfflineCiphersStateFlow.value = it }
private fun observeVaultDiskCiphers(
userId: String,
): Flow<DataState<List<CipherView>>> =
@ -1029,6 +1065,11 @@ class VaultRepositoryImpl(
.onEach { mutableSendDataStateFlow.value = it }
private fun updateVaultStateFlowsToError(throwable: Throwable) {
mutableOfflineCiphersStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(
data = currentState.data,
)
}
mutableCiphersStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(
data = currentState.data,

View file

@ -9,6 +9,7 @@ import com.bitwarden.vault.FolderView
/**
* Represents decrypted vault data.
*
* @param offlineCipherViewList List of decrypted ciphers from offline cache.
* @param cipherViewList List of decrypted ciphers.
* @param collectionViewList List of decrypted collections.
* @param folderViewList List of decrypted folders.
@ -16,6 +17,7 @@ import com.bitwarden.vault.FolderView
* @param fido2CredentialAutofillViewList List of decrypted fido 2 credentials.
*/
data class VaultData(
val offlineCipherViewList: List<CipherView>,
val cipherViewList: List<CipherView>,
val collectionViewList: List<CollectionView>,
val folderViewList: List<FolderView>,

View file

@ -31,6 +31,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.SecureNoteTypeJso
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UriMatchTypeJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.OfflineCipher
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.util.UUID
@ -111,6 +112,12 @@ fun OfflineCipher.toOfflineCipherJson(): OfflineCipherJson =
mergeConflict = false, // TODO: Copy from the new OfflineCipher type
)
fun CipherView.toNotificationSummary(): NotificationSummary =
NotificationSummary(
title = name,
subtitle = "edited on ${revisionDate.toString()}",
)
fun OfflineCipherJson.toOfflineCipher(): OfflineCipher =
OfflineCipher(
id = if(id.startsWith("create")) null else id,
@ -136,6 +143,8 @@ fun OfflineCipherJson.toOfflineCipher(): OfflineCipher =
mergeConflict = mergeConflict
)
fun OfflineCipher.toCipher(): Cipher =
Cipher(
id = id,
@ -427,6 +436,13 @@ private fun CipherType.toNetworkCipherType(): CipherTypeJson =
fun List<SyncResponseJson.Cipher>.toEncryptedSdkCipherList(): List<Cipher> =
map { it.toEncryptedSdkCipher() }
/**
* Converts a list of [OfflineCipherJson] objects to a list of corresponding
* Bitwarden SDK [Cipher] objects.
*/
fun List<OfflineCipherJson>.toCipherList(): List<Cipher> =
map { it.toOfflineCipher().toCipher() }
/**
* Converts a [SyncResponseJson.Cipher] object to a corresponding
* Bitwarden SDK [Cipher] object.

View file

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.vault.feature.unsyncedvaultitem
import android.util.Range
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.updateTransition
@ -42,14 +41,10 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.lowercaseWithCurrentLocal
import com.x8bit.bitwarden.ui.platform.base.util.scrolledContainerBackground
import com.x8bit.bitwarden.ui.platform.base.util.toSafeOverlayColor
import com.x8bit.bitwarden.ui.platform.base.util.toUnscaledTextUnit
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLogoutConfirmationDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenRemovalConfirmationDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenBasicDialogRow
import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
@ -57,12 +52,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.scrim.BitwardenAnimatedScrim
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultViewModel
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.iconRes
import com.x8bit.bitwarden.ui.vault.feature.vault.util.iconTestTag
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.supportingTextResOrNull
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -83,19 +73,8 @@ private const val MAXIMUM_ACCOUNT_LIMIT = 5
*
* @param isVisible Whether or not this component is visible. Changing this value will animate the
* component in or out of view.
* @param accountSummaries The accounts to display in the switcher.
* @param onSwitchAccountClick A callback when an account is clicked indicating that the account
* should be switched to.
* @param onLockAccountClick A callback when an account is clicked indicating that the account
* should be locked.
* @param onLogoutAccountClick A callback when an account is clicked indicating that the account
* should be logged out.
* @param onAddAccountClick A callback when the Add Account row is clicked.
* @param onDismissRequest A callback when the component requests to be dismissed. This is triggered
* whenever the user clicks on the scrim or any of the switcher items.
* @param isAddAccountAvailable Whether or not the "Add account" button is available. Note that even
* when `true`, this button may be hidden when there are more than [MAXIMUM_ACCOUNT_LIMIT] accounts
* present.
* @param modifier A [Modifier] for the composable.
* @param topAppBarScrollBehavior Used to derive the background color of the content and keep it in
* sync with the associated app bar.
@ -104,7 +83,6 @@ private const val MAXIMUM_ACCOUNT_LIMIT = 5
@Suppress("LongMethod")
@Composable
fun NotificationCenter(
viewModel: NotificationCenterViewModel = hiltViewModel(),
isVisible: Boolean,
notificationSummaries: ImmutableList<NotificationSummary>,
onNotificationClick: (NotificationSummary) -> Unit,

View file

@ -1,8 +0,0 @@
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class NotificationCenterViewModel @Inject constructor(
): ViewModel() {}

View file

@ -4,6 +4,7 @@ import android.os.Build
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@ -24,6 +25,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.toNotificationSummary
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -553,8 +555,9 @@ class VaultViewModel @Inject constructor(
),
)
}
mutableStateFlow.update {
mutableStateFlow.update { it ->
it.copy(
notificationSummaries = vaultData.data.offlineCipherViewList.map { view -> view.toNotificationSummary()},
viewState = vaultData.data.toViewState(
baseIconUrl = state.baseIconUrl,
isIconLoadingDisabled = state.isIconLoadingDisabled,