feat(sync): добавил механизм синхронизации хранилищ и управление группами

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 23:46:31 +03:00
parent d6bfdff077
commit f38b3dfbb4
27 changed files with 1819 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
@@ -40,6 +41,9 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineS
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsScreen
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsViewModel
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncScreen
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncViewModel
import com.github.nullptroma.wallenc.ui.theme.WallencTheme
@@ -70,6 +74,7 @@ fun WallencNavRoot(
val mainViewModel: MainViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val storageSyncViewModel: StorageSyncViewModel = hiltViewModel()
val topLevelRoutes = viewModel.routes
@@ -90,6 +95,11 @@ fun WallencNavRoot(
Icons.AutoMirrored.Rounded.List,
R.string.task_pipeline_open,
),
StorageSyncRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_sync,
StorageSyncRoute::class.qualifiedName!!,
Icons.Rounded.Sync,
),
SettingsRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_settings,
SettingsRoute::class.qualifiedName!!,
@@ -158,6 +168,20 @@ fun WallencNavRoot(
}) {
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
}
composable<StorageSyncRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.SYNC_URI_PATTERN },
),
enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) {
StorageSyncScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = storageSyncViewModel,
)
}
composable<TaskPipelineRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },

View File

@@ -8,6 +8,7 @@ import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlin.collections.set
@@ -20,6 +21,7 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS
mapOf(
MainRoute::class.qualifiedName!! to MainRoute(),
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
StorageSyncRoute::class.qualifiedName!! to StorageSyncRoute(),
SettingsRoute::class.qualifiedName!! to SettingsRoute()
)
)

View File

@@ -16,11 +16,13 @@ object WallencDeepLinks {
object Host {
const val MAIN = "main"
const val TASKS = "tasks"
const val SYNC = "sync"
const val SETTINGS = "settings"
}
const val MAIN_URI_PATTERN = "$SCHEME://${Host.MAIN}"
const val TASKS_URI_PATTERN = "$SCHEME://${Host.TASKS}"
const val SYNC_URI_PATTERN = "$SCHEME://${Host.SYNC}"
const val SETTINGS_URI_PATTERN = "$SCHEME://${Host.SETTINGS}"
}

View File

