Работа с 2fa и секретами перенесена в tasks

This commit is contained in:
2026-05-17 11:54:11 +03:00
parent 555448d998
commit f8d4407eb0
10 changed files with 178 additions and 59 deletions

View File

@@ -59,7 +59,10 @@ fun TextSecretDetailsScreen(
text = secret.title, text = secret.title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
TextButton(onClick = { onEdit(secret.id) }, enabled = uiState.isAvailable) { TextButton(
onClick = { onEdit(secret.id) },
enabled = uiState.isAvailable && !uiState.isMutating,
) {
Text(stringResource(R.string.edit)) Text(stringResource(R.string.edit))
} }
} }
@@ -94,7 +97,7 @@ fun TextSecretDetailsScreen(
Button( Button(
onClick = { viewModel.delete(onDeleted) }, onClick = { viewModel.delete(onDeleted) },
enabled = uiState.isAvailable, enabled = uiState.isAvailable && !uiState.isMutating,
) { ) {
Text(stringResource(R.string.remove)) Text(stringResource(R.string.remove))
} }

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
data class TextSecretDetailsScreenState( data class TextSecretDetailsScreenState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isAvailable: Boolean = false, val isAvailable: Boolean = false,
val isMutating: Boolean = false,
val secret: TextSecretRecord? = null, val secret: TextSecretRecord? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
) )

View File

@@ -2,11 +2,17 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -17,6 +23,8 @@ class TextSecretDetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase, private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase, private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<TextSecretDetailsScreenState>(TextSecretDetailsScreenState()) { ) : ViewModelBase<TextSecretDetailsScreenState>(TextSecretDetailsScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid() private val storageUuid = savedStateHandle.requireStorageUuid()
@@ -44,10 +52,16 @@ class TextSecretDetailsViewModel @Inject constructor(
manageTextSecretsUseCase.observe(storage).map { list -> manageTextSecretsUseCase.observe(storage).map { list ->
list.firstOrNull { it.id == secretId } list.firstOrNull { it.id == secretId }
}, },
) { available, secret -> taskOrchestrator.pipelineState.map { pipe ->
pipe.tasks.any { t ->
t.busyStorageUuid == storage.uuid && isTaskActive(t.state)
}
},
) { available, secret, isMutating ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = available, isAvailable = available,
isMutating = isMutating,
secret = secret, secret = secret,
errorMessage = if (secret == null) "Secret not found" else null, errorMessage = if (secret == null) "Secret not found" else null,
) )
@@ -60,10 +74,28 @@ class TextSecretDetailsViewModel @Inject constructor(
fun delete(onDeleted: () -> Unit) { fun delete(onDeleted: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
val result = CompletableDeferred<Result<Boolean>>()
taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_delete_text_secret),
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { _ ->
try {
val removed = manageTextSecretsUseCase.delete(storage, secretId) val removed = manageTextSecretsUseCase.delete(storage, secretId)
result.complete(Result.success(removed))
} catch (t: Throwable) {
result.complete(Result.failure(t))
throw t
}
},
)
val removed = result.await().getOrElse { false }
if (removed) { if (removed) {
onDeleted() onDeleted()
} }
} }
} }
private fun isTaskActive(state: TaskRunState): Boolean =
state is TaskRunState.Queued || state is TaskRunState.Running
} }

View File

@@ -106,6 +106,7 @@ fun TextSecretEditScreen(
label = { Text(stringResource(R.string.text_secret_item_value)) }, label = { Text(stringResource(R.string.text_secret_item_value)) },
) )
IconButton( IconButton(
enabled = !uiState.isMutating,
onClick = { onClick = {
if (items.size > 1) { if (items.size > 1) {
items.removeAt(index) items.removeAt(index)
@@ -121,7 +122,10 @@ fun TextSecretEditScreen(
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) }) { TextButton(
onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) },
enabled = !uiState.isMutating,
) {
Icon(Icons.Default.Add, contentDescription = null) Icon(Icons.Default.Add, contentDescription = null)
Text(stringResource(R.string.text_secret_add_item)) Text(stringResource(R.string.text_secret_add_item))
} }
@@ -133,7 +137,7 @@ fun TextSecretEditScreen(
onSaved = currentOnSaved, onSaved = currentOnSaved,
) )
}, },
enabled = title.isNotBlank(), enabled = title.isNotBlank() && !uiState.isMutating,
) { ) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
} }

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
data class TextSecretEditScreenState( data class TextSecretEditScreenState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isAvailable: Boolean = false, val isAvailable: Boolean = false,
val isMutating: Boolean = false,
val initialSecret: TextSecretRecord? = null, val initialSecret: TextSecretRecord? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
) )

