From f8d4407eb053db637ad09baff8a0a5c005986028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 17 May 2026 11:54:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=202fa=20=D0=B8=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../secrets/TextSecretDetailsScreen.kt | 7 +- .../secrets/TextSecretDetailsScreenState.kt | 1 + .../secrets/TextSecretDetailsViewModel.kt | 36 +++++++- .../storage/secrets/TextSecretEditScreen.kt | 8 +- .../secrets/TextSecretEditScreenState.kt | 1 + .../secrets/TextSecretEditViewModel.kt | 89 +++++++++++++------ .../storage/twofa/TwoFaTokensScreen.kt | 15 ++-- .../storage/twofa/TwoFaTokensScreenState.kt | 1 + .../storage/twofa/TwoFaTokensViewModel.kt | 75 +++++++++++----- ui/src/main/res/values/strings.xml | 4 + 10 files changed, 178 insertions(+), 59 deletions(-) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt index 0ae9927..661c844 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt @@ -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)) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt index 42f938b..8fa8285 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt @@ -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, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt index 3ae96eb..515781d 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt @@ -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()) { 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 removed = manageTextSecretsUseCase.delete(storage, secretId) + val result = CompletableDeferred>() + 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 } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt index b25760c..0b111ff 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt @@ -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)) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt index 531e05f..65fb957 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt @@ -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, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt index 7927506..7585ad3 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt @@ -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()) { 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,24 +81,42 @@ class TextSecretEditViewModel @Inject constructor( viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) ?: return@launch val existingId = secretId - if (existingId == null) { - val created = manageTextSecretsUseCase.create( - storageInfo = storage, - title = title, - items = items, - ) - onSaved(created.id) - } else { - manageTextSecretsUseCase.update( - storageInfo = storage, - secret = TextSecretRecord( - id = existingId, - title = title, - items = items, - ), - ) - onSaved(existingId) - } + val result = CompletableDeferred>() + 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, + ) + result.complete(Result.success(created.id)) + } else { + manageTextSecretsUseCase.update( + storageInfo = storage, + secret = TextSecretRecord( + id = existingId, + title = title, + items = items, + ), + ) + 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 } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 12e1e85..94deea9 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -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)) } }, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt index 959bef8..5cc51ae 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt @@ -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 = emptyList(), val errorMessage: String? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt index 1d09e1f..668199c 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt @@ -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()) { 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,33 +76,50 @@ class TwoFaTokensViewModel @Inject constructor( ) { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) ?: return@launch - if (existingId == null) { - manageTwoFaTokensUseCase.create( - storageInfo = storage, - issuer = issuer, - account = account, - secret = secret, - notes = notes, - ) - } else { - manageTwoFaTokensUseCase.update( - storageInfo = storage, - token = TwoFaTokenRecord( - id = existingId, - issuer = issuer, - account = account, - secret = secret, - notes = notes, - ), - ) - } + 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, + issuer = issuer, + account = account, + secret = secret, + notes = notes, + ) + } else { + manageTwoFaTokensUseCase.update( + storageInfo = storage, + token = TwoFaTokenRecord( + id = existingId, + issuer = issuer, + account = account, + secret = secret, + notes = notes, + ), + ) + } + }, + ) } } fun deleteToken(id: String) { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) ?: return@launch - manageTwoFaTokensUseCase.delete(storage, id) + 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 } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index ef70891..6f9a5e4 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -123,6 +123,10 @@ Удаление удалённого хранилища Синхронизация хранилищ Фоновая синхронизация хранилищ + Сохранение 2FA токена + Удаление 2FA токена + Сохранение текстового секрета + Удаление текстового секрета Шифрование включено Хранилище уже зашифровано