Возможность переподключения к remote vault
This commit is contained in:
@@ -85,6 +85,16 @@ class VaultsManager(
|
|||||||
yandexAccountStore.deleteByVaultUuid(vaultUuid.toString())
|
yandexAccountStore.deleteByVaultUuid(vaultUuid.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun retry(vaultUuid: UUID): Boolean = withContext(ioDispatcher) {
|
||||||
|
val account = yandexAccountStore.getByVaultUuid(vaultUuid.toString()) ?: return@withContext false
|
||||||
|
yandexAccountStore.updateCredentials(
|
||||||
|
vaultUuid = account.vaultUuid,
|
||||||
|
email = account.email,
|
||||||
|
token = account.oauthToken,
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun registerYandex(registration: YandexRegistration) {
|
private suspend fun registerYandex(registration: YandexRegistration) {
|
||||||
val token = registration.oauthToken
|
val token = registration.oauthToken
|
||||||
val info = yandexUserInfoRepository.userInfo(token)
|
val info = yandexUserInfoRepository.userInfo(token)
|
||||||
|
|||||||
@@ -130,6 +130,27 @@ fun RemoteVaultsScreen(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
IconButton(
|
||||||
onClick = { viewModel.requestDeleteVault(item) },
|
onClick = { viewModel.requestDeleteVault(item) },
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ data class RemoteVaultListItem(
|
|||||||
val uuid: UUID,
|
val uuid: UUID,
|
||||||
val brand: CloudBrand,
|
val brand: CloudBrand,
|
||||||
val label: String,
|
val label: String,
|
||||||
|
val isAvailable: Boolean,
|
||||||
|
val isRefreshing: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RemoteVaultsScreenState(
|
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.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.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.RemoteVaultAuthenticator
|
||||||
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
|
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
|
||||||
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
|
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 com.github.nullptroma.wallenc.vault.contract.remotes
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class RemoteVaultsViewModel @Inject constructor(
|
class RemoteVaultsViewModel @Inject constructor(
|
||||||
private val vaultsManager: IVaultsManager,
|
private val vaultsManager: IVaultsManager,
|
||||||
private val vaultRegistrar: VaultRegistrar,
|
private val vaultRegistrar: VaultRegistrar,
|
||||||
@@ -30,19 +35,26 @@ class RemoteVaultsViewModel @Inject constructor(
|
|||||||
private val uiStrings: UiStringResolver,
|
private val uiStrings: UiStringResolver,
|
||||||
) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) {
|
) : 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(
|
val uiState = combine(
|
||||||
vaultsManager.vaults,
|
remoteItems,
|
||||||
state,
|
state,
|
||||||
) { all, base ->
|
) { items, base ->
|
||||||
base.copy(
|
base.copy(
|
||||||
vaults = all.described().remotes.mapNotNull { v ->
|
vaults = items,
|
||||||
val descriptor = v.descriptor as? VaultDescriptor.LinkedRemote ?: return@mapNotNull null
|
|
||||||
RemoteVaultListItem(
|
|
||||||
uuid = descriptor.uuid,
|
|
||||||
brand = descriptor.brand,
|
|
||||||
label = descriptor.accountDisplayName,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
viewModelScope,
|
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.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -49,6 +50,9 @@ fun TextSecretDetailsScreen(
|
|||||||
uiState.errorMessage?.let {
|
uiState.errorMessage?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
if (uiState.isMutating) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
if (secret == null) return@Column
|
if (secret == null) return@Column
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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.ITaskOrchestrator
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskId
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
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.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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -74,23 +76,15 @@ 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>>()
|
val taskId = taskOrchestrator.enqueue(
|
||||||
taskOrchestrator.enqueue(
|
|
||||||
title = uiStrings(R.string.task_title_delete_text_secret),
|
title = uiStrings(R.string.task_title_delete_text_secret),
|
||||||
dispatcher = Dispatchers.IO,
|
dispatcher = Dispatchers.IO,
|
||||||
busyStorageUuid = storage.uuid,
|
busyStorageUuid = storage.uuid,
|
||||||
work = { _ ->
|
work = { _ ->
|
||||||
try {
|
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 (awaitTaskTerminalState(taskId) is TaskRunState.Completed) {
|
||||||
if (removed) {
|
|
||||||
onDeleted()
|
onDeleted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,4 +92,15 @@ class TextSecretDetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun isTaskActive(state: TaskRunState): Boolean =
|
private fun isTaskActive(state: TaskRunState): Boolean =
|
||||||
state is TaskRunState.Queued || state is TaskRunState.Running
|
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.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -73,9 +74,13 @@ fun TextSecretEditScreen(
|
|||||||
stringResource(R.string.text_secret_edit)
|
stringResource(R.string.text_secret_edit)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (uiState.isMutating) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
|
enabled = !uiState.isMutating,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text(stringResource(R.string.text_secret_title)) },
|
label = { Text(stringResource(R.string.text_secret_title)) },
|
||||||
)
|
)
|
||||||
@@ -94,6 +99,7 @@ fun TextSecretEditScreen(
|
|||||||
onValueChange = { newLabel ->
|
onValueChange = { newLabel ->
|
||||||
items[index] = item.copy(label = newLabel.ifBlank { null })
|
items[index] = item.copy(label = newLabel.ifBlank { null })
|
||||||
},
|
},
|
||||||
|
enabled = !uiState.isMutating,
|
||||||
modifier = Modifier.weight(0.45f),
|
modifier = Modifier.weight(0.45f),
|
||||||
label = { Text(stringResource(R.string.text_secret_item_label_optional)) },
|
label = { Text(stringResource(R.string.text_secret_item_label_optional)) },
|
||||||
)
|
)
|
||||||
@@ -102,6 +108,7 @@ fun TextSecretEditScreen(
|
|||||||
onValueChange = { newValue ->
|
onValueChange = { newValue ->
|
||||||
items[index] = item.copy(value = newValue)
|
items[index] = item.copy(value = newValue)
|
||||||
},
|
},
|
||||||
|
enabled = !uiState.isMutating,
|
||||||
modifier = Modifier.weight(0.55f),
|
modifier = Modifier.weight(0.55f),
|
||||||
label = { Text(stringResource(R.string.text_secret_item_value)) },
|
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.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.ITaskOrchestrator
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskId
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
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.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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -81,42 +84,48 @@ 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>>()
|
val targetSecretId = existingId ?: UUID.randomUUID().toString()
|
||||||
taskOrchestrator.enqueue(
|
val taskId = taskOrchestrator.enqueue(
|
||||||
title = uiStrings(R.string.task_title_save_text_secret),
|
title = uiStrings(R.string.task_title_save_text_secret),
|
||||||
dispatcher = Dispatchers.IO,
|
dispatcher = Dispatchers.IO,
|
||||||
busyStorageUuid = storage.uuid,
|
busyStorageUuid = storage.uuid,
|
||||||
work = { _ ->
|
work = { _ ->
|
||||||
try {
|
if (existingId == null) {
|
||||||
if (existingId == null) {
|
manageTextSecretsUseCase.create(
|
||||||
val created = manageTextSecretsUseCase.create(
|
storageInfo = storage,
|
||||||
storageInfo = storage,
|
title = title,
|
||||||
|
items = items,
|
||||||
|
id = targetSecretId,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
manageTextSecretsUseCase.update(
|
||||||
|
storageInfo = storage,
|
||||||
|
secret = TextSecretRecord(
|
||||||
|
id = targetSecretId,
|
||||||
title = title,
|
title = title,
|
||||||
items = items,
|
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
|
if (awaitTaskTerminalState(taskId) is TaskRunState.Completed) {
|
||||||
onSaved(savedId)
|
onSaved(targetSecretId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isTaskActive(state: TaskRunState): Boolean =
|
private fun isTaskActive(state: TaskRunState): Boolean =
|
||||||
state is TaskRunState.Queued || state is TaskRunState.Running
|
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.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -202,7 +203,7 @@ private fun TwoFaTokenEditDialog(
|
|||||||
var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) }
|
var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!isBusy) onDismiss() },
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
if (startValue == null) {
|
if (startValue == null) {
|
||||||
@@ -214,24 +215,31 @@ private fun TwoFaTokenEditDialog(
|
|||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (isBusy) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = issuer,
|
value = issuer,
|
||||||
onValueChange = { issuer = it },
|
onValueChange = { issuer = it },
|
||||||
|
enabled = !isBusy,
|
||||||
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
|
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = account,
|
value = account,
|
||||||
onValueChange = { account = it },
|
onValueChange = { account = it },
|
||||||
|
enabled = !isBusy,
|
||||||
label = { Text(stringResource(R.string.two_fa_field_account)) },
|
label = { Text(stringResource(R.string.two_fa_field_account)) },
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
|
enabled = !isBusy,
|
||||||
label = { Text(stringResource(R.string.two_fa_field_secret)) },
|
label = { Text(stringResource(R.string.two_fa_field_secret)) },
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = notes,
|
value = notes,
|
||||||
onValueChange = { notes = it },
|
onValueChange = { notes = it },
|
||||||
|
enabled = !isBusy,
|
||||||
label = { Text(stringResource(R.string.two_fa_field_notes_optional)) },
|
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_clear_sync_lock">Снятие блокировки синхронизации</string>
|
||||||
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
|
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
|
||||||
<string name="task_title_remove_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">Синхронизация хранилищ</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_save_2fa_token">Сохранение 2FA токена</string>
|
||||||
@@ -149,6 +150,9 @@
|
|||||||
<string name="remote_vaults_provider_yandex">Яндекс</string>
|
<string name="remote_vaults_provider_yandex">Яндекс</string>
|
||||||
<string name="remote_vaults_add_cancel">Отмена</string>
|
<string name="remote_vaults_add_cancel">Отмена</string>
|
||||||
<string name="remote_vault_type_yandex">Яндекс</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_delete_cd">Удалить удалённое хранилище</string>
|
||||||
<string name="remote_vault_remove_title">Удалить удалённое хранилище?</string>
|
<string name="remote_vault_remove_title">Удалить удалённое хранилище?</string>
|
||||||
<string name="remote_vault_remove_message">Удалить «%1$s» с этого устройства? Данные на сервере не удаляются.</string>
|
<string name="remote_vault_remove_message">Удалить «%1$s» с этого устройства? Данные на сервере не удаляются.</string>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
|||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
@@ -29,7 +30,13 @@ class ManageTextSecretsUseCase {
|
|||||||
val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
|
val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
|
||||||
return merge(
|
return merge(
|
||||||
flowOf(Unit),
|
flowOf(Unit),
|
||||||
storage.accessor.filesUpdates.map { Unit },
|
storage.accessor.filesUpdates
|
||||||
|
.filter { page ->
|
||||||
|
page.data.any { file ->
|
||||||
|
domainFilePathEquals(file.metaInfo.path, StorageDomainDataFiles.TEXT_SECRETS_FILE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { Unit },
|
||||||
).map {
|
).map {
|
||||||
mutex.withLock { readAll(storage) }
|
mutex.withLock { readAll(storage) }
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
@@ -40,11 +47,16 @@ class ManageTextSecretsUseCase {
|
|||||||
readAll(storage).firstOrNull { it.id == id }
|
readAll(storage).firstOrNull { it.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun create(storageInfo: IStorageInfo, title: String, items: List<TextSecretEntryRecord>): TextSecretRecord =
|
suspend fun create(
|
||||||
|
storageInfo: IStorageInfo,
|
||||||
|
title: String,
|
||||||
|
items: List<TextSecretEntryRecord>,
|
||||||
|
id: String = UUID.randomUUID().toString(),
|
||||||
|
): TextSecretRecord =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
|
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
|
||||||
val next = TextSecretRecord(
|
val next = TextSecretRecord(
|
||||||
id = UUID.randomUUID().toString(),
|
id = id,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
items = items.normalizeItems(),
|
items = items.normalizeItems(),
|
||||||
)
|
)
|
||||||
@@ -142,4 +154,7 @@ class ManageTextSecretsUseCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun domainFilePathEquals(left: String, right: String): Boolean =
|
||||||
|
left.trimStart('/') == right.trimStart('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
|||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
@@ -25,7 +26,13 @@ class ManageTwoFaTokensUseCase {
|
|||||||
val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
|
val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
|
||||||
return merge(
|
return merge(
|
||||||
flowOf(Unit),
|
flowOf(Unit),
|
||||||
storage.accessor.filesUpdates.map { Unit },
|
storage.accessor.filesUpdates
|
||||||
|
.filter { page ->
|
||||||
|
page.data.any { file ->
|
||||||
|
domainFilePathEquals(file.metaInfo.path, StorageDomainDataFiles.TWO_FA_TOKENS_FILE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { Unit },
|
||||||
).map {
|
).map {
|
||||||
mutex.withLock { readAll(storage) }
|
mutex.withLock { readAll(storage) }
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
@@ -117,4 +124,7 @@ class ManageTwoFaTokensUseCase {
|
|||||||
put("secret", JsonPrimitive(record.secret))
|
put("secret", JsonPrimitive(record.secret))
|
||||||
record.notes?.let { put("notes", JsonPrimitive(it)) }
|
record.notes?.let { put("notes", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun domainFilePathEquals(left: String, right: String): Boolean =
|
||||||
|
left.trimStart('/') == right.trimStart('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ package com.github.nullptroma.wallenc.usecases
|
|||||||
* Единая точка с путями обычных JSON-файлов пользовательских доменных данных.
|
* Единая точка с путями обычных JSON-файлов пользовательских доменных данных.
|
||||||
*/
|
*/
|
||||||
object StorageDomainDataFiles {
|
object StorageDomainDataFiles {
|
||||||
const val TWO_FA_TOKENS_FILE = "/two-fa-tokens.json"
|
const val TWO_FA_TOKENS_FILE = "/wallenc-data/two-fa-tokens.json"
|
||||||
const val TEXT_SECRETS_FILE = "/text-secrets.json"
|
const val TEXT_SECRETS_FILE = "/wallenc-data/text-secrets.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class StorageSyncEngine(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
|
var leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
|
||||||
val lockedAccessors = mutableListOf<IStorageAccessor>()
|
val lockedAccessors = mutableListOf<IStorageAccessor>()
|
||||||
try {
|
try {
|
||||||
reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks")
|
reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks")
|
||||||
@@ -92,6 +92,12 @@ class StorageSyncEngine(
|
|||||||
|
|
||||||
reportProgress(null, "Storage sync: group \"$groupId\" reading journals")
|
reportProgress(null, "Storage sync: group \"$groupId\" reading journals")
|
||||||
for ((journalIndex, storage) in storages.withIndex()) {
|
for ((journalIndex, storage) in storages.withIndex()) {
|
||||||
|
leaseUntil = renewLocksIfNeeded(
|
||||||
|
groupId = groupId,
|
||||||
|
lockedAccessors = lockedAccessors,
|
||||||
|
currentLeaseUntil = leaseUntil,
|
||||||
|
reportProgress = reportProgress,
|
||||||
|
) ?: return
|
||||||
if (syncGeneration.get() != generationSnapshot) {
|
if (syncGeneration.get() != generationSnapshot) {
|
||||||
reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run")
|
reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run")
|
||||||
return
|
return
|
||||||
@@ -115,6 +121,12 @@ class StorageSyncEngine(
|
|||||||
|
|
||||||
reportProgress(null, "Storage sync: group \"$groupId\" processing ${mergedEntries.size} entries")
|
reportProgress(null, "Storage sync: group \"$groupId\" processing ${mergedEntries.size} entries")
|
||||||
for ((pathIndex, merged) in mergedEntries.withIndex()) {
|
for ((pathIndex, merged) in mergedEntries.withIndex()) {
|
||||||
|
leaseUntil = renewLocksIfNeeded(
|
||||||
|
groupId = groupId,
|
||||||
|
lockedAccessors = lockedAccessors,
|
||||||
|
currentLeaseUntil = leaseUntil,
|
||||||
|
reportProgress = reportProgress,
|
||||||
|
) ?: return
|
||||||
val path = merged.key
|
val path = merged.key
|
||||||
val winnerEntry = merged.value
|
val winnerEntry = merged.value
|
||||||
reportProgress(null, "Storage sync: group \"$groupId\" entry ${pathIndex + 1}/${mergedEntries.size}")
|
reportProgress(null, "Storage sync: group \"$groupId\" entry ${pathIndex + 1}/${mergedEntries.size}")
|
||||||
@@ -156,6 +168,30 @@ class StorageSyncEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun renewLocksIfNeeded(
|
||||||
|
groupId: String,
|
||||||
|
lockedAccessors: List<IStorageAccessor>,
|
||||||
|
currentLeaseUntil: Instant,
|
||||||
|
reportProgress: suspend (fraction: Float?, label: String?) -> Unit,
|
||||||
|
): Instant? {
|
||||||
|
val now = Instant.now()
|
||||||
|
if (currentLeaseUntil.isAfter(now.plusSeconds(SYNC_LOCK_RENEW_MARGIN_SECONDS))) {
|
||||||
|
return currentLeaseUntil
|
||||||
|
}
|
||||||
|
val nextLeaseUntil = now.plusSeconds(SYNC_LOCK_LEASE_SECONDS)
|
||||||
|
reportProgress(null, "Storage sync: group \"$groupId\" renewing locks")
|
||||||
|
for (accessor in lockedAccessors) {
|
||||||
|
val renewed = runCatching {
|
||||||
|
accessor.tryAcquireSyncLock(holderId, nextLeaseUntil)
|
||||||
|
}.getOrElse { false }
|
||||||
|
if (!renewed) {
|
||||||
|
reportProgress(null, "Storage sync: group \"$groupId\" lock renewal failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextLeaseUntil
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveStorages(uuids: Set<UUID>): List<IStorage> {
|
private fun resolveStorages(uuids: Set<UUID>): List<IStorage> {
|
||||||
val byUuid = vaultsManager.allStorages.value.associateBy { it.uuid }
|
val byUuid = vaultsManager.allStorages.value.associateBy { it.uuid }
|
||||||
return uuids.mapNotNull { byUuid[it] }
|
return uuids.mapNotNull { byUuid[it] }
|
||||||
@@ -226,5 +262,6 @@ class StorageSyncEngine(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val SYNC_LOCK_LEASE_SECONDS: Long = 30 * 60
|
private const val SYNC_LOCK_LEASE_SECONDS: Long = 30 * 60
|
||||||
|
private const val SYNC_LOCK_RENEW_MARGIN_SECONDS: Long = 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
|||||||
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -184,7 +185,13 @@ private class FakeStorageAccessor : IStorageAccessor {
|
|||||||
return object : ByteArrayOutputStream() {
|
return object : ByteArrayOutputStream() {
|
||||||
override fun close() {
|
override fun close() {
|
||||||
dataFiles[path] = toByteArray()
|
dataFiles[path] = toByteArray()
|
||||||
_filesUpdates.tryEmit(DataPage(list = emptyList(), pageLength = 1, pageIndex = 0))
|
_filesUpdates.tryEmit(
|
||||||
|
DataPage(
|
||||||
|
listOf(FakeFile(path)),
|
||||||
|
pageLength = 1,
|
||||||
|
pageIndex = 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,3 +230,13 @@ private class FakeStorageAccessor : IStorageAccessor {
|
|||||||
|
|
||||||
override suspend fun forceClearSyncLock() = Unit
|
override suspend fun forceClearSyncLock() = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class FakeFile(path: String) : IFile {
|
||||||
|
override val metaInfo: IMetaInfo = object : IMetaInfo {
|
||||||
|
override val size: Long = 0L
|
||||||
|
override val isDeleted: Boolean = false
|
||||||
|
override val isHidden: Boolean = false
|
||||||
|
override val lastModified: Instant = Instant.now()
|
||||||
|
override val path: String = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,4 +16,10 @@ interface VaultRegistrar {
|
|||||||
|
|
||||||
/** Удалить ранее зарегистрированный vault по идентификатору. */
|
/** Удалить ранее зарегистрированный vault по идентификатору. */
|
||||||
suspend fun unregister(vaultUuid: UUID)
|
suspend fun unregister(vaultUuid: UUID)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Повторная попытка инициализации/подключения удалённого vault.
|
||||||
|
* Возвращает false, если vault не найден или не поддерживает retry.
|
||||||
|
*/
|
||||||
|
suspend fun retry(vaultUuid: UUID): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user