@@ -12,8 +12,11 @@ import com.github.nullptroma.wallenc.ui.R
@Composable
fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) {
Column (modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(text = stringResource(id = R.string.settings_title))
// Text(text = viewModel)
}
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.ui.screens.sync
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
class StorageSyncRoute : ScreenRoute()

View File

@@ -0,0 +1,427 @@
package com.github.nullptroma.wallenc.ui.screens.sync
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R
import java.util.UUID
@Composable
fun StorageSyncScreen(
modifier: Modifier = Modifier,
viewModel: StorageSyncViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var pendingRemoveGroupId by remember { mutableStateOf<String?>(null) }
var pendingRemoveStorage by remember { mutableStateOf<Pair<String, UUID>?>(null) }
val pickerGroupId = state.pickerGroupId
if (pickerGroupId != null) {
StoragePickerScreen(
modifier = modifier,
state = state,
groupId = pickerGroupId,
onBack = viewModel::closePicker,
onAddStorage = viewModel::addStorageToCurrentGroup,
onToggleVault = viewModel::toggleVaultExpanded,
)
return
}
val storageByUuid = state.vaults
.flatMap { vault -> flattenStorageTree(vault.storages) }
.associateBy { it.uuid }
Scaffold(
modifier = modifier,
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::createGroup,
) {
Text("+")
}
},
) { inner ->
Column(
modifier = Modifier
.padding(inner)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = viewModel::runSyncNow, enabled = !state.isBusy) {
Text(stringResource(id = R.string.sync_run_now))
}
}
state.message?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium)
}
Text(
text = stringResource(id = R.string.sync_groups_title),
style = MaterialTheme.typography.titleMedium,
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(state.groups, key = { it.id }) { group ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = group.id, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(
onClick = { viewModel.openPicker(group.id) },
enabled = !state.isBusy,
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.sync_add_storage),
)
}
IconButton(
onClick = { pendingRemoveGroupId = group.id },
enabled = !state.isBusy,
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringResource(id = R.string.sync_remove_group),
)
}
}
if (group.storageUuids.isEmpty()) {
Text(
text = stringResource(id = R.string.sync_group_empty),
style = MaterialTheme.typography.bodySmall,
)
} else {
val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults)
if (hasMixedEncryption) {
Text(
text = stringResource(id = R.string.sync_group_mixed_encryption_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
group.storageUuids.forEach { storageUuid ->
val storage = storageByUuid[storageUuid]
val storageLabel = storage?.name ?: storageUuid.toString()
val encryptionStatus = storage?.encryptionStatus ?: "Unknown"
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "$storageLabel ($storageUuid) | $encryptionStatus",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { pendingRemoveStorage = group.id to storageUuid },
enabled = !state.isBusy,
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringResource(id = R.string.sync_remove_storage),
)
}
}
}
}
}
}
}
}
}
}
}
if (pendingRemoveGroupId != null) {
AlertDialog(
onDismissRequest = { pendingRemoveGroupId = null },
title = { Text(text = stringResource(id = R.string.sync_remove_group_confirm_title)) },
text = {
Text(
text = stringResource(
id = R.string.sync_remove_group_confirm_message,
pendingRemoveGroupId.orEmpty(),
),
)
},
confirmButton = {
Button(
onClick = {
val groupId = pendingRemoveGroupId
pendingRemoveGroupId = null
if (groupId != null) {
viewModel.removeGroup(groupId)
}
},
) {
Text(text = stringResource(id = R.string.sync_confirm_delete))
}
},
dismissButton = {
Button(onClick = { pendingRemoveGroupId = null }) {
Text(text = stringResource(id = R.string.sync_cancel))
}
},
)
}
if (pendingRemoveStorage != null) {
AlertDialog(
onDismissRequest = { pendingRemoveStorage = null },
title = { Text(text = stringResource(id = R.string.sync_remove_storage_confirm_title)) },
text = {
Text(
text = stringResource(
id = R.string.sync_remove_storage_confirm_message,
pendingRemoveStorage?.second.toString(),
),
)
},
confirmButton = {
Button(
onClick = {
val payload = pendingRemoveStorage
pendingRemoveStorage = null
if (payload != null) {
viewModel.removeStorageFromGroup(payload.first, payload.second)
}
},
) {
Text(text = stringResource(id = R.string.sync_confirm_delete))
}
},
dismissButton = {
Button(onClick = { pendingRemoveStorage = null }) {
Text(text = stringResource(id = R.string.sync_cancel))
}
},
)
}
}
@Composable
private fun StoragePickerScreen(
modifier: Modifier,
state: StorageSyncScreenState,
groupId: String,
onBack: () -> Unit,
onAddStorage: (UUID) -> Unit,
onToggleVault: (UUID) -> Unit,
) {
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
Scaffold(modifier = modifier) { inner ->
Column(
modifier = Modifier
.padding(inner)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onBack) {
Text(stringResource(id = R.string.sync_picker_back))
}
Text(
text = stringResource(id = R.string.sync_picker_title, groupId),
style = MaterialTheme.typography.titleMedium,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(state.vaults, key = { it.uuid }) { vault ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val expanded = vault.uuid in state.expandedVaultUuids
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleVault(vault.uuid) },
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = vault.title,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = if (expanded) "Hide" else "Show",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = "${vault.type} | ${vault.uuid}",
style = MaterialTheme.typography.bodySmall,
)
if (expanded) {
if (vault.storages.isEmpty()) {
Text(
text = stringResource(id = R.string.sync_picker_no_storages),
style = MaterialTheme.typography.bodySmall,
)
} else {
vault.storages.forEach { storage ->
StoragePickerNode(
node = storage,
depth = 0,
selected = selected,
isBusy = state.isBusy,
onAddStorage = onAddStorage,
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun StoragePickerNode(
node: StorageSyncStorageUi,
depth: Int,
selected: Set<UUID>,
isBusy: Boolean,
onAddStorage: (UUID) -> Unit,
) {
val isSelected = node.uuid in selected
Card(
modifier = Modifier
.fillMaxWidth()
.padding(start = (depth * 14).dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = node.name,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "${node.uuid} | ${node.encryptionStatus}",
style = MaterialTheme.typography.bodySmall,
)
}
Button(
enabled = !isSelected && !isBusy,
onClick = { onAddStorage(node.uuid) },
) {
Text(
text = if (isSelected) {
stringResource(id = R.string.sync_picker_added)
} else {
stringResource(id = R.string.sync_picker_add)
},
)
}
}
}
node.children.forEach { child ->
StoragePickerNode(
node = child,
depth = depth + 1,
selected = selected,
isBusy = isBusy,
onAddStorage = onAddStorage,
)
}
}
private fun flattenStorageTree(nodes: List<StorageSyncStorageUi>): List<StorageSyncStorageUi> {
return nodes.flatMap { node ->
listOf(node) + flattenStorageTree(node.children)
}
}
private fun hasEncryptionMismatch(
group: StorageSyncGroupUi,
vaults: List<StorageSyncVaultUi>,
): Boolean {
if (group.storageUuids.isEmpty()) return false
val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid }
val statuses = group.storageUuids.mapNotNull { byUuid[it]?.encryptionStatus }.toSet()
val hasEncrypted = statuses.any { it.startsWith("Encrypted") }
val hasPlain = statuses.any { it == "Not encrypted" }
return hasEncrypted && hasPlain
}

View File

@@ -0,0 +1,31 @@
package com.github.nullptroma.wallenc.ui.screens.sync
import java.util.UUID
data class StorageSyncStorageUi(
val uuid: UUID,
val name: String,
val encryptionStatus: String,
val children: List<StorageSyncStorageUi> = emptyList(),
)
data class StorageSyncVaultUi(
val uuid: UUID,
val title: String,
val type: String,
val storages: List<StorageSyncStorageUi>,
)
data class StorageSyncGroupUi(
val id: String,
val storageUuids: Set<UUID>,
)
data class StorageSyncScreenState(
val groups: List<StorageSyncGroupUi> = emptyList(),
val vaults: List<StorageSyncVaultUi> = emptyList(),
val expandedVaultUuids: Set<UUID> = emptySet(),
val pickerGroupId: String? = null,
val isBusy: Boolean = false,
val message: String? = null,
)

View File

@@ -0,0 +1,272 @@
package com.github.nullptroma.wallenc.ui.screens.sync
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import java.util.UUID
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class StorageSyncViewModel @javax.inject.Inject constructor(
private val groupsUseCase: ManageStorageSyncGroupsUseCase,
private val runStorageSyncUseCase: RunStorageSyncUseCase,
private val vaultsManager: IVaultsManager,
) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) {
init {
refreshGroups()
observeVaults()
}
fun refreshGroups() {
viewModelScope.launch {
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
),
)
}
}
fun createGroup() {
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
val group = groupsUseCase.createGroup()
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
pickerGroupId = null,
isBusy = false,
message = "Group ${group.id} created",
),
)
}
}
fun removeGroup(groupId: String) {
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
groupsUseCase.removeGroup(groupId)
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false,
message = "Group removed",
),
)
}
}
fun openPicker(groupId: String) {
updateState(
state.value.copy(
pickerGroupId = groupId,
message = null,
),
)
}
fun closePicker() {
updateState(
state.value.copy(
pickerGroupId = null,
message = null,
),
)
}
fun toggleVaultExpanded(vaultUuid: UUID) {
val expanded = state.value.expandedVaultUuids.toMutableSet()
if (!expanded.add(vaultUuid)) {
expanded.remove(vaultUuid)
}
updateState(state.value.copy(expandedVaultUuids = expanded))
}
fun addStorageToCurrentGroup(storageUuid: UUID) {
val groupId = state.value.pickerGroupId ?: return
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
groupsUseCase.addStorageToGroup(groupId, storageUuid)
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false,
message = "Storage added to $groupId",
),
)
}
}
fun removeStorageFromGroup(groupId: String, storageUuid: UUID) {
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
groupsUseCase.removeStorageFromGroup(groupId, storageUuid)
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false,
message = "Storage removed from $groupId",
),
)
}
}
fun runSyncNow() {
runStorageSyncUseCase.enqueue("sync-tab")
updateState(state.value.copy(message = "Sync task enqueued"))
}
private fun observeVaults() {
viewModelScope.launch {
vaultsManager.vaults
.flatMapLatest { vaults ->
if (vaults.isEmpty()) {
flowOf(emptyList())
} else {
combine(vaults.map { it.storages }) { rootStoragesByVault ->
vaults.zip(rootStoragesByVault.toList())
}.flatMapLatest { vaultWithRoots ->
vaultsManager.unlockManager.openedStorages.flatMapLatest { opened ->
val vaultNodes = vaultWithRoots.map { (vault, roots) ->
vault to roots.map { root ->
buildStorageTree(
root = root,
opened = opened,
)
}
}
val allStorages = vaultNodes
.flatMap { (_, trees) -> trees.flatMap(::flattenStorages) }
.distinctBy { it.uuid }
if (allStorages.isEmpty()) {
flowOf(
vaultNodes.map { (vault, trees) ->
StorageSyncVaultUi(
uuid = vault.uuid,
title = vaultTitle(vault as? DescribedVault),
type = vaultType(vault as? DescribedVault),
storages = trees.map { tree ->
toStorageUi(tree)
},
)
},
)
} else {
combine(allStorages.map { it.metaInfo }) {
val metaByStorageUuid = allStorages
.mapIndexed { index, storage -> storage.uuid to it[index] }
.toMap()
vaultNodes.map { (vault, trees) ->
StorageSyncVaultUi(
uuid = vault.uuid,
title = vaultTitle(vault as? DescribedVault),
type = vaultType(vault as? DescribedVault),
storages = trees.map { tree ->
toStorageUi(tree, metaByStorageUuid)
},
)
}
}
}
}
}
}
}
.collect { mapped ->
updateState(state.value.copy(vaults = mapped))
}
}
}
private fun vaultType(vault: DescribedVault?): String {
val descriptor = vault?.descriptor
return when (descriptor) {
is VaultDescriptor.LocalDevice -> "Local device"
is VaultDescriptor.LinkedRemote -> "Remote ${descriptor.brand.name.lowercase()}"
null -> "Unknown"
}
}
private fun vaultTitle(vault: DescribedVault?): String {
val descriptor = vault?.descriptor
return when (descriptor) {
is VaultDescriptor.LocalDevice -> "Local vault"
is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName
null -> "Unknown vault"
}
}
private fun buildStorageTree(
root: IStorage,
opened: Map<UUID, IStorage>,
visited: MutableSet<UUID> = mutableSetOf(),
): StorageTreeNode {
if (!visited.add(root.uuid)) {
return StorageTreeNode(storage = root, children = emptyList())
}
val child = opened[root.uuid]
val children = if (child == null) {
emptyList()
} else {
listOf(
buildStorageTree(
root = child,
opened = opened,
visited = visited,
),
)
}
return StorageTreeNode(storage = root, children = children)
}
private fun flattenStorages(node: StorageTreeNode): List<IStorage> {
return listOf(node.storage) + node.children.flatMap(::flattenStorages)
}
private fun toStorageUi(
node: StorageTreeNode,
metaByStorageUuid: Map<UUID, com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo> = emptyMap(),
): StorageSyncStorageUi {
val meta = metaByStorageUuid[node.storage.uuid] ?: node.storage.metaInfo.value
val encryptionStatus = when {
meta.encInfo == null -> "Not encrypted"
node.storage.isVirtualStorage -> "Encrypted (opened)"
else -> "Encrypted"
}
return StorageSyncStorageUi(
uuid = node.storage.uuid,
name = meta.name ?: "<noname>",
encryptionStatus = encryptionStatus,
children = node.children.map { child ->
toStorageUi(
node = child,
metaByStorageUuid = metaByStorageUuid,
)
},
)
}
private data class StorageTreeNode(
val storage: IStorage,
val children: List<StorageTreeNode>,
)
}

