Сильно улучшен UX при работе с Yandex vault

This commit is contained in:
2026-05-21 01:40:30 +03:00
parent 9c38da76d2
commit c58bcdc35b
19 changed files with 350 additions and 118 deletions

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.common
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo 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.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
@@ -70,17 +71,30 @@ abstract class BaseStorage(
} }
private suspend fun readMetaInfo() = withContext(ioDispatcher) { private suspend fun readMetaInfo() = withContext(ioDispatcher) {
var meta: CommonStorageMetaInfo val meta = try {
var reader: InputStream? = null accessor.openReadSystemFile(metaInfoFileName).use { input ->
try { val bytes = input.readBytes()
reader = accessor.openReadSystemFile(metaInfoFileName) when {
meta = jackson.readValue(reader, CommonStorageMetaInfo::class.java) 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) { } catch (_: Exception) {
// чтение не удалось — пишем дефолт, чтобы файл появился // Сеть/IO — оставляем дефолт в памяти, существующий файл не трогаем
meta = CommonStorageMetaInfo() CommonStorageMetaInfo()
updateMetaInfo(meta)
} finally {
reader?.close()
} }
_metaInfo.value = meta _metaInfo.value = meta
} }

View File

@@ -272,13 +272,7 @@ class EncryptedStorageAccessor(
override suspend fun openReadSystemFile(name: String): InputStream = scope.run { override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString val path = Path(systemHiddenDirName, name).pathString
return@run try { openRead(path)
openRead(path)
} catch (_: Exception) {
// Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова.
openWriteSystemFile(name).use { }
openRead(path)
}
} }
override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run { override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run {

View File

@@ -553,11 +553,9 @@ class LocalStorageAccessor(
val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME) val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME)
val path = dirPath.resolve(name) val path = dirPath.resolve(name)
val file = path.toFile() val file = path.toFile()
if(!file.exists()) { if (!file.exists()) {
Files.createDirectories(dirPath) throw WallencException.Storage.FileNotFound()
file.createNewFile()
} }
return@withContext file.inputStream() return@withContext file.inputStream()
} }

View File

@@ -575,12 +575,11 @@ class YandexStorageAccessor(
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) { override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
ensureSystemDirExists() ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name" val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
try { val diskPath = toDiskPath(rel)
guard { repo.openDownloadStream(toDiskPath(rel)) } when (guard { repo.getOrNull(diskPath) }?.type) {
} catch (_: Exception) { "file" -> guard { repo.openDownloadStream(diskPath) }
// как Local: пустой файл если нет null -> throw WallencException.Storage.FileNotFound()
guard { repo.uploadBytes(toDiskPath(rel), ByteArray(0), overwrite = true) } else -> throw WallencException.Storage.FileNotFound()
guard { repo.openDownloadStream(toDiskPath(rel)) }
} }
} }

View File