View File

@@ -4,13 +4,21 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.optionalSecretId import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.optionalSecretId
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -19,6 +27,8 @@ class TextSecretEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase, private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase, private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<TextSecretEditScreenState>(TextSecretEditScreenState()) { ) : ViewModelBase<TextSecretEditScreenState>(TextSecretEditScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid() private val storageUuid = savedStateHandle.requireStorageUuid()
@@ -40,15 +50,26 @@ class TextSecretEditViewModel @Inject constructor(
) )
return@launch return@launch
} }
val initial = secretId?.let { manageTextSecretsUseCase.get(storage, it) } val initial = secretId?.let { id -> manageTextSecretsUseCase.get(storage, id) }
updateState( combine(
storage.isAvailable,
taskOrchestrator.pipelineState.map { pipe ->
pipe.tasks.any { t ->
t.busyStorageUuid == storage.uuid && isTaskActive(t.state)
}
},
flowOf(initial),
) { available, isMutating, currentSecret ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = storage.isAvailable.first(), isAvailable = available,
initialSecret = initial, isMutating = isMutating,
initialSecret = currentSecret,
errorMessage = null, errorMessage = null,
),
) )
}.collect { ui ->
updateState(ui)
}
} }
} }
@@ -60,13 +81,20 @@ class TextSecretEditViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
val existingId = secretId val existingId = secretId
val result = CompletableDeferred<Result<String>>()
taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_save_text_secret),
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { _ ->
try {
if (existingId == null) { if (existingId == null) {
val created = manageTextSecretsUseCase.create( val created = manageTextSecretsUseCase.create(
storageInfo = storage, storageInfo = storage,
title = title, title = title,
items = items, items = items,
) )
onSaved(created.id) result.complete(Result.success(created.id))
} else { } else {
manageTextSecretsUseCase.update( manageTextSecretsUseCase.update(
storageInfo = storage, storageInfo = storage,
@@ -76,8 +104,19 @@ class TextSecretEditViewModel @Inject constructor(
items = items, items = items,
), ),
) )
onSaved(existingId) result.complete(Result.success(existingId))
} }
} catch (t: Throwable) {
result.complete(Result.failure(t))
throw t
}
},
)
val savedId = result.await().getOrNull() ?: return@launch
onSaved(savedId)
} }
} }
private fun isTaskActive(state: TaskRunState): Boolean =
state is TaskRunState.Queued || state is TaskRunState.Running
} }

View File

