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

@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.infrastructure.db.RoomFactory
import com.github.nullptroma.wallenc.infrastructure.db.app.IAppDb import com.github.nullptroma.wallenc.infrastructure.db.app.IAppDb
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageKeyMapDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageMetaInfoDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.YandexAccountDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.YandexAccountDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -33,6 +34,12 @@ class RoomModule {
return database.storageMetaInfoDao return database.storageMetaInfoDao
} }
@Provides
@Singleton
fun provideStorageSyncGroupDao(database: IAppDb): StorageSyncGroupDao {
return database.storageSyncGroupDao
}
@Provides @Provides
@Singleton @Singleton
fun provideYandexAccountDao(database: IAppDb): YandexAccountDao { fun provideYandexAccountDao(database: IAppDb): YandexAccountDao {

View File

@@ -4,9 +4,11 @@ import android.content.Context
import com.github.nullptroma.wallenc.app.di.modules.app.IoDispatcher import com.github.nullptroma.wallenc.app.di.modules.app.IoDispatcher
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageKeyMapDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageMetaInfoDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.YandexAccountDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.YandexAccountDao
import com.github.nullptroma.wallenc.infrastructure.db.app.repository.StorageKeyMapRepository import com.github.nullptroma.wallenc.infrastructure.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.infrastructure.db.app.repository.StorageMetaInfoRepository import com.github.nullptroma.wallenc.infrastructure.db.app.repository.StorageMetaInfoRepository
import com.github.nullptroma.wallenc.infrastructure.db.app.repository.StorageSyncGroupRepository
import com.github.nullptroma.wallenc.infrastructure.db.app.repository.YandexAccountRepository import com.github.nullptroma.wallenc.infrastructure.db.app.repository.YandexAccountRepository
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskApiFactory import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskApiFactory
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository.YandexDiskRepositoryFactory import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository.YandexDiskRepositoryFactory
@@ -15,7 +17,6 @@ import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.Yande
import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.repository.YandexUserInfoRepository import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.repository.YandexUserInfoRepository
import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore
import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore
import com.github.nullptroma.wallenc.app.sync.StorageSyncGroupStore
import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator
import com.github.nullptroma.wallenc.infrastructure.vaults.VaultsManager import com.github.nullptroma.wallenc.infrastructure.vaults.VaultsManager
import com.github.nullptroma.wallenc.infrastructure.vaults.local.LocalVault import com.github.nullptroma.wallenc.infrastructure.vaults.local.LocalVault
@@ -138,8 +139,9 @@ class SingletonModule {
@Provides @Provides
@Singleton @Singleton
fun provideStorageSyncGroupStore( fun provideStorageSyncGroupStore(
@ApplicationContext context: Context, dao: StorageSyncGroupDao,
): IStorageSyncGroupStore = StorageSyncGroupStore(context) @IoDispatcher ioDispatcher: CoroutineDispatcher,
): IStorageSyncGroupStore = StorageSyncGroupRepository(dao, ioDispatcher)
@Provides @Provides
@Singleton @Singleton

View File

@@ -1,87 +0,0 @@
package com.github.nullptroma.wallenc.app.sync
import android.content.Context
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import androidx.core.content.edit
@Singleton
class StorageSyncGroupStore @Inject constructor(
@param:ApplicationContext private val app: Context,
) : IStorageSyncGroupStore {
private val prefs by lazy {
app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
override suspend fun getGroups(): List<StorageSyncGroup> {
val raw = prefs.getString(KEY_GROUPS, null).orEmpty()
return parse(raw)
}
override suspend fun putGroup(group: StorageSyncGroup) {
val current = getGroups().toMutableList()
val idx = current.indexOfFirst { it.id == group.id }
if (idx >= 0) {
current[idx] = group
} else {
current.add(group)
}
persist(current)
}
override suspend fun removeGroup(groupId: String) {
val current = getGroups().filterNot { it.id == groupId }
persist(current)
}
private fun parse(raw: String): List<StorageSyncGroup> {
if (raw.isBlank()) {
return emptyList()
}
return raw.lineSequence()
.mapNotNull { line ->
val trimmed = line.trim()
if (trimmed.isBlank()) {
return@mapNotNull null
}
val split = trimmed.split("=", limit = 2)
if (split.size != 2) {
return@mapNotNull null
}
val id = split[0].trim()
if (id.isBlank()) {
return@mapNotNull null
}
val uuids = split[1]
.split(",")
.mapNotNull { token ->
val value = token.trim()
if (value.isBlank()) {
null
} else {
runCatching { UUID.fromString(value) }.getOrNull()
}
}
.toSet()
StorageSyncGroup(id = id, storageUuids = uuids)
}
.toList()
}
private fun persist(groups: List<StorageSyncGroup>) {
val raw = groups.joinToString(separator = "\n") { group ->
val uuids = group.storageUuids.joinToString(separator = ",") { it.toString() }
"${group.id}=$uuids"
}
prefs.edit { putString(KEY_GROUPS, raw) }
}
private companion object {
private const val PREFS_NAME = "wallenc_storage_sync"
private const val KEY_GROUPS = "groups"
}
}

View File

@@ -30,6 +30,15 @@ class UnlockManager(
get() = _openedStorages get() = _openedStorages
private val mutex = Mutex() private val mutex = Mutex()
override fun getOpenedStorageKey(uuid: UUID): EncryptKey? {
val opened = _openedStorages.value
val direct = opened[uuid]
if (direct != null) {
return direct.getKey()
}
return null
}
init { init {
CoroutineScope(ioDispatcher).launch { CoroutineScope(ioDispatcher).launch {
vaultsManager.allStorages.collect { vaultsManager.allStorages.collect {

View File

@@ -53,6 +53,8 @@ class EncryptedStorage private constructor(
throw Exception("Incorrect key") // TODO throw Exception("Incorrect key") // TODO
} }
fun getKey(): EncryptKey = EncryptKey(key.bytes)
override fun dispose() { override fun dispose() {
_accessor.dispose() _accessor.dispose()
job.cancel() job.cancel()

View File

@@ -263,14 +263,7 @@ class EncryptedStorageAccessor(
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
} }
override suspend fun openWrite(path: String): OutputStream { override suspend fun openWrite(path: String): OutputStream = openWriteInternal(path, recordJournal = true)
val stream = source.openWrite(encryptPath(path))
return dataEncryptor.encryptStream(stream).onClosed {
scope.launch {
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
}
}
}
override suspend fun openRead(path: String): InputStream { override suspend fun openRead(path: String): InputStream {
val stream = source.openRead(encryptPath(path)) val stream = source.openRead(encryptPath(path))
@@ -290,7 +283,7 @@ class EncryptedStorageAccessor(
val path = Path(systemHiddenDirName, name).pathString val path = Path(systemHiddenDirName, name).pathString
return@run try { return@run try {
openRead(path) openRead(path)
} catch (_: FileNotFoundException) { } catch (_: Exception) {
// Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова. // Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова.
openWriteSystemFile(name).use { } openWriteSystemFile(name).use { }
openRead(path) openRead(path)
@@ -300,7 +293,7 @@ class EncryptedStorageAccessor(
override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run { override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString val path = Path(systemHiddenDirName, name).pathString
systemHiddenFilesIsActual = false systemHiddenFilesIsActual = false
return@run openWrite(path).onClosing { return@run openWriteInternal(path, recordJournal = false).onClosing {
systemHiddenFilesIsActual = false systemHiddenFilesIsActual = false
} }
} }
@@ -393,6 +386,9 @@ class EncryptedStorageAccessor(
private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) {
val cleanedPath = if (path.startsWith("/")) path else "/$path" val cleanedPath = if (path.startsWith("/")) path else "/$path"
if (cleanedPath.startsWith("/$systemHiddenDirName/")) {
return
}
val entries = readSyncJournal() val entries = readSyncJournal()
val nextSequence = (entries.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L val nextSequence = (entries.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L
appendSyncJournal( appendSyncJournal(
@@ -410,6 +406,19 @@ class EncryptedStorageAccessor(
) )
} }
private suspend fun openWriteInternal(path: String, recordJournal: Boolean): OutputStream {
val stream = source.openWrite(encryptPath(path))
val encrypted = dataEncryptor.encryptStream(stream)
if (!recordJournal) {
return encrypted
}
return encrypted.onClosed {
scope.launch {
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
}
}
}
private fun Iterable<IFile>.filterSystemHiddenFiles(): List<IFile> { private fun Iterable<IFile>.filterSystemHiddenFiles(): List<IFile> {
return this.filter { file -> return this.filter { file ->
!file.metaInfo.path.contains( !file.metaInfo.path.contains(

View File

@@ -13,6 +13,7 @@ interface IUnlockManager {
* Хранилища, для которых есть ключ шифрования * Хранилища, для которых есть ключ шифрования
*/ */
val openedStorages: StateFlow<Map<UUID, IStorage>> val openedStorages: StateFlow<Map<UUID, IStorage>>
fun getOpenedStorageKey(uuid: UUID): EncryptKey?
suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage
suspend fun close(storage: IStorage) suspend fun close(storage: IStorage)

View File

@@ -2,9 +2,18 @@ package com.github.nullptroma.wallenc.domain.interfaces
import java.util.UUID import java.util.UUID
enum class StorageSyncGroupEncryptionKind {
UNSET,
NONE,
PASSWORD,
}
data class StorageSyncGroup( data class StorageSyncGroup(
val id: String, val id: String,
val storageUuids: Set<UUID>, val storageUuids: Set<UUID>,
val encryptionKind: StorageSyncGroupEncryptionKind = StorageSyncGroupEncryptionKind.UNSET,
/** Локально сохранённый секрет группы для сопоставления совместимости (не уходит наружу). */
val encryptionSecret: String? = null,
) )
interface IStorageSyncGroupStore { interface IStorageSyncGroupStore {

View File

@@ -4,24 +4,28 @@ import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageKeyMapDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageMetaInfoDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.infrastructure.db.app.dao.YandexAccountDao import com.github.nullptroma.wallenc.infrastructure.db.app.dao.YandexAccountDao
import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageKeyMap import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageKeyMap
import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageMetaInfo import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageMetaInfo
import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageSyncGroup
import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbYandexAccount import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbYandexAccount
interface IAppDb { interface IAppDb {
val storageKeyMapDao: StorageKeyMapDao val storageKeyMapDao: StorageKeyMapDao
val storageMetaInfoDao: StorageMetaInfoDao val storageMetaInfoDao: StorageMetaInfoDao
val storageSyncGroupDao: StorageSyncGroupDao
val yandexAccountDao: YandexAccountDao val yandexAccountDao: YandexAccountDao
} }
@Database( @Database(
entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class], entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class, DbStorageSyncGroup::class],
version = 4, version = 5,
exportSchema = false exportSchema = false
) )
abstract class AppDb : IAppDb, RoomDatabase() { abstract class AppDb : IAppDb, RoomDatabase() {
abstract override val storageKeyMapDao: StorageKeyMapDao abstract override val storageKeyMapDao: StorageKeyMapDao
abstract override val storageMetaInfoDao: StorageMetaInfoDao abstract override val storageMetaInfoDao: StorageMetaInfoDao
abstract override val storageSyncGroupDao: StorageSyncGroupDao
abstract override val yandexAccountDao: YandexAccountDao abstract override val yandexAccountDao: YandexAccountDao
} }

View File

@@ -0,0 +1,19 @@
package com.github.nullptroma.wallenc.infrastructure.db.app.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageSyncGroup
@Dao
interface StorageSyncGroupDao {
@Query("SELECT * FROM storage_sync_groups")
suspend fun getAll(): List<DbStorageSyncGroup>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(group: DbStorageSyncGroup)
@Query("DELETE FROM storage_sync_groups WHERE group_id = :groupId")
suspend fun deleteById(groupId: String)
}

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.infrastructure.db.app.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "storage_sync_groups")
data class DbStorageSyncGroup(
@PrimaryKey @ColumnInfo(name = "group_id") val id: String,
@ColumnInfo(name = "storage_uuids_csv") val storageUuidsCsv: String,
@ColumnInfo(name = "encryption_kind") val encryptionKind: String,
@ColumnInfo(name = "encryption_secret") val encryptionSecret: String?,
)

View File

@@ -0,0 +1,91 @@
package com.github.nullptroma.wallenc.infrastructure.db.app.repository
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 com.github.nullptroma.wallenc.infrastructure.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageSyncGroup
import com.github.nullptroma.wallenc.infrastructure.utils.IProvider
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.util.UUID
class StorageSyncGroupRepository(
private val dao: StorageSyncGroupDao,
private val ioDispatcher: CoroutineDispatcher,
) : IStorageSyncGroupStore {
private val allGroupsProvider: IProvider<List<StorageSyncGroup>> = object : IProvider<List<StorageSyncGroup>> {
override suspend fun get(): List<StorageSyncGroup> = dao.getAll().mapNotNull(::toDomain)
override suspend fun set(value: List<StorageSyncGroup>) {
val desiredIds = value.map { it.id }.toSet()
val current = dao.getAll()
current
.asSequence()
.filter { it.id !in desiredIds }
.forEach { dao.deleteById(it.id) }
value.forEach { group ->
dao.upsert(toDb(group))
}
}
override suspend fun clear() {
dao.getAll().forEach { dao.deleteById(it.id) }
}
}
override suspend fun getGroups(): List<StorageSyncGroup> = withContext(ioDispatcher) {
allGroupsProvider.get().orEmpty()
}
override suspend fun putGroup(group: StorageSyncGroup) = withContext(ioDispatcher) {
val all = allGroupsProvider.get().orEmpty().toMutableList()
val idx = all.indexOfFirst { it.id == group.id }
if (idx >= 0) {
all[idx] = group
} else {
all.add(group)
}
allGroupsProvider.set(all)
}
override suspend fun removeGroup(groupId: String) = withContext(ioDispatcher) {
val all = allGroupsProvider.get().orEmpty().filterNot { it.id == groupId }
allGroupsProvider.set(all)
}
private fun toDb(group: StorageSyncGroup): DbStorageSyncGroup = DbStorageSyncGroup(
id = group.id,
storageUuidsCsv = group.storageUuids.joinToString(",") { it.toString() },
encryptionKind = group.encryptionKind.name,
encryptionSecret = group.encryptionSecret,
)
private fun toDomain(db: DbStorageSyncGroup): StorageSyncGroup? {
val kind = runCatching {
StorageSyncGroupEncryptionKind.valueOf(db.encryptionKind)
}.getOrElse {
StorageSyncGroupEncryptionKind.UNSET
}
if (db.id.isBlank()) {
return null
}
val uuids = db.storageUuidsCsv
.split(",")
.mapNotNull { token ->
val value = token.trim()
if (value.isBlank()) {
null
} else {
runCatching { UUID.fromString(value) }.getOrNull()
}
}
.toSet()
return StorageSyncGroup(
id = db.id,
storageUuids = uuids,
encryptionKind = kind,
encryptionSecret = db.encryptionSecret?.takeIf { it.isNotBlank() },
)
}
}

View File

@@ -93,7 +93,7 @@ fun StorageHomeScreen(
title = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount), title = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount),
description = stringResource(R.string.storage_home_two_fa_subtitle), description = stringResource(R.string.storage_home_two_fa_subtitle),
icon = Icons.Outlined.Lock, icon = Icons.Outlined.Lock,
enabled = uiState.isAvailable, enabled = uiState.canManageDomainData,
onClick = { onOpenTwoFa(uiState.storageUuid) }, onClick = { onOpenTwoFa(uiState.storageUuid) },
) )
@@ -101,7 +101,7 @@ fun StorageHomeScreen(
title = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount), title = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount),
description = stringResource(R.string.storage_home_text_secrets_subtitle), description = stringResource(R.string.storage_home_text_secrets_subtitle),
icon = Icons.Outlined.Notes, icon = Icons.Outlined.Notes,
enabled = uiState.isAvailable, enabled = uiState.canManageDomainData,
onClick = { onOpenTextSecrets(uiState.storageUuid) }, onClick = { onOpenTextSecrets(uiState.storageUuid) },
) )

View File

@@ -9,7 +9,9 @@ data class StorageHomeScreenState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isAvailable: Boolean = false, val isAvailable: Boolean = false,
val isEncrypted: Boolean = false, val isEncrypted: Boolean = false,
val isVirtualStorage: Boolean = false,
val twoFaCount: Int = 0, val twoFaCount: Int = 0,
val textSecretsCount: Int = 0, val textSecretsCount: Int = 0,
val canManageDomainData: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
) )

View File

@@ -44,15 +44,23 @@ class StorageHomeViewModel @Inject constructor(
manageTwoFaTokensUseCase.observe(storage), manageTwoFaTokensUseCase.observe(storage),
manageTextSecretsUseCase.observe(storage), manageTextSecretsUseCase.observe(storage),
) { available, meta, twoFa, secrets -> ) { available, meta, twoFa, secrets ->
val isRawEncrypted = meta.encInfo != null && !storage.isVirtualStorage
val canManageDomainData = available && !isRawEncrypted
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
storageUuid = storage.uuid.toString(), storageUuid = storage.uuid.toString(),
storageName = meta.name.orEmpty(), storageName = meta.name.orEmpty(),
isAvailable = available, isAvailable = available,
isEncrypted = meta.encInfo != null, isEncrypted = meta.encInfo != null,
isVirtualStorage = storage.isVirtualStorage,
twoFaCount = twoFa.size, twoFaCount = twoFa.size,
textSecretsCount = secrets.size, textSecretsCount = secrets.size,
errorMessage = null, canManageDomainData = canManageDomainData,
errorMessage = if (isRawEncrypted) {
"Откройте расшифрованное отображение storage для работы с 2FA и секретами"
} else {
null
},
) )
}.collect { ui -> }.collect { ui ->
updateState(ui) updateState(ui)

View File

@@ -49,6 +49,16 @@ class TextSecretDetailsViewModel @Inject constructor(
) )
return@launch return@launch
} }
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
isLoading = false,
isAvailable = false,
errorMessage = "Откройте расшифрованное отображение storage для просмотра и редактирования секрета",
),
)
return@launch
}
combine( combine(
storage.isAvailable, storage.isAvailable,
manageTextSecretsUseCase.observe(storage).map { list -> manageTextSecretsUseCase.observe(storage).map { list ->
@@ -76,6 +86,14 @@ class TextSecretDetailsViewModel @Inject constructor(
fun delete(onDeleted: () -> Unit) { fun delete(onDeleted: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
errorMessage = "Откройте расшифрованное отображение storage для просмотра и редактирования секрета",
),
)
return@launch
}
val taskId = taskOrchestrator.enqueue( val taskId = taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_delete_text_secret), title = uiStrings(R.string.task_title_delete_text_secret),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,

View File

@@ -42,6 +42,7 @@ fun TextSecretEditScreen(
) { ) {
val uiState by viewModel.state.collectAsStateWithLifecycle() val uiState by viewModel.state.collectAsStateWithLifecycle()
val currentOnSaved by rememberUpdatedState(onSaved) val currentOnSaved by rememberUpdatedState(onSaved)
val inputEnabled = uiState.isAvailable && !uiState.isMutating && uiState.errorMessage == null
var title by remember(uiState.initialSecret) { var title by remember(uiState.initialSecret) {
mutableStateOf(uiState.initialSecret?.title.orEmpty()) mutableStateOf(uiState.initialSecret?.title.orEmpty())
@@ -74,13 +75,16 @@ fun TextSecretEditScreen(
stringResource(R.string.text_secret_edit) stringResource(R.string.text_secret_edit)
}, },
) )
uiState.errorMessage?.let { err ->
Text(text = err)
}
if (uiState.isMutating) { if (uiState.isMutating) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} }
OutlinedTextField( OutlinedTextField(
value = title, value = title,
onValueChange = { title = it }, onValueChange = { title = it },
enabled = !uiState.isMutating, enabled = inputEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.text_secret_title)) }, label = { Text(stringResource(R.string.text_secret_title)) },
) )
@@ -99,7 +103,7 @@ fun TextSecretEditScreen(
onValueChange = { newLabel -> onValueChange = { newLabel ->
items[index] = item.copy(label = newLabel.ifBlank { null }) items[index] = item.copy(label = newLabel.ifBlank { null })
}, },
enabled = !uiState.isMutating, enabled = inputEnabled,
modifier = Modifier.weight(0.45f), modifier = Modifier.weight(0.45f),
label = { Text(stringResource(R.string.text_secret_item_label_optional)) }, label = { Text(stringResource(R.string.text_secret_item_label_optional)) },
) )
@@ -108,12 +112,12 @@ fun TextSecretEditScreen(
onValueChange = { newValue -> onValueChange = { newValue ->
items[index] = item.copy(value = newValue) items[index] = item.copy(value = newValue)
}, },
enabled = !uiState.isMutating, enabled = inputEnabled,
modifier = Modifier.weight(0.55f), modifier = Modifier.weight(0.55f),
label = { Text(stringResource(R.string.text_secret_item_value)) }, label = { Text(stringResource(R.string.text_secret_item_value)) },
) )
IconButton( IconButton(
enabled = !uiState.isMutating, enabled = inputEnabled,
onClick = { onClick = {
if (items.size > 1) { if (items.size > 1) {
items.removeAt(index) items.removeAt(index)
@@ -131,7 +135,7 @@ fun TextSecretEditScreen(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton( TextButton(
onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) }, onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) },
enabled = !uiState.isMutating, enabled = inputEnabled,
) { ) {
Icon(Icons.Default.Add, contentDescription = null) Icon(Icons.Default.Add, contentDescription = null)
Text(stringResource(R.string.text_secret_add_item)) Text(stringResource(R.string.text_secret_add_item))
@@ -144,7 +148,7 @@ fun TextSecretEditScreen(
onSaved = currentOnSaved, onSaved = currentOnSaved,
) )
}, },
enabled = title.isNotBlank() && !uiState.isMutating, enabled = title.isNotBlank() && inputEnabled,
) { ) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
} }

View File

@@ -53,6 +53,16 @@ class TextSecretEditViewModel @Inject constructor(
) )
return@launch return@launch
} }
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
isLoading = false,
isAvailable = false,
errorMessage = "Откройте расшифрованное отображение storage для редактирования секрета",
),
)
return@launch
}
val initial = secretId?.let { id -> manageTextSecretsUseCase.get(storage, id) } val initial = secretId?.let { id -> manageTextSecretsUseCase.get(storage, id) }
combine( combine(
storage.isAvailable, storage.isAvailable,
@@ -83,6 +93,14 @@ class TextSecretEditViewModel @Inject constructor(
) { ) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
errorMessage = "Откройте расшифрованное отображение storage для редактирования секрета",
),
)
return@launch
}
val existingId = secretId val existingId = secretId
val targetSecretId = existingId ?: UUID.randomUUID().toString() val targetSecretId = existingId ?: UUID.randomUUID().toString()
val taskId = taskOrchestrator.enqueue( val taskId = taskOrchestrator.enqueue(

View File

@@ -36,6 +36,16 @@ class TextSecretsViewModel @Inject constructor(
) )
return@launch return@launch
} }
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
isLoading = false,
isAvailable = false,
errorMessage = "Откройте расшифрованное отображение storage для работы с секретами",
),
)
return@launch
}
combine( combine(
storage.isAvailable, storage.isAvailable,
manageTextSecretsUseCase.observe(storage), manageTextSecretsUseCase.observe(storage),

View File

@@ -45,6 +45,16 @@ class TwoFaTokensViewModel @Inject constructor(
) )
return@launch return@launch
} }
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
isLoading = false,
isAvailable = false,
errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA",
),
)
return@launch
}
combine( combine(
storage.isAvailable, storage.isAvailable,
manageTwoFaTokensUseCase.observe(storage), manageTwoFaTokensUseCase.observe(storage),
@@ -76,6 +86,14 @@ class TwoFaTokensViewModel @Inject constructor(
) { ) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA",
),
)
return@launch
}
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_save_2fa_token), title = uiStrings(R.string.task_title_save_2fa_token),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
@@ -109,6 +127,14 @@ class TwoFaTokensViewModel @Inject constructor(
fun deleteToken(id: String) { fun deleteToken(id: String) {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState(
state.value.copy(
errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA",
),
)
return@launch
}
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_delete_2fa_token), title = uiStrings(R.string.task_title_delete_2fa_token),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,

View File

@@ -49,6 +49,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -57,27 +58,6 @@ fun StorageSyncScreen(
viewModel: StorageSyncViewModel = hiltViewModel(), viewModel: StorageSyncViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() 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 }
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(state.userMessage) { LaunchedEffect(state.userMessage) {
@@ -96,6 +76,28 @@ fun StorageSyncScreen(
viewModel.consumeUserMessage() viewModel.consumeUserMessage()
} }
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,
snackbarHostState = snackbarHostState,
)
return
}
val storageByUuid = state.vaults
.flatMap { vault -> flattenStorageTree(vault.storages) }
.associateBy { it.uuid }
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
@@ -232,14 +234,24 @@ fun StorageSyncScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults) if (group.incompatibleStorageUuids.isNotEmpty()) {
if (hasMixedEncryption) {
Text( Text(
text = stringResource(id = R.string.sync_group_mixed_encryption_warning), text = stringResource(
id = R.string.sync_group_incompatible_warning,
group.incompatibleStorageUuids.size,
),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
Text(
text = stringResource(
R.string.sync_group_policy_line,
groupPolicyLabel(group.encryptionKind),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
group.storageUuids.forEach { storageUuid -> group.storageUuids.forEach { storageUuid ->
val storage = storageByUuid[storageUuid] val storage = storageByUuid[storageUuid]
@@ -426,12 +438,14 @@ private fun StoragePickerScreen(
onBack: () -> Unit, onBack: () -> Unit,
onAddStorage: (UUID) -> Unit, onAddStorage: (UUID) -> Unit,
onToggleVault: (UUID) -> Unit, onToggleVault: (UUID) -> Unit,
snackbarHostState: SnackbarHostState,
) { ) {
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet() val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
val groupEditLocked = state.isBusy || state.isStorageSyncRunning val groupEditLocked = state.isBusy || state.isStorageSyncRunning
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -618,15 +632,11 @@ private fun flattenStorageTree(nodes: List<StorageSyncStorageUi>): List<StorageS
} }
} }
private fun hasEncryptionMismatch( @Composable
group: StorageSyncGroupUi, private fun groupPolicyLabel(kind: StorageSyncGroupEncryptionKind): String {
vaults: List<StorageSyncVaultUi>, return when (kind) {
): Boolean { StorageSyncGroupEncryptionKind.UNSET -> stringResource(R.string.sync_group_policy_unset)
if (group.storageUuids.isEmpty()) return false StorageSyncGroupEncryptionKind.NONE -> stringResource(R.string.sync_group_policy_plain)
val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid } StorageSyncGroupEncryptionKind.PASSWORD -> stringResource(R.string.sync_group_policy_password)
val kinds = group.storageUuids.mapNotNull { byUuid[it]?.encryptionKind }.toSet() }
if (kinds.isEmpty()) return false
val hasEncrypted = kinds.any { it != StorageSyncEncryptionKind.NotEncrypted }
val hasPlain = kinds.contains(StorageSyncEncryptionKind.NotEncrypted)
return hasEncrypted && hasPlain
} }

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.ui.screens.sync package com.github.nullptroma.wallenc.ui.screens.sync
import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.UUID import java.util.UUID
enum class StorageSyncEncryptionKind { enum class StorageSyncEncryptionKind {
@@ -28,6 +29,8 @@ data class StorageSyncVaultUi(
data class StorageSyncGroupUi( data class StorageSyncGroupUi(
val id: String, val id: String,
val storageUuids: Set<UUID>, val storageUuids: Set<UUID>,
val encryptionKind: StorageSyncGroupEncryptionKind = StorageSyncGroupEncryptionKind.UNSET,
val incompatibleStorageUuids: Set<UUID> = emptySet(),
) )
data class StorageSyncScreenState( data class StorageSyncScreenState(

View File

@@ -10,8 +10,12 @@ import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.usecases.StorageSyncCompatibilityInput
import com.github.nullptroma.wallenc.usecases.isStorageCompatibleWithGroup
import com.github.nullptroma.wallenc.usecases.storageEncryptionSecret
import com.github.nullptroma.wallenc.vault.contract.DescribedVault import com.github.nullptroma.wallenc.vault.contract.DescribedVault
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -33,6 +37,7 @@ class StorageSyncViewModel @Inject constructor(
private val taskOrchestrator: ITaskOrchestrator, private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver, private val uiStrings: UiStringResolver,
) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) { ) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) {
private var storageByUuid: Map<UUID, IStorage> = emptyMap()
init { init {
refreshGroups() refreshGroups()
@@ -144,11 +149,40 @@ class StorageSyncViewModel @Inject constructor(
val groupId = state.value.pickerGroupId ?: return val groupId = state.value.pickerGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
withGroupMutationBusy { withGroupMutationBusy {
groupsUseCase.addStorageToGroup(groupId, storageUuid) val storage = storageByUuid[storageUuid]
UserNotification.TextRes( if (storage == null) {
return@withGroupMutationBusy UserNotification.TextRes(R.string.sync_storage_not_in_vaults)
}
val isEncrypted = storage.metaInfo.value.encInfo != null
val secret = vaultsManager.unlockManager
.getOpenedStorageKey(storageUuid)
?.let(::storageEncryptionSecret)
val result = groupsUseCase.addStorageToGroup(
groupId = groupId,
storageUuid = storageUuid,
compatibility = StorageSyncCompatibilityInput(
isEncrypted = isEncrypted,
encryptionSecret = secret,
),
)
when (result) {
AddStorageToSyncGroupResult.Added -> UserNotification.TextRes(
R.string.sync_msg_storage_added, R.string.sync_msg_storage_added,
listOf(groupId), listOf(groupId),
) )
AddStorageToSyncGroupResult.AlreadyInGroup -> UserNotification.TextRes(
R.string.sync_msg_storage_already_added,
)
AddStorageToSyncGroupResult.MissingEncryptionSecret -> UserNotification.TextRes(
R.string.sync_msg_storage_encryption_key_required,
)
AddStorageToSyncGroupResult.IncompatibleEncryption -> UserNotification.TextRes(
R.string.sync_msg_storage_incompatible_encryption,
)
AddStorageToSyncGroupResult.GroupNotFound -> UserNotification.TextRes(
R.string.sync_msg_group_removed,
)
}
} }
} }
} }
@@ -205,6 +239,7 @@ class StorageSyncViewModel @Inject constructor(
val allStorages = vaultNodes val allStorages = vaultNodes
.flatMap { (_, trees) -> trees.flatMap(::flattenStorages) } .flatMap { (_, trees) -> trees.flatMap(::flattenStorages) }
.distinctBy { it.uuid } .distinctBy { it.uuid }
storageByUuid = allStorages.associateBy { it.uuid }
if (allStorages.isEmpty()) { if (allStorages.isEmpty()) {
flowOf( flowOf(
@@ -245,7 +280,12 @@ class StorageSyncViewModel @Inject constructor(
} }
} }
.collect { mapped -> .collect { mapped ->
updateState(state.value.copy(vaults = mapped)) updateState(
state.value.copy(
vaults = mapped,
groups = reloadGroupsUi(),
),
)
} }
} }
} }
@@ -332,7 +372,22 @@ class StorageSyncViewModel @Inject constructor(
) )
private suspend fun reloadGroupsUi(): List<StorageSyncGroupUi> = private suspend fun reloadGroupsUi(): List<StorageSyncGroupUi> =
groupsUseCase.getGroups().map { StorageSyncGroupUi(it.id, it.storageUuids) } groupsUseCase.getGroups().map { group ->
val incompatible = group.storageUuids.filterTo(mutableSetOf()) { uuid ->
val storage = storageByUuid[uuid] ?: return@filterTo true
!isStorageCompatibleWithGroup(
storage = storage,
group = group,
resolveStorageKey = vaultsManager.unlockManager::getOpenedStorageKey,
)
}
StorageSyncGroupUi(
id = group.id,
storageUuids = group.storageUuids,
encryptionKind = group.encryptionKind,
incompatibleStorageUuids = incompatible,
)
}
private suspend fun withGroupMutationBusy( private suspend fun withGroupMutationBusy(
clearPicker: Boolean = false, clearPicker: Boolean = false,

View File

@@ -34,6 +34,11 @@
<string name="sync_picker_collapse">Свернуть</string> <string name="sync_picker_collapse">Свернуть</string>
<string name="sync_fab_create_group_cd">Создать группу синхронизации</string> <string name="sync_fab_create_group_cd">Создать группу синхронизации</string>
<string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string> <string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string>
<string name="sync_group_incompatible_warning">Несовместимые хранилища в группе: %1$d</string>
<string name="sync_group_policy_line">Политика шифрования группы: %1$s</string>
<string name="sync_group_policy_unset">Не определена (группа пуста)</string>
<string name="sync_group_policy_plain">Только незашифрованные</string>
<string name="sync_group_policy_password">Только зашифрованные с паролем группы</string>
<string name="sync_remove_group_confirm_title">Удалить группу?</string> <string name="sync_remove_group_confirm_title">Удалить группу?</string>
<string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string> <string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string>
<string name="sync_remove_storage_confirm_title">Убрать хранилище?</string> <string name="sync_remove_storage_confirm_title">Убрать хранилище?</string>
@@ -44,6 +49,10 @@
<string name="sync_msg_group_removed">Группа удалена</string> <string name="sync_msg_group_removed">Группа удалена</string>
<string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string> <string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string>
<string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string> <string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string>
<string name="sync_msg_storage_already_added">Хранилище уже добавлено в группу</string>
<string name="sync_msg_storage_encryption_key_required">Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением)</string>
<string name="sync_msg_storage_incompatible_encryption">Хранилище не совместимо с политикой шифрования группы</string>
<string name="sync_msg_virtual_storage_not_supported">Нельзя добавлять открытое виртуальное хранилище: синхронизация работает с исходными raw storage</string>
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string> <string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string>
<string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string> <string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string>
<string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string> <string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string>

View File

@@ -8,8 +8,11 @@ class FindStorageUseCase(
private val vaultsManager: IVaultsManager, private val vaultsManager: IVaultsManager,
) { ) {
fun find(storageUuid: UUID): IStorage? { fun find(storageUuid: UUID): IStorage? {
return vaultsManager.vaults.value return (
.flatMap { it.storages.value } vaultsManager.allStorages.value +
vaultsManager.unlockManager.openedStorages.value.values
)
.distinctBy { it.uuid }
.firstOrNull { it.uuid == storageUuid } .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.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.UUID 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( class ManageStorageSyncGroupsUseCase(
private val groupStore: IStorageSyncGroupStore, private val groupStore: IStorageSyncGroupStore,
) { ) {
@@ -17,7 +31,12 @@ class ManageStorageSyncGroupsUseCase(
index++ index++
candidate = "group-$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) groupStore.putGroup(group)
return group return group
} }
@@ -26,17 +45,68 @@ class ManageStorageSyncGroupsUseCase(
groupStore.removeGroup(groupId.trim()) groupStore.removeGroup(groupId.trim())
} }
suspend fun addStorageToGroup(groupId: String, storageUuid: UUID) { suspend fun addStorageToGroup(
val current = getGroups().firstOrNull { it.id == groupId } ?: return 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( 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) { suspend fun removeStorageFromGroup(groupId: String, storageUuid: UUID) {
val current = getGroups().firstOrNull { it.id == groupId } ?: return val current = getGroups().firstOrNull { it.id == groupId } ?: return
val remaining = current.storageUuids - storageUuid
groupStore.putGroup( 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)") reportProgress(null, "Storage sync: group \"$groupId\" skipped (need at least 2 storages)")
return 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) var leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
val lockedAccessors = mutableListOf<IStorageAccessor>() val lockedAccessors = mutableListOf<IStorageAccessor>()