Работа с 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,
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))
}
}
@@ -94,7 +97,7 @@ fun TextSecretDetailsScreen(
Button(
onClick = { viewModel.delete(onDeleted) },
enabled = uiState.isAvailable,
enabled = uiState.isAvailable && !uiState.isMutating,
) {
Text(stringResource(R.string.remove))
}

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
data class TextSecretDetailsScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val isMutating: Boolean = false,
val secret: TextSecretRecord? = 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.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.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -17,6 +23,8 @@ class TextSecretDetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<TextSecretDetailsScreenState>(TextSecretDetailsScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
@@ -44,10 +52,16 @@ class TextSecretDetailsViewModel @Inject constructor(
manageTextSecretsUseCase.observe(storage).map { list ->
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(
isLoading = false,
isAvailable = available,
isMutating = isMutating,
secret = secret,
errorMessage = if (secret == null) "Secret not found" else null,
)
@@ -60,10 +74,28 @@ class TextSecretDetailsViewModel @Inject constructor(
fun delete(onDeleted: () -> Unit) {
viewModelScope.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)
result.complete(Result.success(removed))
} catch (t: Throwable) {
result.complete(Result.failure(t))
throw t
}
},
)
val removed = result.await().getOrElse { false }
if (removed) {
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)) },
)
IconButton(
enabled = !uiState.isMutating,
onClick = {
if (items.size > 1) {
items.removeAt(index)
@@ -121,7 +122,10 @@ fun TextSecretEditScreen(
}
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)
Text(stringResource(R.string.text_secret_add_item))
}
@@ -133,7 +137,7 @@ fun TextSecretEditScreen(
onSaved = currentOnSaved,
)
},
enabled = title.isNotBlank(),
enabled = title.isNotBlank() && !uiState.isMutating,
) {
Text(stringResource(R.string.save))
}

View File

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

View File

@@ -4,13 +4,21 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
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.resources.UiStringResolver
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.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
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 javax.inject.Inject
@@ -19,6 +27,8 @@ class TextSecretEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<TextSecretEditScreenState>(TextSecretEditScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
@@ -40,15 +50,26 @@ class TextSecretEditViewModel @Inject constructor(
)
return@launch
}
val initial = secretId?.let { manageTextSecretsUseCase.get(storage, it) }
updateState(
val initial = secretId?.let { id -> manageTextSecretsUseCase.get(storage, id) }
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(
isLoading = false,
isAvailable = storage.isAvailable.first(),
initialSecret = initial,
isAvailable = available,
isMutating = isMutating,
initialSecret = currentSecret,
errorMessage = null,
),
)
}.collect { ui ->
updateState(ui)
}
}
}
@@ -60,13 +81,20 @@ class TextSecretEditViewModel @Inject constructor(
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
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) {
val created = manageTextSecretsUseCase.create(
storageInfo = storage,
title = title,
items = items,
)
onSaved(created.id)
result.complete(Result.success(created.id))
} else {
manageTextSecretsUseCase.update(
storageInfo = storage,
@@ -76,8 +104,19 @@ class TextSecretEditViewModel @Inject constructor(
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(
onClick = {
if (uiState.isAvailable) {
if (uiState.isAvailable && !uiState.isMutating) {
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))
}
@@ -133,13 +133,13 @@ fun TwoFaTokensScreen(
Row {
IconButton(
onClick = { editingToken = item },
enabled = uiState.isAvailable,
enabled = uiState.isAvailable && !uiState.isMutating,
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
}
IconButton(
onClick = { viewModel.deleteToken(item.id) },
enabled = uiState.isAvailable,
enabled = uiState.isAvailable && !uiState.isMutating,
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
}
@@ -155,6 +155,7 @@ fun TwoFaTokensScreen(
if (creating) {
TwoFaTokenEditDialog(
startValue = null,
isBusy = uiState.isMutating,
onDismiss = { creating = false },
onSave = { issuer, account, secret, notes ->
creating = false
@@ -172,6 +173,7 @@ fun TwoFaTokensScreen(
editingToken?.let { token ->
TwoFaTokenEditDialog(
startValue = token,
isBusy = uiState.isMutating,
onDismiss = { editingToken = null },
onSave = { issuer, account, secret, notes ->
editingToken = null
@@ -190,6 +192,7 @@ fun TwoFaTokensScreen(
@Composable
private fun TwoFaTokenEditDialog(
startValue: TwoFaTokenRecord?,
isBusy: Boolean,
onDismiss: () -> Unit,
onSave: (String, String, String, String?) -> Unit,
) {
@@ -235,14 +238,14 @@ private fun TwoFaTokenEditDialog(
},
confirmButton = {
TextButton(
enabled = issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
enabled = !isBusy && issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) },
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
TextButton(onClick = onDismiss, enabled = !isBusy) {
Text(stringResource(R.string.cancel))
}
},

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
data class TwoFaTokensScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val isMutating: Boolean = false,
val items: List<TwoFaTokenRecord> = emptyList(),
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.viewModelScope
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.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -17,6 +23,8 @@ class TwoFaTokensViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<TwoFaTokensScreenState>(TwoFaTokensScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
@@ -40,10 +48,16 @@ class TwoFaTokensViewModel @Inject constructor(
combine(
storage.isAvailable,
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(
isLoading = false,
isAvailable = available,
isMutating = isMutating,
items = items,
errorMessage = null,
)
@@ -62,6 +76,11 @@ class TwoFaTokensViewModel @Inject constructor(
) {
viewModelScope.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) {
manageTwoFaTokensUseCase.create(
storageInfo = storage,
@@ -82,13 +101,25 @@ class TwoFaTokensViewModel @Inject constructor(
),
)
}
},
)
}
}
fun deleteToken(id: String) {
viewModelScope.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)
},
)
}
}
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_storage_sync">Синхронизация хранилищ</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_storage_already_encrypted">Хранилище уже зашифровано</string>