@@ -103,6 +103,17 @@ class LocalVault(
return@withContext storage 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) { override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
val path = path.value val path = path.value
if (path == null || !_isAvailable.value) { if (path == null || !_isAvailable.value) {

View File

@@ -13,9 +13,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -54,12 +57,20 @@ class YandexVault(
private val _availableSpace = MutableStateFlow<Long?>(null) private val _availableSpace = MutableStateFlow<Long?>(null)
override val availableSpace: StateFlow<Long?> = _availableSpace override val availableSpace: StateFlow<Long?> = _availableSpace
private val refreshMutex = Mutex()
init { init {
parentScope.launch { parentScope.launch {
runCatching { refreshFromDisk() } runCatching { refreshFromDisk() }
} }
} }
override suspend fun rescanStorages() {
refreshMutex.withLock {
refreshFromDisk()
}
}
private suspend fun refreshFromDisk() { private suspend fun refreshFromDisk() {
_storagesScanInProgress.value = true _storagesScanInProgress.value = true
_vaultReachable.value = false _vaultReachable.value = false
@@ -111,13 +122,23 @@ class YandexVault(
if (pending.isEmpty()) return emptyList() if (pending.isEmpty()) return emptyList()
return coroutineScope { return coroutineScope {
pending.map { storage -> pending.map { storage ->
async(ioDispatcher) { async(ioDispatcher) { initStorageWithRetry(storage) }
if (runCatching { storage.init() }.isSuccess) storage else null
}
}.awaitAll().filterNotNull() }.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) { override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
val id = UUID.randomUUID() val id = UUID.randomUUID()
repo.createFolder("app:/$id") repo.createFolder("app:/$id")
@@ -150,5 +171,7 @@ class YandexVault(
private companion object { private companion object {
private const val APP_LIST_LIMIT = 1000 private const val APP_LIST_LIMIT = 1000
private const val STORAGE_INIT_ATTEMPTS = 3
private const val STORAGE_INIT_RETRY_DELAY_MS = 400L
} }
} }

View File

@@ -21,4 +21,7 @@ interface IVault : IVaultInfo {
suspend fun createStorage(): IStorage suspend fun createStorage(): IStorage
suspend fun createStorage(enc: StorageEncryptionInfo): IStorage suspend fun createStorage(enc: StorageEncryptionInfo): IStorage
suspend fun remove(storage: IStorage) suspend fun remove(storage: IStorage)
/** Пересканировать список storages (для удалённых vault — повторный листинг и init). */
suspend fun rescanStorages() {}
} }

View File

@@ -43,6 +43,7 @@ enum class VaultTaskStep {
AddRemoteVault, AddRemoteVault,
RemoveRemoteVault, RemoveRemoteVault,
RetryRemoteVault, RetryRemoteVault,
RescanVaultStorages,
Save2FaToken, Save2FaToken,
Delete2FaToken, Delete2FaToken,
SaveTextSecret, SaveTextSecret,

View File

@@ -58,6 +58,7 @@ fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this)
VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault) VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault)
VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault) VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault)
VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_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.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token)
VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token) VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token)
VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret) VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret)

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -18,6 +19,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -45,7 +47,12 @@ fun StorageHomeScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator() Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
return@Column return@Column
} }

View File

