From e562e4d9e9437f26f8a5bb5de8a1c64bf30664cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 17 May 2026 18:03:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=91=D0=BB=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BD=D0=B0=20Room=20=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallenc/app/di/modules/data/RoomModule.kt | 7 ++ .../app/di/modules/data/SingletonModule.kt | 8 +- .../wallenc/app/sync/StorageSyncGroupStore.kt | 87 ------------------ .../infrastructure/storages/UnlockManager.kt | 9 ++ .../storages/encrypt/EncryptedStorage.kt | 2 + .../encrypt/EncryptedStorageAccessor.kt | 29 ++++-- .../domain/interfaces/IUnlockManager.kt | 1 + .../domain/interfaces/StorageSyncContracts.kt | 9 ++ .../wallenc/infrastructure/db/app/AppDb.kt | 8 +- .../db/app/dao/StorageSyncGroupDao.kt | 19 ++++ .../db/app/model/DbStorageSyncGroup.kt | 13 +++ .../repository/StorageSyncGroupRepository.kt | 91 +++++++++++++++++++ .../main/screens/storage/StorageHomeScreen.kt | 4 +- .../screens/storage/StorageHomeScreenState.kt | 2 + .../screens/storage/StorageHomeViewModel.kt | 10 +- .../secrets/TextSecretDetailsViewModel.kt | 18 ++++ .../storage/secrets/TextSecretEditScreen.kt | 16 ++-- .../secrets/TextSecretEditViewModel.kt | 18 ++++ .../storage/secrets/TextSecretsViewModel.kt | 10 ++ .../storage/twofa/TwoFaTokensViewModel.kt | 26 ++++++ .../ui/screens/sync/StorageSyncScreen.kt | 80 +++++++++------- .../ui/screens/sync/StorageSyncScreenState.kt | 3 + .../ui/screens/sync/StorageSyncViewModel.kt | 67 ++++++++++++-- ui/src/main/res/values/strings.xml | 9 ++ .../wallenc/usecases/FindStorageUseCase.kt | 7 +- .../ManageStorageSyncGroupsUseCase.kt | 80 +++++++++++++++- .../usecases/StorageSyncEncryptionCompat.kt | 30 ++++++ .../wallenc/usecases/StorageSyncEngine.kt | 14 +++ 28 files changed, 518 insertions(+), 159 deletions(-) delete mode 100644 app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncGroupStore.kt create mode 100644 infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt create mode 100644 infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt create mode 100644 infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEncryptionCompat.kt diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt index 6f47e31..ccef822 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt @@ -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.dao.StorageKeyMapDao 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 dagger.Module import dagger.Provides @@ -33,6 +34,12 @@ class RoomModule { return database.storageMetaInfoDao } + @Provides + @Singleton + fun provideStorageSyncGroupDao(database: IAppDb): StorageSyncGroupDao { + return database.storageSyncGroupDao + } + @Provides @Singleton fun provideYandexAccountDao(database: IAppDb): YandexAccountDao { diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt index b2f594c..86d1173 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt @@ -4,9 +4,11 @@ import android.content.Context 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.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.repository.StorageKeyMapRepository 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.network.yandexdisk.YandexDiskApiFactory 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.ports.StorageKeyMapStore 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.infrastructure.vaults.VaultsManager import com.github.nullptroma.wallenc.infrastructure.vaults.local.LocalVault @@ -138,8 +139,9 @@ class SingletonModule { @Provides @Singleton fun provideStorageSyncGroupStore( - @ApplicationContext context: Context, - ): IStorageSyncGroupStore = StorageSyncGroupStore(context) + dao: StorageSyncGroupDao, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + ): IStorageSyncGroupStore = StorageSyncGroupRepository(dao, ioDispatcher) @Provides @Singleton diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncGroupStore.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncGroupStore.kt deleted file mode 100644 index 21f4da8..0000000 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncGroupStore.kt +++ /dev/null @@ -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 { - 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 { - 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) { - 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" - } -} diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/UnlockManager.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/UnlockManager.kt index 0ce3ac2..da8be25 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/UnlockManager.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/UnlockManager.kt @@ -30,6 +30,15 @@ class UnlockManager( get() = _openedStorages 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 { CoroutineScope(ioDispatcher).launch { vaultsManager.allStorages.collect { diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorage.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorage.kt index 2e2821c..1643a4a 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorage.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorage.kt @@ -53,6 +53,8 @@ class EncryptedStorage private constructor( throw Exception("Incorrect key") // TODO } + fun getKey(): EncryptKey = EncryptKey(key.bytes) + override fun dispose() { _accessor.dispose() job.cancel() diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt index a721eea..37062b1 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt @@ -263,14 +263,7 @@ class EncryptedStorageAccessor( appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } - override suspend fun openWrite(path: String): OutputStream { - val stream = source.openWrite(encryptPath(path)) - return dataEncryptor.encryptStream(stream).onClosed { - scope.launch { - appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) - } - } - } + override suspend fun openWrite(path: String): OutputStream = openWriteInternal(path, recordJournal = true) override suspend fun openRead(path: String): InputStream { val stream = source.openRead(encryptPath(path)) @@ -290,7 +283,7 @@ class EncryptedStorageAccessor( val path = Path(systemHiddenDirName, name).pathString return@run try { openRead(path) - } catch (_: FileNotFoundException) { + } catch (_: Exception) { // Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова. openWriteSystemFile(name).use { } openRead(path) @@ -300,7 +293,7 @@ class EncryptedStorageAccessor( override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run { val path = Path(systemHiddenDirName, name).pathString systemHiddenFilesIsActual = false - return@run openWrite(path).onClosing { + return@run openWriteInternal(path, recordJournal = false).onClosing { systemHiddenFilesIsActual = false } } @@ -393,6 +386,9 @@ class EncryptedStorageAccessor( private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { val cleanedPath = if (path.startsWith("/")) path else "/$path" + if (cleanedPath.startsWith("/$systemHiddenDirName/")) { + return + } val entries = readSyncJournal() val nextSequence = (entries.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L 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.filterSystemHiddenFiles(): List { return this.filter { file -> !file.metaInfo.path.contains( diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt index e79f272..5972d77 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt @@ -13,6 +13,7 @@ interface IUnlockManager { * Хранилища, для которых есть ключ шифрования */ val openedStorages: StateFlow> + fun getOpenedStorageKey(uuid: UUID): EncryptKey? suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage suspend fun close(storage: IStorage) diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/StorageSyncContracts.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/StorageSyncContracts.kt index 0a22ec5..d2f7380 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/StorageSyncContracts.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/StorageSyncContracts.kt @@ -2,9 +2,18 @@ package com.github.nullptroma.wallenc.domain.interfaces import java.util.UUID +enum class StorageSyncGroupEncryptionKind { + UNSET, + NONE, + PASSWORD, +} + data class StorageSyncGroup( val id: String, val storageUuids: Set, + val encryptionKind: StorageSyncGroupEncryptionKind = StorageSyncGroupEncryptionKind.UNSET, + /** Локально сохранённый секрет группы для сопоставления совместимости (не уходит наружу). */ + val encryptionSecret: String? = null, ) interface IStorageSyncGroupStore { diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt index 8aece18..471a35f 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt @@ -4,24 +4,28 @@ import androidx.room.Database import androidx.room.RoomDatabase 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.StorageSyncGroupDao 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.DbStorageMetaInfo +import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbStorageSyncGroup import com.github.nullptroma.wallenc.infrastructure.db.app.model.DbYandexAccount interface IAppDb { val storageKeyMapDao: StorageKeyMapDao val storageMetaInfoDao: StorageMetaInfoDao + val storageSyncGroupDao: StorageSyncGroupDao val yandexAccountDao: YandexAccountDao } @Database( - entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class], - version = 4, + entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class, DbStorageSyncGroup::class], + version = 5, exportSchema = false ) abstract class AppDb : IAppDb, RoomDatabase() { abstract override val storageKeyMapDao: StorageKeyMapDao abstract override val storageMetaInfoDao: StorageMetaInfoDao + abstract override val storageSyncGroupDao: StorageSyncGroupDao abstract override val yandexAccountDao: YandexAccountDao } diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt new file mode 100644 index 0000000..ced4f28 --- /dev/null +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(group: DbStorageSyncGroup) + + @Query("DELETE FROM storage_sync_groups WHERE group_id = :groupId") + suspend fun deleteById(groupId: String) +} diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt new file mode 100644 index 0000000..9a46876 --- /dev/null +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt @@ -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?, +) diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt new file mode 100644 index 0000000..40272ca --- /dev/null +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt @@ -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> = object : IProvider> { + override suspend fun get(): List = dao.getAll().mapNotNull(::toDomain) + + override suspend fun set(value: List) { + 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 = 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() }, + ) + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt index 737f1ff..f0763d2 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt @@ -93,7 +93,7 @@ fun StorageHomeScreen( title = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount), description = stringResource(R.string.storage_home_two_fa_subtitle), icon = Icons.Outlined.Lock, - enabled = uiState.isAvailable, + enabled = uiState.canManageDomainData, onClick = { onOpenTwoFa(uiState.storageUuid) }, ) @@ -101,7 +101,7 @@ fun StorageHomeScreen( title = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount), description = stringResource(R.string.storage_home_text_secrets_subtitle), icon = Icons.Outlined.Notes, - enabled = uiState.isAvailable, + enabled = uiState.canManageDomainData, onClick = { onOpenTextSecrets(uiState.storageUuid) }, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt index 5882367..82e6ebf 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt @@ -9,7 +9,9 @@ data class StorageHomeScreenState( val isLoading: Boolean = true, val isAvailable: Boolean = false, val isEncrypted: Boolean = false, + val isVirtualStorage: Boolean = false, val twoFaCount: Int = 0, val textSecretsCount: Int = 0, + val canManageDomainData: Boolean = false, val errorMessage: String? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt index 51def79..ec307b3 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt @@ -44,15 +44,23 @@ class StorageHomeViewModel @Inject constructor( manageTwoFaTokensUseCase.observe(storage), manageTextSecretsUseCase.observe(storage), ) { available, meta, twoFa, secrets -> + val isRawEncrypted = meta.encInfo != null && !storage.isVirtualStorage + val canManageDomainData = available && !isRawEncrypted state.value.copy( isLoading = false, storageUuid = storage.uuid.toString(), storageName = meta.name.orEmpty(), isAvailable = available, isEncrypted = meta.encInfo != null, + isVirtualStorage = storage.isVirtualStorage, twoFaCount = twoFa.size, textSecretsCount = secrets.size, - errorMessage = null, + canManageDomainData = canManageDomainData, + errorMessage = if (isRawEncrypted) { + "Откройте расшифрованное отображение storage для работы с 2FA и секретами" + } else { + null + }, ) }.collect { ui -> updateState(ui) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt index 08ae0a7..e43683e 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt @@ -49,6 +49,16 @@ class TextSecretDetailsViewModel @Inject constructor( ) return@launch } + if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { + updateState( + state.value.copy( + isLoading = false, + isAvailable = false, + errorMessage = "Откройте расшифрованное отображение storage для просмотра и редактирования секрета", + ), + ) + return@launch + } combine( storage.isAvailable, manageTextSecretsUseCase.observe(storage).map { list -> @@ -76,6 +86,14 @@ class TextSecretDetailsViewModel @Inject constructor( fun delete(onDeleted: () -> Unit) { viewModelScope.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( title = uiStrings(R.string.task_title_delete_text_secret), dispatcher = Dispatchers.IO, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt index 0133825..f63fc97 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt @@ -42,6 +42,7 @@ fun TextSecretEditScreen( ) { val uiState by viewModel.state.collectAsStateWithLifecycle() val currentOnSaved by rememberUpdatedState(onSaved) + val inputEnabled = uiState.isAvailable && !uiState.isMutating && uiState.errorMessage == null var title by remember(uiState.initialSecret) { mutableStateOf(uiState.initialSecret?.title.orEmpty()) @@ -74,13 +75,16 @@ fun TextSecretEditScreen( stringResource(R.string.text_secret_edit) }, ) + uiState.errorMessage?.let { err -> + Text(text = err) + } if (uiState.isMutating) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } OutlinedTextField( value = title, onValueChange = { title = it }, - enabled = !uiState.isMutating, + enabled = inputEnabled, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.text_secret_title)) }, ) @@ -99,7 +103,7 @@ fun TextSecretEditScreen( onValueChange = { newLabel -> items[index] = item.copy(label = newLabel.ifBlank { null }) }, - enabled = !uiState.isMutating, + enabled = inputEnabled, modifier = Modifier.weight(0.45f), label = { Text(stringResource(R.string.text_secret_item_label_optional)) }, ) @@ -108,12 +112,12 @@ fun TextSecretEditScreen( onValueChange = { newValue -> items[index] = item.copy(value = newValue) }, - enabled = !uiState.isMutating, + enabled = inputEnabled, modifier = Modifier.weight(0.55f), label = { Text(stringResource(R.string.text_secret_item_value)) }, ) IconButton( - enabled = !uiState.isMutating, + enabled = inputEnabled, onClick = { if (items.size > 1) { items.removeAt(index) @@ -131,7 +135,7 @@ fun TextSecretEditScreen( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TextButton( onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) }, - enabled = !uiState.isMutating, + enabled = inputEnabled, ) { Icon(Icons.Default.Add, contentDescription = null) Text(stringResource(R.string.text_secret_add_item)) @@ -144,7 +148,7 @@ fun TextSecretEditScreen( onSaved = currentOnSaved, ) }, - enabled = title.isNotBlank() && !uiState.isMutating, + enabled = title.isNotBlank() && inputEnabled, ) { Text(stringResource(R.string.save)) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt index 09b05c3..8c5de0e 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt @@ -53,6 +53,16 @@ class TextSecretEditViewModel @Inject constructor( ) 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) } combine( storage.isAvailable, @@ -83,6 +93,14 @@ class TextSecretEditViewModel @Inject constructor( ) { viewModelScope.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 targetSecretId = existingId ?: UUID.randomUUID().toString() val taskId = taskOrchestrator.enqueue( diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt index 2a09f42..32e1fe9 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt @@ -36,6 +36,16 @@ class TextSecretsViewModel @Inject constructor( ) return@launch } + if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { + updateState( + state.value.copy( + isLoading = false, + isAvailable = false, + errorMessage = "Откройте расшифрованное отображение storage для работы с секретами", + ), + ) + return@launch + } combine( storage.isAvailable, manageTextSecretsUseCase.observe(storage), diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt index 668199c..c2a960e 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt @@ -45,6 +45,16 @@ class TwoFaTokensViewModel @Inject constructor( ) return@launch } + if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { + updateState( + state.value.copy( + isLoading = false, + isAvailable = false, + errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA", + ), + ) + return@launch + } combine( storage.isAvailable, manageTwoFaTokensUseCase.observe(storage), @@ -76,6 +86,14 @@ class TwoFaTokensViewModel @Inject constructor( ) { viewModelScope.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( title = uiStrings(R.string.task_title_save_2fa_token), dispatcher = Dispatchers.IO, @@ -109,6 +127,14 @@ class TwoFaTokensViewModel @Inject constructor( fun deleteToken(id: String) { viewModelScope.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( title = uiStrings(R.string.task_title_delete_2fa_token), dispatcher = Dispatchers.IO, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt index b768114..3545afa 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt @@ -49,6 +49,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.resources.UserNotification +import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind import java.util.UUID @Composable @@ -57,27 +58,6 @@ fun StorageSyncScreen( viewModel: StorageSyncViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - var pendingRemoveGroupId by remember { mutableStateOf(null) } - var pendingRemoveStorage by remember { mutableStateOf?>(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 context = LocalContext.current LaunchedEffect(state.userMessage) { @@ -96,6 +76,28 @@ fun StorageSyncScreen( viewModel.consumeUserMessage() } + var pendingRemoveGroupId by remember { mutableStateOf(null) } + var pendingRemoveStorage by remember { mutableStateOf?>(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( modifier = modifier, contentWindowInsets = WindowInsets(0.dp), @@ -232,14 +234,24 @@ fun StorageSyncScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults) - if (hasMixedEncryption) { + if (group.incompatibleStorageUuids.isNotEmpty()) { 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, 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 -> val storage = storageByUuid[storageUuid] @@ -426,12 +438,14 @@ private fun StoragePickerScreen( onBack: () -> Unit, onAddStorage: (UUID) -> Unit, onToggleVault: (UUID) -> Unit, + snackbarHostState: SnackbarHostState, ) { val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet() val groupEditLocked = state.isBusy || state.isStorageSyncRunning Scaffold( modifier = modifier, contentWindowInsets = WindowInsets(0.dp), + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { inner -> Column( modifier = Modifier @@ -618,15 +632,11 @@ private fun flattenStorageTree(nodes: List): List, -): Boolean { - if (group.storageUuids.isEmpty()) return false - val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid } - 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 +@Composable +private fun groupPolicyLabel(kind: StorageSyncGroupEncryptionKind): String { + return when (kind) { + StorageSyncGroupEncryptionKind.UNSET -> stringResource(R.string.sync_group_policy_unset) + StorageSyncGroupEncryptionKind.NONE -> stringResource(R.string.sync_group_policy_plain) + StorageSyncGroupEncryptionKind.PASSWORD -> stringResource(R.string.sync_group_policy_password) + } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt index 1b4e6a1..11dd940 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.ui.screens.sync import com.github.nullptroma.wallenc.ui.resources.UserNotification +import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind import java.util.UUID enum class StorageSyncEncryptionKind { @@ -28,6 +29,8 @@ data class StorageSyncVaultUi( data class StorageSyncGroupUi( val id: String, val storageUuids: Set, + val encryptionKind: StorageSyncGroupEncryptionKind = StorageSyncGroupEncryptionKind.UNSET, + val incompatibleStorageUuids: Set = emptySet(), ) data class StorageSyncScreenState( diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt index 63282c2..5b5fc38 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt @@ -10,8 +10,12 @@ import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.resources.UiStringResolver 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.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.VaultDescriptor import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,6 +37,7 @@ class StorageSyncViewModel @Inject constructor( private val taskOrchestrator: ITaskOrchestrator, private val uiStrings: UiStringResolver, ) : ViewModelBase(StorageSyncScreenState()) { + private var storageByUuid: Map = emptyMap() init { refreshGroups() @@ -144,11 +149,40 @@ class StorageSyncViewModel @Inject constructor( val groupId = state.value.pickerGroupId ?: return viewModelScope.launch { withGroupMutationBusy { - groupsUseCase.addStorageToGroup(groupId, storageUuid) - UserNotification.TextRes( - R.string.sync_msg_storage_added, - listOf(groupId), + val storage = storageByUuid[storageUuid] + 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, + 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 .flatMap { (_, trees) -> trees.flatMap(::flattenStorages) } .distinctBy { it.uuid } + storageByUuid = allStorages.associateBy { it.uuid } if (allStorages.isEmpty()) { flowOf( @@ -245,7 +280,12 @@ class StorageSyncViewModel @Inject constructor( } } .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 = - 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( clearPicker: Boolean = false, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 706a746..8a3a1b9 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -34,6 +34,11 @@ Свернуть Создать группу синхронизации В группе разное шифрование: задайте единый режим + Несовместимые хранилища в группе: %1$d + Политика шифрования группы: %1$s + Не определена (группа пуста) + Только незашифрованные + Только зашифрованные с паролем группы Удалить группу? Удалить группу синхронизации «%1$s»? Убрать хранилище? @@ -44,6 +49,10 @@ Группа удалена Хранилище добавлено в %1$s Хранилище убрано из %1$s + Хранилище уже добавлено в группу + Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением) + Хранилище не совместимо с политикой шифрования группы + Нельзя добавлять открытое виртуальное хранилище: синхронизация работает с исходными raw storage Задача синхронизации поставлена в очередь Синхронизация уже выполняется Дождитесь окончания синхронизации diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt index fd268e7..c33e1b1 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt @@ -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 } } } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageStorageSyncGroupsUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageStorageSyncGroupsUseCase.kt index e09da78..5fdee67 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageStorageSyncGroupsUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageStorageSyncGroupsUseCase.kt @@ -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, + ), ) } } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEncryptionCompat.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEncryptionCompat.kt new file mode 100644 index 0000000..f27e239 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEncryptionCompat.kt @@ -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 + } + } +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt index a90f13c..80ee7f7 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt @@ -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()