feat(sync): перевёл группы синхронизации на Room и добавил контроль совместимости

This commit is contained in:
2026-05-17 18:03:14 +03:00
parent 15f13577c8
commit e562e4d9e9
28 changed files with 518 additions and 159 deletions

View File

@@ -8,8 +8,11 @@ class FindStorageUseCase(
private val vaultsManager: IVaultsManager,
) {
fun find(storageUuid: UUID): IStorage? {
return vaultsManager.vaults.value
.flatMap { it.storages.value }
return (
vaultsManager.allStorages.value +
vaultsManager.unlockManager.openedStorages.value.values
)
.distinctBy { it.uuid }
.firstOrNull { it.uuid == storageUuid }
}
}

View File

@@ -2,8 +2,22 @@ package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.UUID
data class StorageSyncCompatibilityInput(
val isEncrypted: Boolean,
val encryptionSecret: String? = null,
)
sealed interface AddStorageToSyncGroupResult {
data object Added : AddStorageToSyncGroupResult
data object GroupNotFound : AddStorageToSyncGroupResult
data object AlreadyInGroup : AddStorageToSyncGroupResult
data object IncompatibleEncryption : AddStorageToSyncGroupResult
data object MissingEncryptionSecret : AddStorageToSyncGroupResult
}
class ManageStorageSyncGroupsUseCase(
private val groupStore: IStorageSyncGroupStore,
) {
@@ -17,7 +31,12 @@ class ManageStorageSyncGroupsUseCase(
index++
candidate = "group-$index"
}
val group = StorageSyncGroup(id = candidate, storageUuids = emptySet())
val group = StorageSyncGroup(
id = candidate,
storageUuids = emptySet(),
encryptionKind = StorageSyncGroupEncryptionKind.UNSET,
encryptionSecret = null,
)
groupStore.putGroup(group)
return group
}
@@ -26,17 +45,68 @@ class ManageStorageSyncGroupsUseCase(
groupStore.removeGroup(groupId.trim())
}
suspend fun addStorageToGroup(groupId: String, storageUuid: UUID) {
val current = getGroups().firstOrNull { it.id == groupId } ?: return
suspend fun addStorageToGroup(
groupId: String,
storageUuid: UUID,
compatibility: StorageSyncCompatibilityInput,
): AddStorageToSyncGroupResult {
val current = getGroups().firstOrNull { it.id == groupId }
?: return AddStorageToSyncGroupResult.GroupNotFound
if (storageUuid in current.storageUuids) {
return AddStorageToSyncGroupResult.AlreadyInGroup
}
val encryptedSecret = compatibility.encryptionSecret?.takeIf { it.isNotBlank() }
val effectiveEncryption = when {
!compatibility.isEncrypted -> StorageSyncGroupEncryptionKind.NONE to null
encryptedSecret == null -> return AddStorageToSyncGroupResult.MissingEncryptionSecret
else -> StorageSyncGroupEncryptionKind.PASSWORD to encryptedSecret
}
val (nextKind, nextSecret) = when (current.encryptionKind) {
StorageSyncGroupEncryptionKind.UNSET -> effectiveEncryption
StorageSyncGroupEncryptionKind.NONE -> {
if (effectiveEncryption.first != StorageSyncGroupEncryptionKind.NONE) {
return AddStorageToSyncGroupResult.IncompatibleEncryption
}
StorageSyncGroupEncryptionKind.NONE to null
}
StorageSyncGroupEncryptionKind.PASSWORD -> {
if (
effectiveEncryption.first != StorageSyncGroupEncryptionKind.PASSWORD ||
current.encryptionSecret != effectiveEncryption.second
) {
return AddStorageToSyncGroupResult.IncompatibleEncryption
}
StorageSyncGroupEncryptionKind.PASSWORD to current.encryptionSecret
}
}
groupStore.putGroup(
current.copy(storageUuids = current.storageUuids + storageUuid),
current.copy(
storageUuids = current.storageUuids + storageUuid,
encryptionKind = nextKind,
encryptionSecret = nextSecret,
),
)
return AddStorageToSyncGroupResult.Added
}
suspend fun removeStorageFromGroup(groupId: String, storageUuid: UUID) {
val current = getGroups().firstOrNull { it.id == groupId } ?: return
val remaining = current.storageUuids - storageUuid
groupStore.putGroup(
current.copy(storageUuids = current.storageUuids - storageUuid),
current.copy(
storageUuids = remaining,
encryptionKind = if (remaining.isEmpty()) {
StorageSyncGroupEncryptionKind.UNSET
} else {
current.encryptionKind
},
encryptionSecret = if (remaining.isEmpty()) null else current.encryptionSecret,
),
)
}
}

View File

@@ -0,0 +1,30 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.Base64
import java.util.UUID
fun storageEncryptionSecret(key: EncryptKey): String =
Base64.getEncoder().encodeToString(key.bytes)
fun isStorageCompatibleWithGroup(
storage: IStorage,
group: StorageSyncGroup,
resolveStorageKey: (UUID) -> EncryptKey?,
): Boolean {
return when (group.encryptionKind) {
StorageSyncGroupEncryptionKind.UNSET -> true
StorageSyncGroupEncryptionKind.NONE -> storage.metaInfo.value.encInfo == null
StorageSyncGroupEncryptionKind.PASSWORD -> {
val groupSecret = group.encryptionSecret ?: return false
if (storage.metaInfo.value.encInfo == null) {
return false
}
val currentSecret = resolveStorageKey(storage.uuid)?.let(::storageEncryptionSecret)
currentSecret != null && currentSecret == groupSecret
}
}
}

View File

@@ -72,6 +72,20 @@ class StorageSyncEngine(
reportProgress(null, "Storage sync: group \"$groupId\" skipped (need at least 2 storages)")
return
}
val incompatible = storages.filterNot { storage ->
isStorageCompatibleWithGroup(
storage = storage,
group = group,
resolveStorageKey = vaultsManager.unlockManager::getOpenedStorageKey,
)
}
if (incompatible.isNotEmpty()) {
reportProgress(
null,
"Storage sync: group \"$groupId\" skipped (incompatible encryption: ${incompatible.size})",
)
return
}
var leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
val lockedAccessors = mutableListOf<IStorageAccessor>()