BIT-205: Populate vault with login items (#246)

This commit is contained in:
Ramsey Smith
2024-06-20 17:08:07 +01:00
committed by Álison Fernandes
parent 3e6ce662d8
commit fd10de9456
13 changed files with 688 additions and 90 deletions
@@ -8,31 +8,24 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* Used to serialize and deserialize [LocalDateTime].
*/
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
private val localDateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SS'Z'")
private val localDateTimeFormatterNanoSeconds =
private val dateTimeFormatterDeserialization = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.[SSSSSSS][SSSSSS][SSSSS][SSSS][SSS][SS][S]'Z'")
private val dateTimeFormatterSerialization =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'")
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor(serialName = "LocalDateTime", kind = PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): LocalDateTime =
decoder.decodeString().let { dateString ->
try {
LocalDateTime
.parse(dateString, localDateTimeFormatter)
} catch (exception: DateTimeParseException) {
LocalDateTime
.parse(dateString, localDateTimeFormatterNanoSeconds)
}
LocalDateTime.parse(dateString, dateTimeFormatterDeserialization)
}
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(localDateTimeFormatter.format(value))
encoder.encodeString(dateTimeFormatterSerialization.format(value))
}
}
@@ -27,7 +27,12 @@ interface VaultSdkSource {
/**
* Decrypts a list of [Cipher]s returning a list of [CipherListView] wrapped in a [Result].
*/
suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherListView>>
suspend fun decryptCipherListCollection(cipherList: List<Cipher>): Result<List<CipherListView>>
/**
* Decrypts a list of [Cipher]s returning a list of [CipherView] wrapped in a [Result].
*/
suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherView>>
/**
* Decrypts a [Folder] returning a [FolderView] wrapped in a [Result].
@@ -35,9 +35,14 @@ class VaultSdkSourceImpl(
override suspend fun decryptCipher(cipher: Cipher): Result<CipherView> =
runCatching { clientVault.ciphers().decrypt(cipher) }
override suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherListView>> =
override suspend fun decryptCipherListCollection(
cipherList: List<Cipher>,
): Result<List<CipherListView>> =
runCatching { clientVault.ciphers().decryptList(cipherList) }
override suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherView>> =
runCatching { cipherList.map { clientVault.ciphers().decrypt(it) } }
override suspend fun decryptFolder(folder: Folder): Result<FolderView> =
runCatching { clientVault.folders().decrypt(folder) }
@@ -20,6 +20,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -37,6 +41,8 @@ class VaultRepositoryImpl constructor(
private var syncJob: Job = Job().apply { complete() }
private var willSyncAfterUnlock = false
private val vaultDataMutableStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
@@ -48,7 +54,7 @@ class VaultRepositoryImpl constructor(
}
override fun sync() {
if (!syncJob.isCompleted) return
if (!syncJob.isCompleted || willSyncAfterUnlock) return
vaultDataMutableStateFlow.value.data?.let { data ->
vaultDataMutableStateFlow.update {
DataState.Pending(data = data)
@@ -86,10 +92,16 @@ class VaultRepositoryImpl constructor(
}
override suspend fun unlockVaultAndSync(masterPassword: String): VaultUnlockResult {
return initializeCrypto(masterPassword = masterPassword)
.also { vaultUnlockedResult ->
if (vaultUnlockedResult is VaultUnlockResult.Success) sync()
return flow {
willSyncAfterUnlock = true
emit(initializeCrypto(masterPassword = masterPassword))
}
.onEach {
willSyncAfterUnlock = false
if (it is VaultUnlockResult.Success) sync()
}
.onCompletion { willSyncAfterUnlock = false }
.first()
}
private fun storeUserKeyAndPrivateKey(
@@ -156,7 +168,7 @@ class VaultRepositoryImpl constructor(
onSuccess = { (decryptedCipherList, decryptedFolderList) ->
DataState.Loaded(
data = VaultData(
cipherListViewList = decryptedCipherList,
cipherViewList = decryptedCipherList,
folderViewList = decryptedFolderList,
),
)
@@ -1,15 +1,15 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.core.CipherListView
import com.bitwarden.core.CipherView
import com.bitwarden.core.FolderView
/**
* Represents decrypted vault data.
*
* @param cipherListViewList List of decrypted ciphers.
* @param cipherViewList List of decrypted ciphers.
* @param folderViewList List of decrypted folders.
*/
data class VaultData(
val cipherListViewList: List<CipherListView>,
val cipherViewList: List<CipherView>,
val folderViewList: List<FolderView>,
)
@@ -7,12 +7,16 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
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
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
@@ -32,8 +36,8 @@ private const val KEY_STATE = "state"
@HiltViewModel
class VaultViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
vaultRepository: VaultRepository,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
// TODO retrieve this from the data layer BIT-205
initialState = savedStateHandle[KEY_STATE] ?: VaultState(
initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex,
@@ -46,30 +50,18 @@ class VaultViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
vaultRepository
.vaultDataStateFlow
.onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) }
.launchIn(viewModelScope)
// TODO remove this block once vault unlocked is implemented in BIT-1082
viewModelScope.launch {
// TODO will need to load actual vault items BIT-205
@Suppress("MagicNumber")
delay(2000)
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = null,
name = R.string.folder_none.asText(),
itemCount = 0,
),
),
// TODO Take into account the max threshold of no folder as well as the
// case where it is empty, in which case, no folder is a folder. BIT-205
noFolderItems = emptyList(),
trashItemsCount = 0,
delay(5000)
if (vaultRepository.vaultDataStateFlow.value == DataState.Loading) {
sendAction(
VaultAction.Internal.VaultDataReceive(
DataState.Error(error = IllegalStateException()),
),
)
}
@@ -78,17 +70,18 @@ class VaultViewModel @Inject constructor(
override fun handleAction(action: VaultAction) {
when (action) {
VaultAction.AddItemClick -> handleAddItemClick()
VaultAction.CardGroupClick -> handleCardClick()
is VaultAction.AddItemClick -> handleAddItemClick()
is VaultAction.CardGroupClick -> handleCardClick()
is VaultAction.FolderClick -> handleFolderItemClick(action)
VaultAction.IdentityGroupClick -> handleIdentityClick()
VaultAction.LoginGroupClick -> handleLoginClick()
VaultAction.SearchIconClick -> handleSearchIconClick()
is VaultAction.IdentityGroupClick -> handleIdentityClick()
is VaultAction.LoginGroupClick -> handleLoginClick()
is VaultAction.SearchIconClick -> handleSearchIconClick()
is VaultAction.AccountSwitchClick -> handleAccountSwitchClick(action)
VaultAction.AddAccountClick -> handleAddAccountClick()
VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
VaultAction.TrashClick -> handleTrashClick()
is VaultAction.AddAccountClick -> handleAddAccountClick()
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
is VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
}
}
@@ -150,6 +143,41 @@ class VaultViewModel @Inject constructor(
private fun handleVaultItemClick(action: VaultAction.VaultItemClick) {
sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id))
}
private fun handleVaultDataReceive(action: VaultAction.Internal.VaultDataReceive) {
when (val vaultData = action.vaultData) {
is DataState.Error -> vaultErrorReceive(vaultData = vaultData)
is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData)
is DataState.Loading -> vaultLoadingReceive()
is DataState.NoNetwork -> vaultNoNetworkReceive(vaultData = vaultData)
is DataState.Pending -> vaultPendingReceive(vaultData = vaultData)
}
}
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
// TODO update state to error state BIT-1157
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
sendEvent(VaultEvent.ShowToast(message = "Vault error state not yet implemented"))
}
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) }
}
private fun vaultLoadingReceive() {
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.Loading) }
}
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
// TODO update state to no network state BIT-1158
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
sendEvent(VaultEvent.ShowToast(message = "Vault no network state not yet implemented"))
}
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
// TODO update state to refresh state BIT-505
mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) }
sendEvent(VaultEvent.ShowToast(message = "Refreshing"))
}
//endregion VaultAction Handlers
}
@@ -499,4 +527,17 @@ sealed class VaultAction {
* User clicked the trash button.
*/
data object TrashClick : VaultAction()
/**
* Models actions that the [VaultViewModel] itself might send.
*/
sealed class Internal : VaultAction() {
/**
* Indicates a vault data was received.
*/
data class VaultDataReceive(
val vaultData: DataState<VaultData>,
) : Internal()
}
}
@@ -0,0 +1,70 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
/**
* Transforms a [CipherView] into a [VaultState.ViewState.VaultItem].
*/
@Suppress("MagicNumber")
private fun CipherView.toVaultItem(): VaultState.ViewState.VaultItem =
when (type) {
CipherType.LOGIN -> VaultState.ViewState.VaultItem.Login(
id = id.toString(),
name = name.asText(),
username = login?.username?.asText(),
)
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
id = id.toString(),
name = name.asText(),
)
CipherType.CARD -> VaultState.ViewState.VaultItem.Card(
id = id.toString(),
name = name.asText(),
brand = card?.brand?.asText(),
lastFourDigits = card?.number
?.takeLast(4)
?.asText(),
)
CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity(
id = id.toString(),
name = name.asText(),
firstName = identity?.firstName?.asText(),
)
}
/**
* Transforms [VaultData] into [VaultState.ViewState].
*/
fun VaultData.toViewState(): VaultState.ViewState =
if (cipherViewList.isEmpty() && folderViewList.isEmpty()) {
VaultState.ViewState.NoItems
} else {
VaultState.ViewState.Content(
loginItemsCount = cipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = cipherViewList.count { it.type == CipherType.CARD },
identityItemsCount = cipherViewList.count { it.type == CipherType.IDENTITY },
secureNoteItemsCount = cipherViewList.count { it.type == CipherType.SECURE_NOTE },
favoriteItems = cipherViewList
.filter { it.favorite }
.map { it.toVaultItem() },
folderItems = folderViewList.map { folderView ->
VaultState.ViewState.FolderItem(
id = folderView.id,
name = folderView.name.asText(),
itemCount = cipherViewList.count { folderView.id == it.folderId },
)
},
noFolderItems = cipherViewList
.filter { it.folderId.isNullOrBlank() }
.map { it.toVaultItem() },
// TODO need to populate trash item count in BIT-969
trashItemsCount = 0,
)
}