@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
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 java.util.UUID
@@ -39,6 +40,8 @@ import java.util.UUID
*/ */
abstract class AbstractVaultBrowserViewModel( abstract class AbstractVaultBrowserViewModel(
storagesFlow: Flow<List<IStorage>>, storagesFlow: Flow<List<IStorage>>,
private val storagesScanInProgressFlow: Flow<Boolean> = flowOf(false),
private val vaultHeaderFlow: Flow<VaultBrowserHeader?> = flowOf(null),
private val vaultAvailabilityFlow: Flow<Boolean>, private val vaultAvailabilityFlow: Flow<Boolean>,
private val resolveCreateVaultUuid: () -> UUID?, private val resolveCreateVaultUuid: () -> UUID?,
private val removeStorageUseCase: RemoveStorageUseCase, private val removeStorageUseCase: RemoveStorageUseCase,
@@ -63,8 +66,12 @@ abstract class AbstractVaultBrowserViewModel(
private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8) private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8)
val userNotifications: SharedFlow<UserNotification> = _userNotifications val userNotifications: SharedFlow<UserNotification> = _userNotifications
/** Удалённый vault: показать кнопку повторного сканирования storages на Диске. */
open val supportsStorageRescan: Boolean = false
init { init {
collectStoragesFlow(storagesFlow) collectStoragesFlow(storagesFlow)
collectVaultHeaderFlow()
collectPipelineBusyFlags() collectPipelineBusyFlags()
viewModelScope.launch { viewModelScope.launch {
vaultAvailabilityFlow vaultAvailabilityFlow
@@ -94,31 +101,41 @@ abstract class AbstractVaultBrowserViewModel(
t.locksVaultStorageList && isPipelineTaskActive(t.state) t.locksVaultStorageList && isPipelineTaskActive(t.state)
} }
private fun collectVaultHeaderFlow() {
viewModelScope.launch {
vaultHeaderFlow.collect { header ->
updateState(state.value.copy(header = header))
}
}
}
private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) { private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) {
viewModelScope.launch { viewModelScope.launch {
combine( combine(
storagesFlow, storagesFlow,
storagesScanInProgressFlow,
getOpenedStoragesUseCase.openedStorages, getOpenedStoragesUseCase.openedStorages,
) { storages, opened -> storages to opened } ) { storages, scanInProgress, opened ->
.collect { (storages, opened) -> Triple(storages, scanInProgress, opened)
val list = mutableListOf<Tree<IStorageInfo>>() }.collect { (storages, scanInProgress, opened) ->
for (storage in storages) { val list = mutableListOf<Tree<IStorageInfo>>()
var tree = Tree<IStorageInfo>(storage) for (storage in storages) {
list.add(tree) var tree = Tree<IStorageInfo>(storage)
while (opened.containsKey(tree.value.uuid)) { list.add(tree)
val child = opened.getValue(tree.value.uuid) while (opened.containsKey(tree.value.uuid)) {
val nextTree = Tree(child) val child = opened.getValue(tree.value.uuid)
tree.children = listOf(nextTree) val nextTree = Tree(child)
tree = nextTree 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() { fun createStorage() {
if (!state.value.addStorageFabEnabled) { if (!state.value.addStorageFabEnabled) {
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)") logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")

View File

@@ -1,7 +1,9 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault 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.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager 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.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase 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.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase 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.described
import com.github.nullptroma.wallenc.vault.contract.locals import com.github.nullptroma.wallenc.vault.contract.locals
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -36,6 +40,8 @@ class LocalVaultViewModel @Inject constructor(
storagesFlow = vaultsManager.vaults storagesFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() } .map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) }, .flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
vaultHeaderFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull().toLocalVaultBrowserHeader() },
vaultAvailabilityFlow = vaultsManager.vaults vaultAvailabilityFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() } .map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) }, .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
@@ -50,3 +56,8 @@ class LocalVaultViewModel @Inject constructor(
uiStrings = uiStrings, uiStrings = uiStrings,
logger = logger, 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)
}

View File

@@ -2,8 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.github.nullptroma.wallenc.domain.interfaces.ILogger 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.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver 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.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -14,6 +19,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -32,6 +38,11 @@ class RemoteVaultViewModel @Inject constructor(
logger: ILogger, logger: ILogger,
) : AbstractVaultBrowserViewModel( ) : AbstractVaultBrowserViewModel(
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()), storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
storagesScanInProgressFlow = manageVaultUseCase.storagesScanInProgressOf(
savedStateHandle.requireVaultUuid(),
),
vaultHeaderFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
.map { vault -> vault.toRemoteVaultBrowserHeader() },
vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid()) vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) }, .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() }, resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() },
@@ -44,9 +55,25 @@ class RemoteVaultViewModel @Inject constructor(
taskOrchestrator = taskOrchestrator, taskOrchestrator = taskOrchestrator,
uiStrings = uiStrings, uiStrings = uiStrings,
logger = logger, logger = logger,
) ) {
override val supportsStorageRescan: Boolean = true
}
private fun SavedStateHandle.requireVaultUuid(): UUID { private fun SavedStateHandle.requireVaultUuid(): UUID {
val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments") val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments")
return UUID.fromString(raw) 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,
)
}

