From c58bcdc35be915c251b5f4ee6c3e83630ddcd410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Thu, 21 May 2026 01:40:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20UX=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B5=20=D1=81=20Yandex=20vau?= =?UTF-8?q?lt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vault/storages/common/BaseStorage.kt | 34 +++- .../encrypt/EncryptedStorageAccessor.kt | 8 +- .../storages/local/LocalStorageAccessor.kt | 6 +- .../storages/yandex/YandexStorageAccessor.kt | 11 +- .../domain/vault/vaults/local/LocalVault.kt | 11 + .../domain/vault/vaults/yandex/YandexVault.kt | 29 ++- .../wallenc/domain/interfaces/IVault.kt | 3 + .../wallenc/domain/tasks/TaskProgressLabel.kt | 1 + .../ui/resources/TaskProgressLabels.kt | 1 + .../main/screens/storage/StorageHomeScreen.kt | 9 +- .../vault/AbstractVaultBrowserViewModel.kt | 82 ++++++-- .../main/screens/vault/LocalVaultViewModel.kt | 11 + .../screens/vault/RemoteVaultViewModel.kt | 29 ++- .../main/screens/vault/VaultBrowserHeader.kt | 8 + .../main/screens/vault/VaultBrowserScreen.kt | 188 +++++++++++------- .../screens/vault/VaultBrowserScreenState.kt | 1 + ui/src/main/res/values-ru/strings.xml | 15 ++ ui/src/main/res/values/strings.xml | 10 + .../wallenc/usecases/ManageVaultUseCase.kt | 11 + 19 files changed, 350 insertions(+), 118 deletions(-) create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserHeader.kt diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/common/BaseStorage.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/common/BaseStorage.kt index 1a7b3f2..daa956e 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/common/BaseStorage.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/common/BaseStorage.kt @@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.common import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo @@ -70,17 +71,30 @@ abstract class BaseStorage( } private suspend fun readMetaInfo() = withContext(ioDispatcher) { - var meta: CommonStorageMetaInfo - var reader: InputStream? = null - try { - reader = accessor.openReadSystemFile(metaInfoFileName) - meta = jackson.readValue(reader, CommonStorageMetaInfo::class.java) + val meta = try { + accessor.openReadSystemFile(metaInfoFileName).use { input -> + val bytes = input.readBytes() + when { + bytes.isEmpty() -> { + val default = CommonStorageMetaInfo() + updateMetaInfo(default) + default + } + else -> try { + jackson.readValue(bytes, CommonStorageMetaInfo::class.java) + } catch (_: Exception) { + // Битый JSON — не перезаписываем файл на диске + CommonStorageMetaInfo() + } + } + } + } catch (_: WallencException.Storage.FileNotFound) { + val default = CommonStorageMetaInfo() + updateMetaInfo(default) + default } catch (_: Exception) { - // чтение не удалось — пишем дефолт, чтобы файл появился - meta = CommonStorageMetaInfo() - updateMetaInfo(meta) - } finally { - reader?.close() + // Сеть/IO — оставляем дефолт в памяти, существующий файл не трогаем + CommonStorageMetaInfo() } _metaInfo.value = meta } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt index fdeb83c..1db31c8 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt @@ -272,13 +272,7 @@ class EncryptedStorageAccessor( override suspend fun openReadSystemFile(name: String): InputStream = scope.run { val path = Path(systemHiddenDirName, name).pathString - return@run try { - openRead(path) - } catch (_: Exception) { - // Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова. - openWriteSystemFile(name).use { } - openRead(path) - } + openRead(path) } override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run { diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt index d7f0eed..37cc14a 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt @@ -553,11 +553,9 @@ class LocalStorageAccessor( val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME) val path = dirPath.resolve(name) val file = path.toFile() - if(!file.exists()) { - Files.createDirectories(dirPath) - file.createNewFile() + if (!file.exists()) { + throw WallencException.Storage.FileNotFound() } - return@withContext file.inputStream() } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt index d769e85..4e21b7f 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt @@ -575,12 +575,11 @@ class YandexStorageAccessor( override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) { ensureSystemDirExists() val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name" - try { - guard { repo.openDownloadStream(toDiskPath(rel)) } - } catch (_: Exception) { - // как Local: пустой файл если нет - guard { repo.uploadBytes(toDiskPath(rel), ByteArray(0), overwrite = true) } - guard { repo.openDownloadStream(toDiskPath(rel)) } + val diskPath = toDiskPath(rel) + when (guard { repo.getOrNull(diskPath) }?.type) { + "file" -> guard { repo.openDownloadStream(diskPath) } + null -> throw WallencException.Storage.FileNotFound() + else -> throw WallencException.Storage.FileNotFound() } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt index 4aaef56..169c02b 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt @@ -103,6 +103,17 @@ class LocalVault( return@withContext storage } + override suspend fun rescanStorages() = withContext(ioDispatcher) { + _storagesScanInProgress.value = true + try { + if (_isAvailable.value) { + readStorages() + } + } finally { + _storagesScanInProgress.value = false + } + } + override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) { val path = path.value if (path == null || !_isAvailable.value) { diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/yandex/YandexVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/yandex/YandexVault.kt index 394fb1f..2527477 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/yandex/YandexVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/yandex/YandexVault.kt @@ -13,9 +13,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.util.UUID @@ -54,12 +57,20 @@ class YandexVault( private val _availableSpace = MutableStateFlow(null) override val availableSpace: StateFlow = _availableSpace + private val refreshMutex = Mutex() + init { parentScope.launch { runCatching { refreshFromDisk() } } } + override suspend fun rescanStorages() { + refreshMutex.withLock { + refreshFromDisk() + } + } + private suspend fun refreshFromDisk() { _storagesScanInProgress.value = true _vaultReachable.value = false @@ -111,13 +122,23 @@ class YandexVault( if (pending.isEmpty()) return emptyList() return coroutineScope { pending.map { storage -> - async(ioDispatcher) { - if (runCatching { storage.init() }.isSuccess) storage else null - } + async(ioDispatcher) { initStorageWithRetry(storage) } }.awaitAll().filterNotNull() } } + private suspend fun initStorageWithRetry(storage: YandexStorage): YandexStorage? { + for (attempt in 0 until STORAGE_INIT_ATTEMPTS) { + if (attempt > 0) { + delay(STORAGE_INIT_RETRY_DELAY_MS * attempt) + } + if (runCatching { storage.init() }.isSuccess) { + return storage + } + } + return null + } + override suspend fun createStorage(): IStorage = withContext(ioDispatcher) { val id = UUID.randomUUID() repo.createFolder("app:/$id") @@ -150,5 +171,7 @@ class YandexVault( private companion object { private const val APP_LIST_LIMIT = 1000 + private const val STORAGE_INIT_ATTEMPTS = 3 + private const val STORAGE_INIT_RETRY_DELAY_MS = 400L } } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt index f5001b2..8400c95 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt @@ -21,4 +21,7 @@ interface IVault : IVaultInfo { suspend fun createStorage(): IStorage suspend fun createStorage(enc: StorageEncryptionInfo): IStorage suspend fun remove(storage: IStorage) + + /** Пересканировать список storages (для удалённых vault — повторный листинг и init). */ + suspend fun rescanStorages() {} } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgressLabel.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgressLabel.kt index a30a1ce..c190c9e 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgressLabel.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgressLabel.kt @@ -43,6 +43,7 @@ enum class VaultTaskStep { AddRemoteVault, RemoveRemoteVault, RetryRemoteVault, + RescanVaultStorages, Save2FaToken, Delete2FaToken, SaveTextSecret, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskProgressLabels.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskProgressLabels.kt index fff0934..8abdc97 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskProgressLabels.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskProgressLabels.kt @@ -58,6 +58,7 @@ fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this) VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault) VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault) VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_remote_vault) + VaultTaskStep.RescanVaultStorages -> resolver(R.string.task_progress_rescan_vault_storages) VaultTaskStep.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token) VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token) VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt index e119433..ce6339b 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size @@ -18,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource @@ -45,7 +47,12 @@ fun StorageHomeScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (uiState.isLoading) { - CircularProgressIndicator() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } return@Column } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt index 9d68072..1c978ed 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.util.UUID @@ -39,6 +40,8 @@ import java.util.UUID */ abstract class AbstractVaultBrowserViewModel( storagesFlow: Flow>, + private val storagesScanInProgressFlow: Flow = flowOf(false), + private val vaultHeaderFlow: Flow = flowOf(null), private val vaultAvailabilityFlow: Flow, private val resolveCreateVaultUuid: () -> UUID?, private val removeStorageUseCase: RemoveStorageUseCase, @@ -63,8 +66,12 @@ abstract class AbstractVaultBrowserViewModel( private val _userNotifications = MutableSharedFlow(extraBufferCapacity = 8) val userNotifications: SharedFlow = _userNotifications + /** Удалённый vault: показать кнопку повторного сканирования storages на Диске. */ + open val supportsStorageRescan: Boolean = false + init { collectStoragesFlow(storagesFlow) + collectVaultHeaderFlow() collectPipelineBusyFlags() viewModelScope.launch { vaultAvailabilityFlow @@ -94,31 +101,41 @@ abstract class AbstractVaultBrowserViewModel( t.locksVaultStorageList && isPipelineTaskActive(t.state) } + private fun collectVaultHeaderFlow() { + viewModelScope.launch { + vaultHeaderFlow.collect { header -> + updateState(state.value.copy(header = header)) + } + } + } + private fun collectStoragesFlow(storagesFlow: Flow>) { viewModelScope.launch { combine( storagesFlow, + storagesScanInProgressFlow, getOpenedStoragesUseCase.openedStorages, - ) { storages, opened -> storages to opened } - .collect { (storages, opened) -> - val list = mutableListOf>() - for (storage in storages) { - var tree = Tree(storage) - list.add(tree) - while (opened.containsKey(tree.value.uuid)) { - val child = opened.getValue(tree.value.uuid) - val nextTree = Tree(child) - tree.children = listOf(nextTree) - tree = nextTree - } + ) { storages, scanInProgress, opened -> + Triple(storages, scanInProgress, opened) + }.collect { (storages, scanInProgress, opened) -> + val list = mutableListOf>() + for (storage in storages) { + var tree = Tree(storage) + list.add(tree) + while (opened.containsKey(tree.value.uuid)) { + val child = opened.getValue(tree.value.uuid) + val nextTree = Tree(child) + tree.children = listOf(nextTree) + tree = nextTree } - updateState( - state.value.copy( - storagesList = list, - storagesRefreshing = false, - ), - ) } + updateState( + state.value.copy( + storagesList = list, + storagesRefreshing = scanInProgress, + ), + ) + } } } @@ -149,6 +166,35 @@ abstract class AbstractVaultBrowserViewModel( } } + fun rescanStorages() { + if (!supportsStorageRescan) return + if (state.value.storagesRefreshing) { + notifyUser(R.string.vault_msg_rescan_already_in_progress) + return + } + if (isVaultListMutationActive()) { + notifyUser(R.string.vault_msg_vault_list_mutation_busy) + return + } + val vaultUuid = resolveCreateVaultUuid() ?: return + taskOrchestrator.enqueue( + title = uiStrings(R.string.task_title_rescan_vault_storages), + dispatcher = Dispatchers.IO, + locksVaultStorageList = true, + work = { ctx -> + try { + ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RescanVaultStorages)) + ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_rescanning_vault_storages)) + manageVaultUseCase.rescanStorages(vaultUuid) + ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_rescan_vault_storages_done)) + } catch (e: Exception) { + logger.debug(TAG, "rescanStorages failed: ${e.stackTraceToString()}") + ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_rescan_vault_storages_failed)) + } + }, + ) + } + fun createStorage() { if (!state.value.addStorageFabEnabled) { logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)") diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt index c030c01..188a7d1 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt @@ -1,7 +1,9 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import com.github.nullptroma.wallenc.domain.interfaces.ILogger +import com.github.nullptroma.wallenc.domain.interfaces.IVault import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase @@ -10,6 +12,8 @@ import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase +import com.github.nullptroma.wallenc.vault.contract.DescribedVault +import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import com.github.nullptroma.wallenc.vault.contract.described import com.github.nullptroma.wallenc.vault.contract.locals import dagger.hilt.android.lifecycle.HiltViewModel @@ -36,6 +40,8 @@ class LocalVaultViewModel @Inject constructor( storagesFlow = vaultsManager.vaults .map { vaults -> vaults.described().locals.firstOrNull() } .flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) }, + vaultHeaderFlow = vaultsManager.vaults + .map { vaults -> vaults.described().locals.firstOrNull().toLocalVaultBrowserHeader() }, vaultAvailabilityFlow = vaultsManager.vaults .map { vaults -> vaults.described().locals.firstOrNull() } .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) }, @@ -50,3 +56,8 @@ class LocalVaultViewModel @Inject constructor( uiStrings = uiStrings, logger = logger, ) + +private fun IVault?.toLocalVaultBrowserHeader(): VaultBrowserHeader? { + if ((this as? DescribedVault)?.descriptor !is VaultDescriptor.LocalDevice) return null + return VaultBrowserHeader(titleResId = R.string.screen_title_local_vault) +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt index a5e3b88..5bf5ace 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt @@ -2,8 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import androidx.lifecycle.SavedStateHandle import com.github.nullptroma.wallenc.domain.interfaces.ILogger +import com.github.nullptroma.wallenc.domain.interfaces.IVault import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator +import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.resources.UiStringResolver +import com.github.nullptroma.wallenc.vault.contract.CloudBrand +import com.github.nullptroma.wallenc.vault.contract.DescribedVault +import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase @@ -14,6 +19,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import java.util.UUID import javax.inject.Inject @@ -32,6 +38,11 @@ class RemoteVaultViewModel @Inject constructor( logger: ILogger, ) : AbstractVaultBrowserViewModel( storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()), + storagesScanInProgressFlow = manageVaultUseCase.storagesScanInProgressOf( + savedStateHandle.requireVaultUuid(), + ), + vaultHeaderFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid()) + .map { vault -> vault.toRemoteVaultBrowserHeader() }, vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid()) .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) }, resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() }, @@ -44,9 +55,25 @@ class RemoteVaultViewModel @Inject constructor( taskOrchestrator = taskOrchestrator, uiStrings = uiStrings, logger = logger, -) +) { + override val supportsStorageRescan: Boolean = true +} private fun SavedStateHandle.requireVaultUuid(): UUID { val raw = get("vaultUuid") ?: error("Missing vault UUID in navigation arguments") return UUID.fromString(raw) } + +private fun IVault?.toRemoteVaultBrowserHeader(): VaultBrowserHeader? { + val remote = (this as? DescribedVault)?.descriptor as? VaultDescriptor.LinkedRemote ?: return null + val subtitle = when (remote.brand) { + CloudBrand.YANDEX -> remote.accountDisplayName + } + val titleResId = when (remote.brand) { + CloudBrand.YANDEX -> R.string.screen_title_yandex_vault + } + return VaultBrowserHeader( + titleResId = titleResId, + subtitle = subtitle, + ) +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserHeader.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserHeader.kt new file mode 100644 index 0000000..4583029 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserHeader.kt @@ -0,0 +1,8 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.vault + +import androidx.annotation.StringRes + +data class VaultBrowserHeader( + @param:StringRes val titleResId: Int, + val subtitle: String? = null, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt index 61c1ad5..b49e9aa 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -31,7 +32,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -42,6 +42,8 @@ import com.github.nullptroma.wallenc.ui.elements.StorageTree import com.github.nullptroma.wallenc.ui.resources.UserNotification import java.util.UUID +private val VaultRescanBottomInset = 88.dp + @Composable fun VaultBrowserScreen( modifier: Modifier = Modifier, @@ -78,6 +80,10 @@ fun VaultBrowserScreen( val fabBusy = uiState.vaultListMutationActive val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing + val showRescan = viewModel.supportsStorageRescan + val rescanEnabled = showRescan && + !uiState.vaultListMutationActive && + !uiState.storagesRefreshing val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids } val addFab: @Composable () -> Unit = { @@ -103,78 +109,108 @@ fun VaultBrowserScreen( } val vaultContent: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit = { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) { - if (!fabEnabled) { - Text( - text = stringResource(R.string.vault_unavailable_banner), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - ) - } - Box( - modifier = Modifier.fillMaxSize(), + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + uiState.header?.let { header -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), ) { - when { - showEmptyState -> { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.vault_empty_list_hint), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + Text( + text = stringResource(header.titleResId), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + header.subtitle?.let { subtitle -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (!fabEnabled) { + Text( + text = stringResource(R.string.vault_unavailable_banner), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + when { + showEmptyState -> { + Text( + text = stringResource( + if (showRescan) { + R.string.vault_empty_list_hint_remote + } else { + R.string.vault_empty_list_hint + }, + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 24.dp), + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(uiState.storagesList) { listItem -> + StorageTree( + modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp), + tree = listItem, + isUuidBusy = isUuidBusy, + onClick = { onOpenStorageHome(it.value.uuid.toString()) }, + onRename = { tree, newName -> viewModel.rename(tree.value, newName) }, + onRemove = { tree -> viewModel.remove(tree.value) }, + onEncrypt = { tree, password, encryptPath, rememberPassword -> + viewModel.enableEncryption( + tree.value, + password, + encryptPath, + rememberPassword, + ) + }, + onOpenEncrypted = { tree, password, remember -> + viewModel.openEncryptedStorage(tree.value, password, remember) + }, + onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) }, + onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) }, + getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) }, + isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) }, + isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) }, + onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) }, ) } - } - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - ) { - items(uiState.storagesList) { listItem -> - StorageTree( - modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp), - tree = listItem, - isUuidBusy = isUuidBusy, - onClick = { onOpenStorageHome(it.value.uuid.toString()) }, - onRename = { tree, newName -> viewModel.rename(tree.value, newName) }, - onRemove = { tree -> viewModel.remove(tree.value) }, - onEncrypt = { tree, password, encryptPath, rememberPassword -> - viewModel.enableEncryption( - tree.value, - password, - encryptPath, - rememberPassword, - ) - }, - onOpenEncrypted = { tree, password, remember -> - viewModel.openEncryptedStorage(tree.value, password, remember) - }, - onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) }, - onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) }, - getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) }, - isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) }, - isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) }, - onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) }, - ) - } - item { Spacer(modifier = Modifier.height(8.dp)) } + item { + Spacer( + modifier = Modifier.height( + if (showRescan) VaultRescanBottomInset else 8.dp, + ), + ) } } } } } + } } Box(modifier = modifier) { @@ -184,22 +220,38 @@ fun VaultBrowserScreen( content = vaultContent, ) + if (showRescan) { + FilledTonalButton( + onClick = { viewModel.rescanStorages() }, + enabled = rescanEnabled, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = VaultRescanBottomInset), + ) { + Text(stringResource(R.string.vault_rescan_storages_action)) + } + } + if (showFullscreenLoader) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black)) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim), + ) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { CircularProgressIndicator( modifier = Modifier.size(64.dp), - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( text = stringResource(R.string.vault_loading_storages), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimary, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp), ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt index 09cb4f6..74157d2 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt @@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import java.util.UUID data class VaultBrowserScreenState( + val header: VaultBrowserHeader? = null, val storagesList: List>, /** Первый снимок списка storages ещё не получен (удалённый vault). */ val storagesRefreshing: Boolean, diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 03a7c00..8904a53 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -8,6 +8,13 @@ Синхронизация Настройки Назад + Удалённое хранилище + Локальное хранилище + Хранилище Яндекс.Диска + Хранилище + Токены 2FA + Текстовые секреты + Текст Статус: Выполняется задач: %1$d Сканирование vault: загрузка списка хранилищ… @@ -85,9 +92,12 @@ Создание хранилища уже выполняется С этим хранилищем уже выполняется операция Список хранилищ сейчас меняется — подождите + Сканирование хранилищ уже выполняется Хранилище недоступно. Проверьте сеть, путь или разблокировку. Загрузка списка хранилищ… В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно. + На удалённом хранилище каталоги не найдены. Если папки уже есть на сервере, нажмите «Обновить список», либо создайте хранилище кнопкой «+», когда оно доступно. + Обновить список Очередь задач Задачи Журнал @@ -122,6 +132,7 @@ Добавление… Удаление… Подключение… + Сканирование хранилищ… Сохранение… Удаление… Сохранение… @@ -134,6 +145,7 @@ Добавление удалённого хранилища Удаление удалённого хранилища Повторное подключение удалённого хранилища + Обновление списка хранилищ Синхронизация хранилищ Фоновая синхронизация хранилищ Сохранение 2FA токена @@ -315,6 +327,9 @@ Повторное подключение… Повтор запрошен Не удалось повторить подключение + Повторное сканирование хранилищ на удалённом vault… + Список хранилищ обновлён + Не удалось обновить список хранилищ Тестовая задача запущена на %1$d с Тестовая задача завершена diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5a5e01b..0a2a83b 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ Settings Go back Remote vault + Local vault + Yandex Disk vault Storage 2FA tokens Text secrets @@ -90,9 +92,12 @@ Storage creation already running An operation is already running for this storage Storage list is changing — please wait + Storage scan is already in progress Vault unavailable. Check network, path, or unlock. Loading storage list… No folders yet. Create storage with "+" when available. + No storages found on the remote vault. Tap rescan if folders already exist on the server, or create one with "+" when available. + Rescan storages Task queue Tasks Log @@ -127,6 +132,7 @@ Adding… Removing… Connecting… + Scanning storages… Saving… Removing… Saving… @@ -139,6 +145,7 @@ Add remote vault Remove remote vault Retry remote vault connection + Rescan vault storages Storage sync Background storage sync Save 2FA token @@ -320,6 +327,9 @@ Retrying remote vault connection… Retry requested Failed to retry remote vault + Rescanning storages on remote vault… + Storage list updated + Failed to rescan storages Test task started for %1$d s Test task finished diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageVaultUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageVaultUseCase.kt index e9a0f1f..41cd327 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageVaultUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageVaultUseCase.kt @@ -30,10 +30,21 @@ class ManageVaultUseCase @Inject constructor( fun storagesOf(vaultUuid: UUID): Flow> = observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) } + /** Идёт листинг/пересканирование storages vault'а. */ + fun storagesScanInProgressOf(vaultUuid: UUID): Flow = + observe(vaultUuid).flatMapLatest { vault -> vault?.storagesScanInProgress ?: flowOf(false) } + /** Создать новое хранилище в указанном vault'е. */ suspend fun createStorage(vaultUuid: UUID): IStorage { val vault = find(vaultUuid) ?: throw IllegalStateException("Vault $vaultUuid is not registered") return vault.createStorage() } + + /** Пересканировать storages vault'а (листинг на Диске и повторный init). */ + suspend fun rescanStorages(vaultUuid: UUID) { + val vault = find(vaultUuid) + ?: throw IllegalStateException("Vault $vaultUuid is not registered") + vault.rescanStorages() + } }