mirror of
https://github.com/bitwarden/android.git
synced 2026-06-05 12:16:32 -05:00
BIT-617: Vault Password History (#935)
This commit is contained in:
committed by
Álison Fernandes
parent
46bc489f1f
commit
8156e306f5
@@ -22,6 +22,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolder
|
||||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
|
||||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGeneratorModal
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.navigateToPasswordHistory
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination
|
||||
@@ -90,7 +91,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) },
|
||||
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
|
||||
onNavigateToPendingRequests = { navController.navigateToPendingRequests() },
|
||||
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() },
|
||||
onNavigateToPasswordHistory = {
|
||||
navController.navigateToPasswordHistory(
|
||||
passwordHistoryMode = GeneratorPasswordHistoryMode.Default,
|
||||
)
|
||||
},
|
||||
)
|
||||
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
|
||||
loginApprovalDestination(onNavigateBack = { navController.popBackStack() })
|
||||
@@ -136,6 +141,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
)
|
||||
},
|
||||
onNavigateToAttachments = { navController.navigateToAttachment(it) },
|
||||
onNavigateToPasswordHistory = {
|
||||
navController.navigateToPasswordHistory(
|
||||
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = it),
|
||||
)
|
||||
},
|
||||
)
|
||||
vaultQrCodeScanDestination(
|
||||
onNavigateToManualCodeEntryScreen = {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.generator.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents the different modes the password history screen can be in.
|
||||
*/
|
||||
sealed class GeneratorPasswordHistoryMode : Parcelable {
|
||||
|
||||
/**
|
||||
* Represents the main or default password history mode.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Default : GeneratorPasswordHistoryMode()
|
||||
|
||||
/**
|
||||
* Represents the item password history mode.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Item(val itemId: String) : GeneratorPasswordHistoryMode()
|
||||
}
|
||||
@@ -1,14 +1,45 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
|
||||
|
||||
private const val DEFAULT_MODE: String = "default"
|
||||
private const val ITEM_MODE: String = "item"
|
||||
|
||||
private const val PASSWORD_HISTORY_PREFIX: String = "password_history"
|
||||
private const val PASSWORD_HISTORY_MODE: String = "password_history_mode"
|
||||
private const val PASSWORD_HISTORY_ITEM_ID: String = "password_history_id"
|
||||
|
||||
private const val PASSWORD_HISTORY_ROUTE: String =
|
||||
PASSWORD_HISTORY_PREFIX +
|
||||
"/{$PASSWORD_HISTORY_MODE}" +
|
||||
"?$PASSWORD_HISTORY_ITEM_ID={$PASSWORD_HISTORY_ITEM_ID}"
|
||||
|
||||
/**
|
||||
* The functions below pertain to entry into the [PasswordHistoryScreen].
|
||||
* Class to retrieve password history arguments from the [SavedStateHandle].
|
||||
*/
|
||||
private const val PASSWORD_HISTORY_ROUTE: String = "password_history"
|
||||
@OmitFromCoverage
|
||||
data class PasswordHistoryArgs(
|
||||
val passwordHistoryMode: GeneratorPasswordHistoryMode,
|
||||
) {
|
||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||
passwordHistoryMode = when (requireNotNull(savedStateHandle[PASSWORD_HISTORY_MODE])) {
|
||||
DEFAULT_MODE -> GeneratorPasswordHistoryMode.Default
|
||||
ITEM_MODE -> GeneratorPasswordHistoryMode.Item(
|
||||
requireNotNull(savedStateHandle[PASSWORD_HISTORY_ITEM_ID]),
|
||||
)
|
||||
|
||||
else -> throw IllegalStateException("Unknown VaultAddEditType.")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add password history destination to the graph.
|
||||
@@ -17,8 +48,10 @@ fun NavGraphBuilder.passwordHistoryDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
// TODO: (BIT-617) Allow Password History screen to launch from VaultItemScreen
|
||||
route = PASSWORD_HISTORY_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(PASSWORD_HISTORY_MODE) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
PasswordHistoryScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
@@ -29,6 +62,25 @@ fun NavGraphBuilder.passwordHistoryDestination(
|
||||
/**
|
||||
* Navigate to the Password History Screen.
|
||||
*/
|
||||
fun NavController.navigateToPasswordHistory(navOptions: NavOptions? = null) {
|
||||
navigate(PASSWORD_HISTORY_ROUTE, navOptions)
|
||||
fun NavController.navigateToPasswordHistory(
|
||||
passwordHistoryMode: GeneratorPasswordHistoryMode,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
navigate(
|
||||
route = "$PASSWORD_HISTORY_PREFIX/${passwordHistoryMode.toModeString()}" +
|
||||
"?$PASSWORD_HISTORY_ITEM_ID=${passwordHistoryMode.toIdOrNull()}",
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
private fun GeneratorPasswordHistoryMode.toModeString(): String =
|
||||
when (this) {
|
||||
is GeneratorPasswordHistoryMode.Default -> DEFAULT_MODE
|
||||
is GeneratorPasswordHistoryMode.Item -> ITEM_MODE
|
||||
}
|
||||
|
||||
private fun GeneratorPasswordHistoryMode.toIdOrNull(): String? =
|
||||
when (this) {
|
||||
is GeneratorPasswordHistoryMode.Default -> null
|
||||
is GeneratorPasswordHistoryMode.Item -> itemId
|
||||
}
|
||||
|
||||
@@ -82,21 +82,23 @@ fun PasswordHistoryScreen(
|
||||
{ viewModel.trySendAction(PasswordHistoryAction.CloseClick) }
|
||||
},
|
||||
actions = {
|
||||
BitwardenOverflowActionItem(
|
||||
menuItemDataList = persistentListOf(
|
||||
OverflowMenuItemData(
|
||||
testTag = "ClearPasswordList",
|
||||
text = stringResource(id = R.string.clear),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
PasswordHistoryAction.PasswordClearClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
if (state.menuEnabled) {
|
||||
BitwardenOverflowActionItem(
|
||||
menuItemDataList = persistentListOf(
|
||||
OverflowMenuItemData(
|
||||
testTag = "ClearPasswordList",
|
||||
text = stringResource(id = R.string.clear),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
PasswordHistoryAction.PasswordClearClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.PasswordHistoryView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
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.util.toFormattedPattern
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -21,24 +26,45 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel responsible for handling user interactions in the PasswordHistoryScreen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions")
|
||||
class PasswordHistoryViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val clipboardManager: BitwardenClipboardManager,
|
||||
private val generatorRepository: GeneratorRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
|
||||
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
PasswordHistoryState(
|
||||
passwordHistoryMode = PasswordHistoryArgs(savedStateHandle).passwordHistoryMode,
|
||||
viewState = PasswordHistoryState.ViewState.Loading,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
generatorRepository
|
||||
.passwordHistoryStateFlow
|
||||
.map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
when (val passwordHistoryMode = state.passwordHistoryMode) {
|
||||
is GeneratorPasswordHistoryMode.Default -> {
|
||||
generatorRepository
|
||||
.passwordHistoryStateFlow
|
||||
.map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
is GeneratorPasswordHistoryMode.Item -> {
|
||||
vaultRepository
|
||||
.getVaultItemStateFlow(passwordHistoryMode.itemId)
|
||||
.map { PasswordHistoryAction.Internal.CipherDataReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAction(action: PasswordHistoryAction) {
|
||||
@@ -49,6 +75,8 @@ class PasswordHistoryViewModel @Inject constructor(
|
||||
is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> {
|
||||
handleUpdatePasswordHistoryReceive(action)
|
||||
}
|
||||
|
||||
is PasswordHistoryAction.Internal.CipherDataReceive -> handleCipherDataReceive(action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,22 +90,7 @@ class PasswordHistoryViewModel @Inject constructor(
|
||||
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
|
||||
}
|
||||
|
||||
is LocalDataState.Loaded -> {
|
||||
val passwords = state.data.map { passwordHistoryView ->
|
||||
GeneratedPassword(
|
||||
password = passwordHistoryView.password,
|
||||
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
|
||||
pattern = "MM/dd/yy h:mm a",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (passwords.isEmpty()) {
|
||||
PasswordHistoryState.ViewState.Empty
|
||||
} else {
|
||||
PasswordHistoryState.ViewState.Content(passwords)
|
||||
}
|
||||
}
|
||||
is LocalDataState.Loaded -> state.data.toViewState()
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
@@ -85,6 +98,21 @@ class PasswordHistoryViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCipherDataReceive(action: PasswordHistoryAction.Internal.CipherDataReceive) {
|
||||
val newState: PasswordHistoryState.ViewState = when (action.state) {
|
||||
is DataState.Error -> {
|
||||
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
|
||||
}
|
||||
is DataState.Loaded -> action.state.data?.passwordHistory.toViewState()
|
||||
is DataState.Loading -> PasswordHistoryState.ViewState.Loading
|
||||
is DataState.NoNetwork -> {
|
||||
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
|
||||
}
|
||||
is DataState.Pending -> action.state.data?.passwordHistory.toViewState()
|
||||
}
|
||||
mutableStateFlow.update { it.copy(viewState = newState) }
|
||||
}
|
||||
|
||||
private fun handleCloseClick() {
|
||||
sendEvent(
|
||||
event = PasswordHistoryEvent.NavigateBack,
|
||||
@@ -100,18 +128,41 @@ class PasswordHistoryViewModel @Inject constructor(
|
||||
private fun handleCopyClick(password: GeneratedPassword) {
|
||||
clipboardManager.setText(text = password.password)
|
||||
}
|
||||
|
||||
private fun List<PasswordHistoryView>?.toViewState(): PasswordHistoryState.ViewState {
|
||||
val passwords = this?.map { passwordHistoryView ->
|
||||
GeneratedPassword(
|
||||
password = passwordHistoryView.password,
|
||||
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
|
||||
pattern = "MM/dd/yy h:mm a",
|
||||
),
|
||||
)
|
||||
}
|
||||
return if (passwords?.isNotEmpty() == true) {
|
||||
PasswordHistoryState.ViewState.Content(passwords)
|
||||
} else {
|
||||
PasswordHistoryState.ViewState.Empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the possible states for the password history screen.
|
||||
*
|
||||
* @property passwordHistoryMode Indicates whether tje VM is in default or item mode.
|
||||
* @property viewState The current view state of the password history screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PasswordHistoryState(
|
||||
val passwordHistoryMode: GeneratorPasswordHistoryMode,
|
||||
val viewState: ViewState,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Helper that represents if the menu is enabled.
|
||||
*/
|
||||
val menuEnabled: Boolean
|
||||
get() = passwordHistoryMode is GeneratorPasswordHistoryMode.Default
|
||||
/**
|
||||
* Represents the specific view states for the password history screen.
|
||||
*/
|
||||
@@ -211,5 +262,12 @@ sealed class PasswordHistoryAction {
|
||||
data class UpdatePasswordHistoryReceive(
|
||||
val state: LocalDataState<List<PasswordHistoryView>>,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates cipher data is received.
|
||||
*/
|
||||
data class CipherDataReceive(
|
||||
val state: DataState<CipherView?>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ fun NavGraphBuilder.vaultItemDestination(
|
||||
onNavigateToVaultEditItem: (vaultItemId: String, isClone: Boolean) -> Unit,
|
||||
onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit,
|
||||
onNavigateToAttachments: (vaultItemId: String) -> Unit,
|
||||
onNavigateToPasswordHistory: (vaultItemId: String) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = VAULT_ITEM_ROUTE,
|
||||
@@ -43,6 +44,7 @@ fun NavGraphBuilder.vaultItemDestination(
|
||||
onNavigateToVaultAddEditItem = onNavigateToVaultEditItem,
|
||||
onNavigateToMoveToOrganization = onNavigateToMoveToOrganization,
|
||||
onNavigateToAttachments = onNavigateToAttachments,
|
||||
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ fun VaultItemScreen(
|
||||
onNavigateToVaultAddEditItem: (vaultItemId: String, isClone: Boolean) -> Unit,
|
||||
onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit,
|
||||
onNavigateToAttachments: (vaultItemId: String) -> Unit,
|
||||
onNavigateToPasswordHistory: (vaultItemId: String) -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -92,8 +93,7 @@ fun VaultItemScreen(
|
||||
}
|
||||
|
||||
is VaultItemEvent.NavigateToPasswordHistory -> {
|
||||
// TODO Implement password history in BIT-617
|
||||
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
|
||||
onNavigateToPasswordHistory(event.itemId)
|
||||
}
|
||||
|
||||
is VaultItemEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri())
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.bitwarden.core.FieldView
|
||||
import com.bitwarden.core.IdentityView
|
||||
import com.bitwarden.core.LoginUriView
|
||||
import com.bitwarden.core.LoginView
|
||||
import com.bitwarden.core.PasswordHistoryView
|
||||
import com.bitwarden.core.SecureNoteType
|
||||
import com.bitwarden.core.SecureNoteView
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
|
||||
@@ -33,7 +34,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView =
|
||||
localData = common.originalCipher?.localData,
|
||||
attachments = common.originalCipher?.attachments,
|
||||
organizationUseTotp = common.originalCipher?.organizationUseTotp ?: false,
|
||||
passwordHistory = common.originalCipher?.passwordHistory,
|
||||
passwordHistory = toPasswordHistory(),
|
||||
creationDate = common.originalCipher?.creationDate ?: Instant.now(),
|
||||
deletedDate = common.originalCipher?.deletedDate,
|
||||
revisionDate = common.originalCipher?.revisionDate ?: Instant.now(),
|
||||
@@ -114,6 +115,26 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toIdentityView(): Ident
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun VaultAddEditState.ViewState.Content.toPasswordHistory(): List<PasswordHistoryView>? {
|
||||
val oldPassword = common.originalCipher?.login?.password
|
||||
|
||||
return if (oldPassword != null &&
|
||||
oldPassword != (type as? VaultAddEditState.ViewState.Content.ItemType.Login)?.password
|
||||
) {
|
||||
listOf(
|
||||
PasswordHistoryView(
|
||||
password = oldPassword,
|
||||
lastUsedDate = Instant.now(),
|
||||
),
|
||||
)
|
||||
.plus(common.originalCipher?.passwordHistory.orEmpty())
|
||||
.take(5)
|
||||
} else {
|
||||
common.originalCipher?.passwordHistory
|
||||
}
|
||||
}
|
||||
|
||||
private fun VaultAddEditState.ViewState.Content.ItemType.toLoginView(
|
||||
common: VaultAddEditState.ViewState.Content.Common,
|
||||
): LoginView? =
|
||||
|
||||
Reference in New Issue
Block a user