View File

@@ -3,9 +3,29 @@
<string name="nav_label_local_vault">Local</string>
<string name="nav_label_remote_vaults">Remotes</string>
<string name="nav_label_main">Main</string>
<string name="nav_label_sync">Sync</string>
<string name="nav_label_settings">Settings</string>
<string name="settings_title">Settings Screen Title!</string>
<string name="settings_title">Settings</string>
<string name="sync_groups_title">Sync groups</string>
<string name="sync_run_now">Run sync now</string>
<string name="sync_refresh">Refresh</string>
<string name="sync_add_storage">Add</string>
<string name="sync_remove_group">Remove group</string>
<string name="sync_group_empty">No storages in group</string>
<string name="sync_remove_storage">Remove</string>
<string name="sync_picker_back">Back</string>
<string name="sync_picker_title">Select storage for %1$s</string>
<string name="sync_picker_add">Add</string>
<string name="sync_picker_added">Added</string>
<string name="sync_picker_no_storages">No storages in this vault</string>
<string name="sync_group_mixed_encryption_warning">Mixed encryption in group: define one canonical encryption mode</string>
<string name="sync_remove_group_confirm_title">Remove group?</string>
<string name="sync_remove_group_confirm_message">Delete sync group \"%1$s\"?</string>
<string name="sync_remove_storage_confirm_title">Remove storage?</string>
<string name="sync_remove_storage_confirm_message">Remove storage \"%1$s\" from the group?</string>
<string name="sync_confirm_delete">Delete</string>
<string name="sync_cancel">Cancel</string>
<string name="no_name">&lt;noname&gt;</string>
<string name="show_storage_item_menu">Show storage item menu</string>
<string name="rename">Rename</string>