View File

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

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -31,7 +32,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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 com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID import java.util.UUID
private val VaultRescanBottomInset = 88.dp
@Composable @Composable
fun VaultBrowserScreen( fun VaultBrowserScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -78,6 +80,10 @@ fun VaultBrowserScreen(
val fabBusy = uiState.vaultListMutationActive val fabBusy = uiState.vaultListMutationActive
val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing
val showEmptyState = 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 isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids }
val addFab: @Composable () -> Unit = { val addFab: @Composable () -> Unit = {
@@ -103,78 +109,108 @@ fun VaultBrowserScreen(
} }
val vaultContent: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit = { innerPadding -> val vaultContent: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit = { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize(),
) { ) {
if (!fabEnabled) { uiState.header?.let { header ->
Text( Column(
text = stringResource(R.string.vault_unavailable_banner), modifier = Modifier
style = MaterialTheme.typography.bodySmall, .fillMaxWidth()
color = MaterialTheme.colorScheme.onSurfaceVariant, .padding(horizontal = 16.dp, vertical = 12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
Box(
modifier = Modifier.fillMaxSize(),
) { ) {
when { Text(
showEmptyState -> { text = stringResource(header.titleResId),
Column( style = MaterialTheme.typography.headlineSmall,
modifier = Modifier color = MaterialTheme.colorScheme.onSurface,
.fillMaxSize() )
.padding(24.dp), header.subtitle?.let { subtitle ->
verticalArrangement = Arrangement.Center, Spacer(modifier = Modifier.height(4.dp))
horizontalAlignment = Alignment.CenterHorizontally, Text(
) { text = subtitle,
Text( style = MaterialTheme.typography.bodyMedium,
text = stringResource(R.string.vault_empty_list_hint), color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge, )
color = MaterialTheme.colorScheme.onSurfaceVariant, }
textAlign = TextAlign.Center, }
}
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) },
) )
} }
} item {
else -> { Spacer(
LazyColumn( modifier = Modifier.height(
modifier = Modifier.fillMaxSize(), if (showRescan) VaultRescanBottomInset else 8.dp,
) { ),
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)) }
} }
} }
} }
} }
} }
}
} }
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -184,22 +220,38 @@ fun VaultBrowserScreen(
content = vaultContent, 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) { if (showFullscreenLoader) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant, trackColor = MaterialTheme.colorScheme.surfaceVariant,
) )
Text( Text(
text = stringResource(R.string.vault_loading_storages), text = stringResource(R.string.vault_loading_storages),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
) )

View File