@@ -57,11 +57,11 @@ fun TwoFaTokensScreen(
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
if (uiState.isAvailable) { if (uiState.isAvailable && !uiState.isMutating) {
creating = true creating = true
} }
}, },
modifier = Modifier.alpha(if (uiState.isAvailable) 1f else 0.5f), modifier = Modifier.alpha(if (uiState.isAvailable && !uiState.isMutating) 1f else 0.5f),
) { ) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.two_fa_add_token)) Icon(Icons.Default.Add, contentDescription = stringResource(R.string.two_fa_add_token))
} }
@@ -133,13 +133,13 @@ fun TwoFaTokensScreen(
Row { Row {
IconButton( IconButton(
onClick = { editingToken = item }, onClick = { editingToken = item },
enabled = uiState.isAvailable, enabled = uiState.isAvailable && !uiState.isMutating,
) { ) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit)) Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
} }
IconButton( IconButton(
onClick = { viewModel.deleteToken(item.id) }, onClick = { viewModel.deleteToken(item.id) },
enabled = uiState.isAvailable, enabled = uiState.isAvailable && !uiState.isMutating,
) { ) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove)) Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
} }
@@ -155,6 +155,7 @@ fun TwoFaTokensScreen(
if (creating) { if (creating) {
TwoFaTokenEditDialog( TwoFaTokenEditDialog(
startValue = null, startValue = null,
isBusy = uiState.isMutating,
onDismiss = { creating = false }, onDismiss = { creating = false },
onSave = { issuer, account, secret, notes -> onSave = { issuer, account, secret, notes ->
creating = false creating = false
@@ -172,6 +173,7 @@ fun TwoFaTokensScreen(
editingToken?.let { token -> editingToken?.let { token ->
TwoFaTokenEditDialog( TwoFaTokenEditDialog(
startValue = token, startValue = token,
isBusy = uiState.isMutating,
onDismiss = { editingToken = null }, onDismiss = { editingToken = null },
onSave = { issuer, account, secret, notes -> onSave = { issuer, account, secret, notes ->
editingToken = null editingToken = null
@@ -190,6 +192,7 @@ fun TwoFaTokensScreen(
@Composable @Composable
private fun TwoFaTokenEditDialog( private fun TwoFaTokenEditDialog(
startValue: TwoFaTokenRecord?, startValue: TwoFaTokenRecord?,
isBusy: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (String, String, String, String?) -> Unit, onSave: (String, String, String, String?) -> Unit,
) { ) {
@@ -235,14 +238,14 @@ private fun TwoFaTokenEditDialog(
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
enabled = issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(), enabled = !isBusy && issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) }, onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) },
) { ) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss, enabled = !isBusy) {
Text(stringResource(R.string.cancel)) Text(stringResource(R.string.cancel))
} }
}, },

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
data class TwoFaTokensScreenState( data class TwoFaTokensScreenState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isAvailable: Boolean = false, val isAvailable: Boolean = false,
val isMutating: Boolean = false,
val items: List<TwoFaTokenRecord> = emptyList(), val items: List<TwoFaTokenRecord> = emptyList(),
val errorMessage: String? = null, val errorMessage: String? = null,
) )

View File

@@ -3,12 +3,18 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -17,6 +23,8 @@ class TwoFaTokensViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase, private val findStorageUseCase: FindStorageUseCase,
private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase, private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<TwoFaTokensScreenState>(TwoFaTokensScreenState()) { ) : ViewModelBase<TwoFaTokensScreenState>(TwoFaTokensScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid() private val storageUuid = savedStateHandle.requireStorageUuid()
@@ -40,10 +48,16 @@ class TwoFaTokensViewModel @Inject constructor(
combine( combine(
storage.isAvailable, storage.isAvailable,
manageTwoFaTokensUseCase.observe(storage), manageTwoFaTokensUseCase.observe(storage),
) { available, items -> taskOrchestrator.pipelineState.map { pipe ->
pipe.tasks.any { t ->
t.busyStorageUuid == storage.uuid && isTaskActive(t.state)
}
},
) { available, items, isMutating ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = available, isAvailable = available,
isMutating = isMutating,
items = items, items = items,
errorMessage = null, errorMessage = null,
) )
@@ -62,6 +76,11 @@ class TwoFaTokensViewModel @Inject constructor(
) { ) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_save_2fa_token),
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { _ ->
if (existingId == null) { if (existingId == null) {
manageTwoFaTokensUseCase.create( manageTwoFaTokensUseCase.create(
storageInfo = storage, storageInfo = storage,
@@ -82,13 +101,25 @@ class TwoFaTokensViewModel @Inject constructor(
), ),
) )
} }
},
)
} }
} }
fun deleteToken(id: String) { fun deleteToken(id: String) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_delete_2fa_token),
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { _ ->
manageTwoFaTokensUseCase.delete(storage, id) manageTwoFaTokensUseCase.delete(storage, id)
},
)
} }
} }
private fun isTaskActive(state: TaskRunState): Boolean =
state is TaskRunState.Queued || state is TaskRunState.Running
} }

View File

@@ -123,6 +123,10 @@
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string> <string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string> <string name="task_title_storage_sync">Синхронизация хранилищ</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string> <string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string>
<string name="task_title_delete_2fa_token">Удаление 2FA токена</string>
<string name="task_title_save_text_secret">Сохранение текстового секрета</string>
<string name="task_title_delete_text_secret">Удаление текстового секрета</string>
<string name="msg_encryption_enabled">Шифрование включено</string> <string name="msg_encryption_enabled">Шифрование включено</string>
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string> <string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string>