diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt index 739f767..bc99018 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt @@ -85,6 +85,16 @@ class VaultsManager( 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) { val token = registration.oauthToken val info = yandexUserInfoRepository.userInfo(token) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt index fb68caf..4aad9a4 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt @@ -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) }, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreenState.kt index 9cd436e..a9bf239 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreenState.kt @@ -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( diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt index 0d925e6..55b3bf4 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt @@ -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()) { + 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, + ) + } } 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 661c844..d315661 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 @@ -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( 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 515781d..08ae0a7 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 @@ -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>() - 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 + } + } } 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 0b111ff..0133825 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 @@ -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)) }, ) 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 7585ad3..09b05c3 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 @@ -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>() - 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 + } + } } 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 94deea9..2ec226f 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 @@ -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)) }, ) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6f9a5e4..706a746 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -121,6 +121,7 @@ Снятие блокировки синхронизации Добавление удалённого хранилища Удаление удалённого хранилища + Повторное подключение удалённого хранилища Синхронизация хранилищ Фоновая синхронизация хранилищ Сохранение 2FA токена @@ -149,6 +150,9 @@ Яндекс Отмена Яндекс + Хранилище временно недоступно + Повторить подключение + Пробую подключиться… Удалить удалённое хранилище Удалить удалённое хранилище? Удалить «%1$s» с этого устройства? Данные на сервере не удаляются. diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt index 8fd0880..8bb3086 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt @@ -6,6 +6,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -29,7 +30,13 @@ class ManageTextSecretsUseCase { val storage = storageInfo as? IStorage ?: return flowOf(emptyList()) return merge( 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 { mutex.withLock { readAll(storage) } }.distinctUntilChanged() @@ -40,11 +47,16 @@ class ManageTextSecretsUseCase { readAll(storage).firstOrNull { it.id == id } } - suspend fun create(storageInfo: IStorageInfo, title: String, items: List): TextSecretRecord = + suspend fun create( + storageInfo: IStorageInfo, + title: String, + items: List, + id: String = UUID.randomUUID().toString(), + ): TextSecretRecord = mutex.withLock { val storage = storageInfo as? IStorage ?: error("Storage is not writable") val next = TextSecretRecord( - id = UUID.randomUUID().toString(), + id = id, title = title.trim(), items = items.normalizeItems(), ) @@ -142,4 +154,7 @@ class ManageTextSecretsUseCase { ) } } + + private fun domainFilePathEquals(left: String, right: String): Boolean = + left.trimStart('/') == right.trimStart('/') } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt index 4e7468b..4cae690 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt @@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -25,7 +26,13 @@ class ManageTwoFaTokensUseCase { val storage = storageInfo as? IStorage ?: return flowOf(emptyList()) return merge( 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 { mutex.withLock { readAll(storage) } }.distinctUntilChanged() @@ -117,4 +124,7 @@ class ManageTwoFaTokensUseCase { put("secret", JsonPrimitive(record.secret)) record.notes?.let { put("notes", JsonPrimitive(it)) } } + + private fun domainFilePathEquals(left: String, right: String): Boolean = + left.trimStart('/') == right.trimStart('/') } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt index 6a96fc3..727096a 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt @@ -4,6 +4,6 @@ package com.github.nullptroma.wallenc.usecases * Единая точка с путями обычных JSON-файлов пользовательских доменных данных. */ object StorageDomainDataFiles { - const val TWO_FA_TOKENS_FILE = "/two-fa-tokens.json" - const val TEXT_SECRETS_FILE = "/text-secrets.json" + const val TWO_FA_TOKENS_FILE = "/wallenc-data/two-fa-tokens.json" + const val TEXT_SECRETS_FILE = "/wallenc-data/text-secrets.json" } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt index e5ff47a..a90f13c 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt @@ -73,7 +73,7 @@ class StorageSyncEngine( return } - val leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS) + var leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS) val lockedAccessors = mutableListOf() try { reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks") @@ -92,6 +92,12 @@ class StorageSyncEngine( reportProgress(null, "Storage sync: group \"$groupId\" reading journals") for ((journalIndex, storage) in storages.withIndex()) { + leaseUntil = renewLocksIfNeeded( + groupId = groupId, + lockedAccessors = lockedAccessors, + currentLeaseUntil = leaseUntil, + reportProgress = reportProgress, + ) ?: return if (syncGeneration.get() != generationSnapshot) { reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run") return @@ -115,6 +121,12 @@ class StorageSyncEngine( reportProgress(null, "Storage sync: group \"$groupId\" processing ${mergedEntries.size} entries") for ((pathIndex, merged) in mergedEntries.withIndex()) { + leaseUntil = renewLocksIfNeeded( + groupId = groupId, + lockedAccessors = lockedAccessors, + currentLeaseUntil = leaseUntil, + reportProgress = reportProgress, + ) ?: return val path = merged.key val winnerEntry = merged.value 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, + 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): List { val byUuid = vaultsManager.allStorages.value.associateBy { it.uuid } return uuids.mapNotNull { byUuid[it] } @@ -226,5 +262,6 @@ class StorageSyncEngine( private companion object { private const val SYNC_LOCK_LEASE_SECONDS: Long = 30 * 60 + private const val SYNC_LOCK_RENEW_MARGIN_SECONDS: Long = 60 } } diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt index c701f2f..d920d33 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt @@ -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.IStorage 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.tasks.TaskProgress import kotlinx.coroutines.flow.Flow @@ -184,7 +185,13 @@ private class FakeStorageAccessor : IStorageAccessor { return object : ByteArrayOutputStream() { override fun close() { 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 } + +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 + } +} diff --git a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistrar.kt b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistrar.kt index 468c7e0..b26a22f 100644 --- a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistrar.kt +++ b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistrar.kt @@ -16,4 +16,10 @@ interface VaultRegistrar { /** Удалить ранее зарегистрированный vault по идентификатору. */ suspend fun unregister(vaultUuid: UUID) + + /** + * Повторная попытка инициализации/подключения удалённого vault. + * Возвращает false, если vault не найден или не поддерживает retry. + */ + suspend fun retry(vaultUuid: UUID): Boolean }