Возможность переподключения к remote vault
This commit is contained in:
@@ -130,6 +130,27 @@ fun RemoteVaultsScreen(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
if (!item.isAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_vault_unavailable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.retryVault(item.uuid) },
|
||||
enabled = !uiState.isBusy,
|
||||
) {
|
||||
Text(
|
||||
if (item.isRefreshing) {
|
||||
stringResource(R.string.remote_vault_retrying)
|
||||
} else {
|
||||
stringResource(R.string.remote_vault_retry_action)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.requestDeleteVault(item) },
|
||||
|
||||
@@ -7,6 +7,8 @@ data class RemoteVaultListItem(
|
||||
val uuid: UUID,
|
||||
val brand: CloudBrand,
|
||||
val label: String,
|
||||
val isAvailable: Boolean,
|
||||
val isRefreshing: Boolean,
|
||||
)
|
||||
|
||||
data class RemoteVaultsScreenState(
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
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.vault.contract.DescribedVault
|
||||
import com.github.nullptroma.wallenc.vault.contract.RemoteVaultAuthenticator
|
||||
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
|
||||
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
|
||||
@@ -15,13 +16,17 @@ import com.github.nullptroma.wallenc.vault.contract.described
|
||||
import com.github.nullptroma.wallenc.vault.contract.remotes
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RemoteVaultsViewModel @Inject constructor(
|
||||
private val vaultsManager: IVaultsManager,
|
||||
private val vaultRegistrar: VaultRegistrar,
|
||||
@@ -30,19 +35,26 @@ class RemoteVaultsViewModel @Inject constructor(
|
||||
private val uiStrings: UiStringResolver,
|
||||
) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) {
|
||||
|
||||
private val remoteItems = vaultsManager.vaults
|
||||
.flatMapLatest { all ->
|
||||
val remotes = all.described().remotes
|
||||
if (remotes.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(remotes.map { vault ->
|
||||
combine(vault.isAvailable, vault.storagesScanInProgress) { available, scanInProgress ->
|
||||
toRemoteItem(vault, available, scanInProgress)
|
||||
}
|
||||
}) { arr -> arr.toList().filterNotNull() }
|
||||
}
|
||||
}
|
||||
|
||||
val uiState = combine(
|
||||
vaultsManager.vaults,
|
||||
remoteItems,
|
||||
state,
|
||||
) { all, base ->
|
||||
) { items, base ->
|
||||
base.copy(
|
||||
vaults = all.described().remotes.mapNotNull { v ->
|
||||
val descriptor = v.descriptor as? VaultDescriptor.LinkedRemote ?: return@mapNotNull null
|
||||
RemoteVaultListItem(
|
||||
uuid = descriptor.uuid,
|
||||
brand = descriptor.brand,
|
||||
label = descriptor.accountDisplayName,
|
||||
)
|
||||
},
|
||||
vaults = items,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
@@ -111,4 +123,40 @@ class RemoteVaultsViewModel @Inject constructor(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun retryVault(vaultUuid: java.util.UUID) {
|
||||
setBusy(true)
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_retry_remote_vault),
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Retrying remote vault connection…")
|
||||
vaultRegistrar.retry(vaultUuid)
|
||||
ctx.log(TaskLogLevel.Info, "Retry requested")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to retry remote vault")
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun toRemoteItem(
|
||||
vault: DescribedVault,
|
||||
isAvailable: Boolean,
|
||||
isRefreshing: Boolean,
|
||||
): RemoteVaultListItem? {
|
||||
val descriptor = vault.descriptor as? VaultDescriptor.LinkedRemote ?: return null
|
||||
return RemoteVaultListItem(
|
||||
uuid = descriptor.uuid,
|
||||
brand = descriptor.brand,
|
||||
label = descriptor.accountDisplayName,
|
||||
isAvailable = isAvailable,
|
||||
isRefreshing = isRefreshing,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -49,6 +50,9 @@ fun TextSecretDetailsScreen(
|
||||
uiState.errorMessage?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (uiState.isMutating) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
if (secret == null) return@Column
|
||||
|
||||
Row(
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.TaskId
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
@@ -11,9 +12,10 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor
|
||||
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.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -74,23 +76,15 @@ class TextSecretDetailsViewModel @Inject constructor(
|
||||
fun delete(onDeleted: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
|
||||
val result = CompletableDeferred<Result<Boolean>>()
|
||||
taskOrchestrator.enqueue(
|
||||
val taskId = 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
|
||||
}
|
||||
manageTextSecretsUseCase.delete(storage, secretId)
|
||||
},
|
||||
)
|
||||
val removed = result.await().getOrElse { false }
|
||||
if (removed) {
|
||||
if (awaitTaskTerminalState(taskId) is TaskRunState.Completed) {
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
@@ -98,4 +92,15 @@ class TextSecretDetailsViewModel @Inject constructor(
|
||||
|
||||
private fun isTaskActive(state: TaskRunState): Boolean =
|
||||
state is TaskRunState.Queued || state is TaskRunState.Running
|
||||
|
||||
private suspend fun awaitTaskTerminalState(taskId: TaskId): TaskRunState {
|
||||
return taskOrchestrator.pipelineState
|
||||
.map { pipe -> pipe.tasks.firstOrNull { it.id == taskId }?.state }
|
||||
.filterNotNull()
|
||||
.first { state ->
|
||||
state is TaskRunState.Completed ||
|
||||
state is TaskRunState.Cancelled ||
|
||||
state is TaskRunState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -73,9 +74,13 @@ fun TextSecretEditScreen(
|
||||
stringResource(R.string.text_secret_edit)
|
||||
},
|
||||
)
|
||||
if (uiState.isMutating) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
enabled = !uiState.isMutating,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.text_secret_title)) },
|
||||
)
|
||||
@@ -94,6 +99,7 @@ fun TextSecretEditScreen(
|
||||
onValueChange = { newLabel ->
|
||||
items[index] = item.copy(label = newLabel.ifBlank { null })
|
||||
},
|
||||
enabled = !uiState.isMutating,
|
||||
modifier = Modifier.weight(0.45f),
|
||||
label = { Text(stringResource(R.string.text_secret_item_label_optional)) },
|
||||
)
|
||||
@@ -102,6 +108,7 @@ fun TextSecretEditScreen(
|
||||
onValueChange = { newValue ->
|
||||
items[index] = item.copy(value = newValue)
|
||||
},
|
||||
enabled = !uiState.isMutating,
|
||||
modifier = Modifier.weight(0.55f),
|
||||
label = { Text(stringResource(R.string.text_secret_item_value)) },
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.TaskId
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
@@ -14,12 +15,14 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor
|
||||
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.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -81,42 +84,48 @@ class TextSecretEditViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
|
||||
val existingId = secretId
|
||||
val result = CompletableDeferred<Result<String>>()
|
||||
taskOrchestrator.enqueue(
|
||||
val targetSecretId = existingId ?: UUID.randomUUID().toString()
|
||||
val taskId = 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,
|
||||
if (existingId == null) {
|
||||
manageTextSecretsUseCase.create(
|
||||
storageInfo = storage,
|
||||
title = title,
|
||||
items = items,
|
||||
id = targetSecretId,
|
||||
)
|
||||
} else {
|
||||
manageTextSecretsUseCase.update(
|
||||
storageInfo = storage,
|
||||
secret = TextSecretRecord(
|
||||
id = targetSecretId,
|
||||
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)
|
||||
if (awaitTaskTerminalState(taskId) is TaskRunState.Completed) {
|
||||
onSaved(targetSecretId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTaskActive(state: TaskRunState): Boolean =
|
||||
state is TaskRunState.Queued || state is TaskRunState.Running
|
||||
|
||||
private suspend fun awaitTaskTerminalState(taskId: TaskId): TaskRunState {
|
||||
return taskOrchestrator.pipelineState
|
||||
.map { pipe -> pipe.tasks.firstOrNull { it.id == taskId }?.state }
|
||||
.filterNotNull()
|
||||
.first { state ->
|
||||
state is TaskRunState.Completed ||
|
||||
state is TaskRunState.Cancelled ||
|
||||
state is TaskRunState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -202,7 +203,7 @@ private fun TwoFaTokenEditDialog(
|
||||
var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { if (!isBusy) onDismiss() },
|
||||
title = {
|
||||
Text(
|
||||
if (startValue == null) {
|
||||
@@ -214,24 +215,31 @@ private fun TwoFaTokenEditDialog(
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (isBusy) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = issuer,
|
||||
onValueChange = { issuer = it },
|
||||
enabled = !isBusy,
|
||||
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = account,
|
||||
onValueChange = { account = it },
|
||||
enabled = !isBusy,
|
||||
label = { Text(stringResource(R.string.two_fa_field_account)) },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
enabled = !isBusy,
|
||||
label = { Text(stringResource(R.string.two_fa_field_secret)) },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
enabled = !isBusy,
|
||||
label = { Text(stringResource(R.string.two_fa_field_notes_optional)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
<string name="task_title_clear_sync_lock">Снятие блокировки синхронизации</string>
|
||||
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
|
||||
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
|
||||
<string name="task_title_retry_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>
|
||||
@@ -149,6 +150,9 @@
|
||||
<string name="remote_vaults_provider_yandex">Яндекс</string>
|
||||
<string name="remote_vaults_add_cancel">Отмена</string>
|
||||
<string name="remote_vault_type_yandex">Яндекс</string>
|
||||
<string name="remote_vault_unavailable">Хранилище временно недоступно</string>
|
||||
<string name="remote_vault_retry_action">Повторить подключение</string>
|
||||
<string name="remote_vault_retrying">Пробую подключиться…</string>
|
||||
<string name="remote_vault_delete_cd">Удалить удалённое хранилище</string>
|
||||
<string name="remote_vault_remove_title">Удалить удалённое хранилище?</string>
|
||||
<string name="remote_vault_remove_message">Удалить «%1$s» с этого устройства? Данные на сервере не удаляются.</string>
|
||||
|
||||
Reference in New Issue
Block a user