@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import java.util.UUID import java.util.UUID
data class VaultBrowserScreenState( data class VaultBrowserScreenState(
val header: VaultBrowserHeader? = null,
val storagesList: List<Tree<IStorageInfo>>, val storagesList: List<Tree<IStorageInfo>>,
/** Первый снимок списка storages ещё не получен (удалённый vault). */ /** Первый снимок списка storages ещё не получен (удалённый vault). */
val storagesRefreshing: Boolean, val storagesRefreshing: Boolean,

View File

@@ -8,6 +8,13 @@
<string name="nav_label_sync">Синхронизация</string> <string name="nav_label_sync">Синхронизация</string>
<string name="nav_label_settings">Настройки</string> <string name="nav_label_settings">Настройки</string>
<string name="nav_cd_back">Назад</string> <string name="nav_cd_back">Назад</string>
<string name="screen_title_remote_vault">Удалённое хранилище</string>
<string name="screen_title_local_vault">Локальное хранилище</string>
<string name="screen_title_yandex_vault">Хранилище Яндекс.Диска</string>
<string name="screen_title_storage">Хранилище</string>
<string name="screen_title_two_fa">Токены 2FA</string>
<string name="screen_title_text_secrets">Текстовые секреты</string>
<string name="screen_title_text_edit">Текст</string>
<string name="main_work_status_label">Статус:</string> <string name="main_work_status_label">Статус:</string>
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string> <string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string> <string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string>
@@ -85,9 +92,12 @@
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string> <string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string>
<string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string> <string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string> <string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string>
<string name="vault_msg_rescan_already_in_progress">Сканирование хранилищ уже выполняется</string>
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string> <string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
<string name="vault_loading_storages">Загрузка списка хранилищ…</string> <string name="vault_loading_storages">Загрузка списка хранилищ…</string>
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string> <string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
<string name="vault_empty_list_hint_remote">На удалённом хранилище каталоги не найдены. Если папки уже есть на сервере, нажмите «Обновить список», либо создайте хранилище кнопкой «+», когда оно доступно.</string>
<string name="vault_rescan_storages_action">Обновить список</string>
<string name="task_pipeline_title">Очередь задач</string> <string name="task_pipeline_title">Очередь задач</string>
<string name="task_pipeline_jobs">Задачи</string> <string name="task_pipeline_jobs">Задачи</string>
<string name="task_pipeline_log">Журнал</string> <string name="task_pipeline_log">Журнал</string>
@@ -122,6 +132,7 @@
<string name="task_progress_add_remote_vault">Добавление…</string> <string name="task_progress_add_remote_vault">Добавление…</string>
<string name="task_progress_remove_remote_vault">Удаление…</string> <string name="task_progress_remove_remote_vault">Удаление…</string>
<string name="task_progress_retry_remote_vault">Подключение…</string> <string name="task_progress_retry_remote_vault">Подключение…</string>
<string name="task_progress_rescan_vault_storages">Сканирование хранилищ…</string>
<string name="task_progress_save_2fa_token">Сохранение…</string> <string name="task_progress_save_2fa_token">Сохранение…</string>
<string name="task_progress_delete_2fa_token">Удаление…</string> <string name="task_progress_delete_2fa_token">Удаление…</string>
<string name="task_progress_save_text_secret">Сохранение…</string> <string name="task_progress_save_text_secret">Сохранение…</string>
@@ -134,6 +145,7 @@
<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_retry_remote_vault">Повторное подключение удалённого хранилища</string>
<string name="task_title_rescan_vault_storages">Обновление списка хранилищ</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>
@@ -315,6 +327,9 @@
<string name="task_log_retrying_vault">Повторное подключение…</string> <string name="task_log_retrying_vault">Повторное подключение…</string>
<string name="task_log_retry_requested">Повтор запрошен</string> <string name="task_log_retry_requested">Повтор запрошен</string>
<string name="task_log_retry_vault_failed">Не удалось повторить подключение</string> <string name="task_log_retry_vault_failed">Не удалось повторить подключение</string>
<string name="task_log_rescanning_vault_storages">Повторное сканирование хранилищ на удалённом vault…</string>
<string name="task_log_rescan_vault_storages_done">Список хранилищ обновлён</string>
<string name="task_log_rescan_vault_storages_failed">Не удалось обновить список хранилищ</string>
<string name="task_log_test_started">Тестовая задача запущена на %1$d с</string> <string name="task_log_test_started">Тестовая задача запущена на %1$d с</string>
<string name="task_log_test_finished">Тестовая задача завершена</string> <string name="task_log_test_finished">Тестовая задача завершена</string>
</resources> </resources>

View File

@@ -9,6 +9,8 @@
<string name="nav_label_settings">Settings</string> <string name="nav_label_settings">Settings</string>
<string name="nav_cd_back">Go back</string> <string name="nav_cd_back">Go back</string>
<string name="screen_title_remote_vault">Remote vault</string> <string name="screen_title_remote_vault">Remote vault</string>
<string name="screen_title_local_vault">Local vault</string>
<string name="screen_title_yandex_vault">Yandex Disk vault</string>
<string name="screen_title_storage">Storage</string> <string name="screen_title_storage">Storage</string>
<string name="screen_title_two_fa">2FA tokens</string> <string name="screen_title_two_fa">2FA tokens</string>
<string name="screen_title_text_secrets">Text secrets</string> <string name="screen_title_text_secrets">Text secrets</string>
@@ -90,9 +92,12 @@
<string name="vault_fab_add_storage_busy_cd">Storage creation already running</string> <string name="vault_fab_add_storage_busy_cd">Storage creation already running</string>
<string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string> <string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string>
<string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string> <string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string>
<string name="vault_msg_rescan_already_in_progress">Storage scan is already in progress</string>
<string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string> <string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string>
<string name="vault_loading_storages">Loading storage list…</string> <string name="vault_loading_storages">Loading storage list…</string>
<string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string> <string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string>
<string name="vault_empty_list_hint_remote">No storages found on the remote vault. Tap rescan if folders already exist on the server, or create one with "+" when available.</string>
<string name="vault_rescan_storages_action">Rescan storages</string>
<string name="task_pipeline_title">Task queue</string> <string name="task_pipeline_title">Task queue</string>
<string name="task_pipeline_jobs">Tasks</string> <string name="task_pipeline_jobs">Tasks</string>
<string name="task_pipeline_log">Log</string> <string name="task_pipeline_log">Log</string>
@@ -127,6 +132,7 @@
<string name="task_progress_add_remote_vault">Adding…</string> <string name="task_progress_add_remote_vault">Adding…</string>
<string name="task_progress_remove_remote_vault">Removing…</string> <string name="task_progress_remove_remote_vault">Removing…</string>
<string name="task_progress_retry_remote_vault">Connecting…</string> <string name="task_progress_retry_remote_vault">Connecting…</string>
<string name="task_progress_rescan_vault_storages">Scanning storages…</string>
<string name="task_progress_save_2fa_token">Saving…</string> <string name="task_progress_save_2fa_token">Saving…</string>
<string name="task_progress_delete_2fa_token">Removing…</string> <string name="task_progress_delete_2fa_token">Removing…</string>
<string name="task_progress_save_text_secret">Saving…</string> <string name="task_progress_save_text_secret">Saving…</string>
@@ -139,6 +145,7 @@
<string name="task_title_add_remote_vault">Add remote vault</string> <string name="task_title_add_remote_vault">Add remote vault</string>
<string name="task_title_remove_remote_vault">Remove remote vault</string> <string name="task_title_remove_remote_vault">Remove remote vault</string>
<string name="task_title_retry_remote_vault">Retry remote vault connection</string> <string name="task_title_retry_remote_vault">Retry remote vault connection</string>
<string name="task_title_rescan_vault_storages">Rescan vault storages</string>
<string name="task_title_storage_sync">Storage sync</string> <string name="task_title_storage_sync">Storage sync</string>
<string name="task_title_storage_sync_background">Background storage sync</string> <string name="task_title_storage_sync_background">Background storage sync</string>
<string name="task_title_save_2fa_token">Save 2FA token</string> <string name="task_title_save_2fa_token">Save 2FA token</string>
@@ -320,6 +327,9 @@
<string name="task_log_retrying_vault">Retrying remote vault connection…</string> <string name="task_log_retrying_vault">Retrying remote vault connection…</string>
<string name="task_log_retry_requested">Retry requested</string> <string name="task_log_retry_requested">Retry requested</string>
<string name="task_log_retry_vault_failed">Failed to retry remote vault</string> <string name="task_log_retry_vault_failed">Failed to retry remote vault</string>
<string name="task_log_rescanning_vault_storages">Rescanning storages on remote vault…</string>
<string name="task_log_rescan_vault_storages_done">Storage list updated</string>
<string name="task_log_rescan_vault_storages_failed">Failed to rescan storages</string>
<string name="task_log_test_started">Test task started for %1$d s</string> <string name="task_log_test_started">Test task started for %1$d s</string>
<string name="task_log_test_finished">Test task finished</string> <string name="task_log_test_finished">Test task finished</string>
</resources> </resources>

View File

@@ -30,10 +30,21 @@ class ManageVaultUseCase @Inject constructor(
fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> = fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> =
observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) } observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) }
/** Идёт листинг/пересканирование storages vault'а. */
fun storagesScanInProgressOf(vaultUuid: UUID): Flow<Boolean> =
observe(vaultUuid).flatMapLatest { vault -> vault?.storagesScanInProgress ?: flowOf(false) }
/** Создать новое хранилище в указанном vault'е. */ /** Создать новое хранилище в указанном vault'е. */
suspend fun createStorage(vaultUuid: UUID): IStorage { suspend fun createStorage(vaultUuid: UUID): IStorage {
val vault = find(vaultUuid) val vault = find(vaultUuid)
?: throw IllegalStateException("Vault $vaultUuid is not registered") ?: throw IllegalStateException("Vault $vaultUuid is not registered")
return vault.createStorage() return vault.createStorage()
} }
/** Пересканировать storages vault'а (листинг на Диске и повторный init). */
suspend fun rescanStorages(vaultUuid: UUID) {
val vault = find(vaultUuid)
?: throw IllegalStateException("Vault $vaultUuid is not registered")
vault.rescanStorages()
}
} }