Возможность переподключения к remote vault

This commit is contained in:
2026-05-17 12:11:53 +03:00
parent f8d4407eb0
commit 15f13577c8
16 changed files with 259 additions and 56 deletions

View File

@@ -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)

View File

@@ -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) },

View File

@@ -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(

View File

@@ -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,
)
}
} }

View File

@@ -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(

View File

@@ -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
}
}
} }

View File

@@ -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)) },
) )

View File

@@ -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) {
val created = manageTextSecretsUseCase.create( manageTextSecretsUseCase.create(
storageInfo = storage, storageInfo = storage,
title = title, title = title,
items = items, items = items,
id = targetSecretId,
) )
result.complete(Result.success(created.id))
} else { } else {
manageTextSecretsUseCase.update( manageTextSecretsUseCase.update(
storageInfo = storage, storageInfo = storage,
secret = TextSecretRecord( secret = TextSecretRecord(
id = existingId, id = targetSecretId,
title = title, title = title,
items = items, 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
}
}
} }

View File

@@ -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)) },
) )
} }

View File

@@ -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>

View File

@@ -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('/')
} }

View File

@@ -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('/')
} }

View File

@@ -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"
} }

View File

@@ -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
} }
} }

View File

@@ -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
}
}

View File

@@ -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
} }