BIT-279, BIT-1201: Storage, retrieval, and clearing implementation for password history (#416)

This commit is contained in:
joshua-livefront
2023-12-19 14:42:47 -05:00
committed by Álison Fernandes
parent 39e285fff8
commit 27140bf02c
9 changed files with 357 additions and 71 deletions

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import java.time.Instant
import javax.inject.Singleton
/**
@@ -67,7 +68,11 @@ class GeneratorRepositoryImpl(
}
.onEach { encryptedPasswordHistoryListResult ->
mutablePasswordHistoryStateFlow.value = encryptedPasswordHistoryListResult.fold(
onSuccess = { LocalDataState.Loaded(it) },
onSuccess = {
LocalDataState.Loaded(
it.sortedByDescending { history -> history.lastUsedDate },
)
},
onFailure = { LocalDataState.Error(it) },
)
}
@@ -78,7 +83,14 @@ class GeneratorRepositoryImpl(
generatorSdkSource
.generatePassword(passwordGeneratorRequest)
.fold(
onSuccess = { GeneratedPasswordResult.Success(it) },
onSuccess = { generatedPassword ->
val passwordHistoryView = PasswordHistoryView(
password = generatedPassword,
lastUsedDate = Instant.now(),
)
storePasswordHistory(passwordHistoryView)
GeneratedPasswordResult.Success(generatedPassword)
},
onFailure = { GeneratedPasswordResult.InvalidRequest },
)
@@ -88,7 +100,14 @@ class GeneratorRepositoryImpl(
generatorSdkSource
.generatePassphrase(passphraseGeneratorRequest)
.fold(
onSuccess = { GeneratedPassphraseResult.Success(it) },
onSuccess = { generatedPassphrase ->
val passwordHistoryView = PasswordHistoryView(
password = generatedPassphrase,
lastUsedDate = Instant.now(),
)
storePasswordHistory(passwordHistoryView)
GeneratedPassphraseResult.Success(generatedPassphrase)
},
onFailure = { GeneratedPassphraseResult.InvalidRequest },
)

View File

@@ -28,11 +28,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
@@ -48,6 +51,7 @@ import kotlinx.collections.immutable.persistentListOf
fun PasswordHistoryScreen(
onNavigateBack: () -> Unit,
viewModel: PasswordHistoryViewModel = hiltViewModel(),
clipboardManager: ClipboardManager = LocalClipboardManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -56,6 +60,11 @@ fun PasswordHistoryScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
PasswordHistoryEvent.NavigateBack -> onNavigateBack.invoke()
is PasswordHistoryEvent.CopyTextToClipboard -> {
clipboardManager.setText(event.text.toAnnotatedString())
}
is PasswordHistoryEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
@@ -189,7 +198,7 @@ private fun PasswordHistoryError(
contentAlignment = Alignment.Center,
) {
Text(
text = state.message,
text = state.message.invoke(),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.navigationBarsPadding())

View File

@@ -1,9 +1,22 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
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.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -12,16 +25,61 @@ import javax.inject.Inject
*/
@HiltViewModel
@Suppress("TooManyFunctions")
class PasswordHistoryViewModel @Inject constructor() :
BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
) {
class PasswordHistoryViewModel @Inject constructor(
private val generatorRepository: GeneratorRepository,
) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
) {
init {
generatorRepository
.passwordHistoryStateFlow
.map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: PasswordHistoryAction) {
when (action) {
PasswordHistoryAction.CloseClick -> handleCloseClick()
is PasswordHistoryAction.PasswordCopyClick -> handleCopyClick(action.password)
PasswordHistoryAction.PasswordClearClick -> handlePasswordHistoryClearClick()
is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> {
handleUpdatePasswordHistoryReceive(action)
}
}
}
private fun handleUpdatePasswordHistoryReceive(
action: PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive,
) {
val newState = when (val state = action.state) {
is LocalDataState.Loading -> PasswordHistoryState.ViewState.Loading
is LocalDataState.Error -> {
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)
}
}
}
mutableStateFlow.update {
it.copy(viewState = newState)
}
}
@@ -32,19 +90,13 @@ class PasswordHistoryViewModel @Inject constructor() :
}
private fun handlePasswordHistoryClearClick() {
sendEvent(
event = PasswordHistoryEvent.ShowToast(
message = "Not yet implemented.",
),
)
viewModelScope.launch {
generatorRepository.clearPasswordHistory()
}
}
private fun handleCopyClick(password: GeneratedPassword) {
sendEvent(
event = PasswordHistoryEvent.ShowToast(
message = "Not yet implemented.",
),
)
sendEvent(PasswordHistoryEvent.CopyTextToClipboard(password.password))
}
}
@@ -76,7 +128,7 @@ data class PasswordHistoryState(
* @property message The error message to be displayed.
*/
@Parcelize
data class Error(val message: String) : ViewState()
data class Error(val message: Text) : ViewState()
/**
* Empty state for the password history screen.
@@ -122,6 +174,11 @@ sealed class PasswordHistoryEvent {
* Event to navigate back to the previous screen.
*/
data object NavigateBack : PasswordHistoryEvent()
/**
* Copies text to the clipboard.
*/
data class CopyTextToClipboard(val text: String) : PasswordHistoryEvent()
}
/**
@@ -145,4 +202,17 @@ sealed class PasswordHistoryAction {
* Action when the close button is clicked.
*/
data object CloseClick : PasswordHistoryAction()
/**
* Models actions that the [PasswordHistoryViewModel] itself might send.
*/
sealed class Internal : PasswordHistoryAction() {
/**
* Indicates a password history update is received.
*/
data class UpdatePasswordHistoryReceive(
val state: LocalDataState<List<PasswordHistoryView>>,
) : Internal()
}
}

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.TimeZone
/**
* Converts the [Instant] to a formatted string based on the provided pattern and time zone.
*/
fun Instant.toFormattedPattern(
pattern: String,
zone: ZoneId = TimeZone.getDefault().toZoneId(),
): String {
val formatter = DateTimeFormatter.ofPattern(pattern).withZone(zone)
return formatter.format(this)
}