feat(sync): перевёл группы синхронизации на Room и добавил контроль совместимости
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>()
|
||||||
|
|||||||
Reference in New Issue
Block a user