From f38b3dfbb41391714e7430568159ba93a6589606 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: Tue, 12 May 2026 23:46:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=89=20=D0=B8=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/build.gradle.kts | 3 + .../wallenc/app/WallencApplication.kt | 17 +- .../app/di/modules/data/SingletonModule.kt | 8 + .../app/di/modules/domain/UseCasesModule.kt | 29 ++ .../wallenc/app/sync/StorageSyncBootstrap.kt | 52 +++ .../wallenc/app/sync/StorageSyncGroupStore.kt | 87 ++++ .../wallenc/app/sync/StorageSyncScheduler.kt | 36 ++ .../wallenc/app/sync/StorageSyncWorker.kt | 30 ++ .../encrypt/EncryptedStorageAccessor.kt | 118 ++++- .../storages/local/LocalStorageAccessor.kt | 166 ++++++- .../storages/yandex/YandexStorageAccessor.kt | 106 +++++ .../domain/datatypes/StorageSyncModels.kt | 29 ++ .../domain/interfaces/IStorageAccessor.kt | 11 + .../domain/interfaces/StorageSyncContracts.kt | 24 + gradle/libs.versions.toml | 5 + .../github/nullptroma/wallenc/ui/WallencUi.kt | 24 + .../nullptroma/wallenc/ui/WallencViewModel.kt | 2 + .../wallenc/ui/navigation/WallencDeepLinks.kt | 2 + .../ui/screens/settings/SettingsScreen.kt | 7 +- .../ui/screens/sync/StorageSyncRoute.kt | 9 + .../ui/screens/sync/StorageSyncScreen.kt | 427 ++++++++++++++++++ .../ui/screens/sync/StorageSyncScreenState.kt | 31 ++ .../ui/screens/sync/StorageSyncViewModel.kt | 272 +++++++++++ ui/src/main/res/values/strings.xml | 22 +- .../ManageStorageSyncGroupsUseCase.kt | 42 ++ .../wallenc/usecases/RunStorageSyncUseCase.kt | 41 ++ .../wallenc/usecases/StorageSyncEngine.kt | 226 +++++++++ 27 files changed, 1819 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt create mode 100644 app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncGroupStore.kt create mode 100644 app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt create mode 100644 app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt create mode 100644 domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageSyncModels.kt create mode 100644 domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/StorageSyncContracts.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncRoute.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageStorageSyncGroupsUseCase.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c110c24..bd621ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,8 +63,11 @@ dependencies { // Hilt implementation(libs.dagger.hilt) ksp(libs.dagger.hilt.compiler) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt index ad6b3f5..875e463 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt @@ -1,18 +1,33 @@ package com.github.nullptroma.wallenc.app import android.app.Application +import androidx.work.Configuration +import androidx.hilt.work.HiltWorkerFactory +import com.github.nullptroma.wallenc.app.sync.StorageSyncBootstrap import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @HiltAndroidApp -class WallencApplication : Application() { +class WallencApplication : Application(), Configuration.Provider { @Inject lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap + @Inject + lateinit var storageSyncBootstrap: StorageSyncBootstrap + + @Inject + lateinit var workerFactory: HiltWorkerFactory + override fun onCreate() { super.onCreate() taskPipelineForegroundBootstrap.start() + storageSyncBootstrap.start() } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() } \ No newline at end of file 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 6af0186..b2f594c 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 @@ -15,10 +15,12 @@ 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 import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore import com.github.nullptroma.wallenc.domain.interfaces.IVault import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator @@ -133,6 +135,12 @@ class SingletonModule { return StorageMetaInfoRepository(dao, ioDispatcher) } + @Provides + @Singleton + fun provideStorageSyncGroupStore( + @ApplicationContext context: Context, + ): IStorageSyncGroupStore = StorageSyncGroupStore(context) + @Provides @Singleton fun provideYandexAccountRepository( diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt index 169a06b..19d9e19 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt @@ -1,12 +1,18 @@ package com.github.nullptroma.wallenc.app.di.modules.domain import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore +import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase +import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase +import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase +import com.github.nullptroma.wallenc.usecases.StorageSyncEngine import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase import dagger.Module import dagger.Provides @@ -58,4 +64,27 @@ class UseCasesModule { ): RemoveStorageUseCase { return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase) } + + @Provides + @Singleton + fun provideStorageSyncEngine( + vaultsManager: IVaultsManager, + groupStore: IStorageSyncGroupStore, + ): IStorageSyncEngine = StorageSyncEngine( + vaultsManager = vaultsManager, + groupStore = groupStore, + ) + + @Provides + @Singleton + fun provideManageStorageSyncGroupsUseCase( + groupStore: IStorageSyncGroupStore, + ): ManageStorageSyncGroupsUseCase = ManageStorageSyncGroupsUseCase(groupStore) + + @Provides + @Singleton + fun provideRunStorageSyncUseCase( + orchestrator: ITaskOrchestrator, + syncEngine: IStorageSyncEngine, + ): RunStorageSyncUseCase = RunStorageSyncUseCase(orchestrator, syncEngine) } \ No newline at end of file diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt new file mode 100644 index 0000000..eb0fd38 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt @@ -0,0 +1,52 @@ +package com.github.nullptroma.wallenc.app.sync + +import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@OptIn(FlowPreview::class) +class StorageSyncBootstrap @Inject constructor( + private val scheduler: StorageSyncScheduler, + private val vaultsManager: IVaultsManager, + private val syncRunner: RunStorageSyncUseCase, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun start() { + scheduler.ensureScheduled() + scope.launch { + vaultsManager.allStorages.collectLatest { storages -> + if (storages.isEmpty()) { + return@collectLatest + } + val triggers = storages.flatMap { storage -> + listOf( + storage.accessor.filesUpdates.map { Unit }, + storage.accessor.dirsUpdates.map { Unit }, + ) + } + merge(*triggers.toTypedArray()) + .debounce(DEBOUNCE_AFTER_CHANGE_MS) + .collect { + syncRunner.enqueue("debounce") + } + } + } + } + + private companion object { + private const val DEBOUNCE_AFTER_CHANGE_MS = 60_000L + } +} 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 new file mode 100644 index 0000000..21f4da8 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncGroupStore.kt @@ -0,0 +1,87 @@ +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/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt new file mode 100644 index 0000000..ae54f84 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt @@ -0,0 +1,36 @@ +package com.github.nullptroma.wallenc.app.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StorageSyncScheduler @Inject constructor( + @param:ApplicationContext private val app: Context, +) { + fun ensureScheduled() { + val request = PeriodicWorkRequestBuilder( + repeatInterval = 30, + repeatIntervalTimeUnit = TimeUnit.MINUTES, + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + + WorkManager.getInstance(app).enqueueUniquePeriodicWork( + StorageSyncWorker.UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt new file mode 100644 index 0000000..df28a0b --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt @@ -0,0 +1,30 @@ +package com.github.nullptroma.wallenc.app.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class StorageSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val syncRunner: RunStorageSyncUseCase, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + return runCatching { + syncRunner.runBlocking() + Result.success() + }.getOrElse { + Result.retry() + } + } + + companion object { + const val UNIQUE_WORK_NAME = "wallenc-storage-sync-periodic" + } +} 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 e8c1080..76f5b7b 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 @@ -1,5 +1,6 @@ package com.github.nullptroma.wallenc.infrastructure.storages.encrypt +import com.github.nullptroma.wallenc.infrastructure.utils.CloseHandledStreamExtension.Companion.onClosed import com.github.nullptroma.wallenc.infrastructure.utils.CloseHandledStreamExtension.Companion.onClosing import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory import com.github.nullptroma.wallenc.domain.common.impl.CommonFile @@ -12,6 +13,10 @@ import com.github.nullptroma.wallenc.domain.interfaces.IDirectory import com.github.nullptroma.wallenc.domain.interfaces.IFile import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.flow.Flow @@ -21,8 +26,12 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.InputStream import java.io.OutputStream +import java.time.Instant +import java.util.UUID import kotlin.io.path.Path import kotlin.io.path.pathString @@ -33,6 +42,7 @@ class EncryptedStorageAccessor( private val systemHiddenDirName: String, private val scope: CoroutineScope ) : IStorageAccessor, DisposableHandle { + private val syncActorId = UUID.randomUUID().toString() private val _size = MutableStateFlow(null) override val size: StateFlow = _size @@ -50,6 +60,7 @@ class EncryptedStorageAccessor( private val dataEncryptor = Encryptor(key.toAesKey()) private val pathEncryptor: EncryptorWithStaticIv? = if(pathIv != null) EncryptorWithStaticIv(key.toAesKey(), pathIv) else null + private val syncLockMutex = Mutex() private var systemHiddenFilesIsActual = false init { @@ -239,6 +250,7 @@ class EncryptedStorageAccessor( override suspend fun touchFile(path: String) { source.touchFile(encryptPath(path)) + appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) } override suspend fun touchDir(path: String) { @@ -247,11 +259,16 @@ class EncryptedStorageAccessor( override suspend fun delete(path: String) { source.delete(encryptPath(path)) + appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } override suspend fun openWrite(path: String): OutputStream { val stream = source.openWrite(encryptPath(path)) - return dataEncryptor.encryptStream(stream) + return dataEncryptor.encryptStream(stream).onClosed { + scope.launch { + appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) + } + } } override suspend fun openRead(path: String): InputStream { @@ -261,6 +278,7 @@ class EncryptedStorageAccessor( override suspend fun moveToTrash(path: String) { source.moveToTrash(encryptPath(path)) + appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } override fun dispose() { @@ -280,6 +298,98 @@ class EncryptedStorageAccessor( } } + override suspend fun readSyncJournal(): List { + val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } + if (bytes.isEmpty()) { + return emptyList() + } + return runCatching { + val javaType = jackson.typeFactory.constructCollectionType( + List::class.java, + StorageSyncJournalEntry::class.java, + ) + @Suppress("UNCHECKED_CAST") + (jackson.readValue(bytes, javaType) as List) + }.getOrElse { + emptyList() + } + } + + override suspend fun appendSyncJournal(entries: List) { + if (entries.isEmpty()) { + return + } + val next = readSyncJournal().toMutableList().apply { addAll(entries) } + openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out -> + jackson.writeValue(out, next) + } + } + + override suspend fun rewriteSyncJournal(entries: List) { + openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out -> + jackson.writeValue(out, entries) + } + } + + override suspend fun readSyncLock(): StorageSyncLock? { + val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } + if (bytes.isEmpty()) { + return null + } + return runCatching { + jackson.readValue(bytes, StorageSyncLock::class.java) + }.getOrNull() + } + + override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean { + return syncLockMutex.withLock { + val current = readSyncLock() + if (current != null && current.holderId != holderId) { + return@withLock false + } + val next = StorageSyncLock( + holderId = holderId, + leaseUntil = leaseUntil, + updatedAt = Instant.now(), + ) + openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> + jackson.writeValue(out, next) + } + readSyncLock()?.holderId == holderId + } + } + + override suspend fun releaseSyncLock(holderId: String) { + syncLockMutex.withLock { + val current = readSyncLock() ?: return@withLock + if (current.holderId != holderId) { + return@withLock + } + openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> + out.write(ByteArray(0)) + } + } + } + + private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { + val cleanedPath = if (path.startsWith("/")) path else "/$path" + val entries = readSyncJournal() + val nextSequence = (entries.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L + appendSyncJournal( + listOf( + StorageSyncJournalEntry( + path = cleanedPath, + operation = operation, + revision = StorageSyncRevision( + sequence = nextSequence, + actorId = syncActorId, + createdAt = Instant.now(), + ), + ), + ), + ) + } + private fun Iterable.filterSystemHiddenFiles(): List { return this.filter { file -> !file.metaInfo.path.contains( @@ -296,4 +406,10 @@ class EncryptedStorageAccessor( } } + private companion object { + private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" + private const val SYNC_LOCK_FILENAME = "sync-lock.json" + private val jackson = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + .apply { findAndRegisterModules() } + } } \ No newline at end of file diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt index 1ff729b..504ef8b 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt @@ -12,6 +12,10 @@ import com.github.nullptroma.wallenc.domain.interfaces.IDirectory import com.github.nullptroma.wallenc.domain.interfaces.IFile import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -26,9 +30,14 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream import java.io.OutputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardOpenOption import java.time.Clock +import java.time.Instant +import java.util.UUID import kotlin.io.path.Path import kotlin.io.path.absolute import kotlin.io.path.fileSize @@ -39,6 +48,7 @@ class LocalStorageAccessor( filesystemBasePath: String, private val ioDispatcher: CoroutineDispatcher ) : IStorageAccessor { + private val syncActorId = UUID.randomUUID().toString() private val _filesystemBasePath: Path = Path(filesystemBasePath).normalize().absolute() private val _size = MutableStateFlow(null) @@ -478,8 +488,16 @@ class LocalStorageAccessor( } override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) { + touchFileInternal(path, recordJournal = true) + } + + private suspend fun touchFileInternal(path: String, recordJournal: Boolean) { createFile(path) + if (recordJournal) { + appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) + } + // перебор все каталогов и обновление их времени модификации var parent = Path(path).parent while(parent != null) { @@ -502,17 +520,19 @@ class LocalStorageAccessor( else pair.file.delete() pair.metaFile.delete() scanSizeAndNumOfFiles() + appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } } override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) { - touchFile(path) + touchFileInternal(path, recordJournal = false) val pair = LocalStorageFilePair.from(_filesystemBasePath, path) ?: throw Exception("Файла нет") // TODO return@withContext pair.file.outputStream().onClosed { CoroutineScope(ioDispatcher).launch { - touchFile(path) + touchFileInternal(path, recordJournal = false) scanSizeAndNumOfFiles() + appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) } } } @@ -528,6 +548,7 @@ class LocalStorageAccessor( ?: throw Exception("Файла нет") // TODO val newMeta = pair.meta.copy(isDeleted = true) writeMeta(pair.metaFile, newMeta) + appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) { @@ -554,11 +575,152 @@ class LocalStorageAccessor( return@withContext file.outputStream() } + override suspend fun readSyncJournal(): List = withContext(ioDispatcher) { + val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } + if (bytes.isEmpty()) { + return@withContext emptyList() + } + return@withContext runCatching { + jackson.readValue>(bytes) + }.getOrElse { + emptyList() + } + } + + override suspend fun appendSyncJournal(entries: List) = withContext(ioDispatcher) { + if (entries.isEmpty()) { + return@withContext + } + val next = readSyncJournal().toMutableList().apply { + addAll(entries) + } + openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out -> + jackson.writeValue(out, next) + } + } + + override suspend fun rewriteSyncJournal(entries: List) = withContext(ioDispatcher) { + openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out -> + jackson.writeValue(out, entries) + } + } + + override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) { + val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } + if (bytes.isEmpty()) { + return@withContext null + } + return@withContext runCatching { + jackson.readValue(bytes) + }.getOrNull() + } + + override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = withContext(ioDispatcher) { + val lockPath = systemLockPath() + Files.createDirectories(lockPath.parent) + FileChannel.open( + lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE, + ).use { channel -> + val fileLock = channel.lock() + try { + val current = readSyncLockFromChannel(channel) + if (current != null && current.holderId != holderId) { + return@withContext false + } + + writeSyncLockToChannel( + channel = channel, + lock = StorageSyncLock( + holderId = holderId, + leaseUntil = leaseUntil, + updatedAt = Instant.now(), + ), + ) + return@withContext true + } finally { + fileLock.release() + } + } + } + + override suspend fun releaseSyncLock(holderId: String) = withContext(ioDispatcher) { + val lockPath = systemLockPath() + Files.createDirectories(lockPath.parent) + FileChannel.open( + lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE, + ).use { channel -> + val fileLock = channel.lock() + try { + val current = readSyncLockFromChannel(channel) ?: return@withContext + if (current.holderId != holderId) { + return@withContext + } + channel.truncate(0) + channel.force(true) + } finally { + fileLock.release() + } + } + } + + private fun systemLockPath(): Path = + _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME).resolve(SYNC_LOCK_FILENAME) + + private fun readSyncLockFromChannel(channel: FileChannel): StorageSyncLock? { + channel.position(0) + val size = channel.size().toInt() + if (size <= 0) { + return null + } + val buffer = ByteBuffer.allocate(size) + while (buffer.hasRemaining()) { + val read = channel.read(buffer) + if (read <= 0) break + } + val bytes = buffer.array().copyOf(buffer.position()) + if (bytes.isEmpty()) { + return null + } + return runCatching { jackson.readValue(bytes, StorageSyncLock::class.java) }.getOrNull() + } + + private fun writeSyncLockToChannel(channel: FileChannel, lock: StorageSyncLock) { + val bytes = jackson.writeValueAsBytes(lock) + channel.truncate(0) + channel.position(0) + channel.write(ByteBuffer.wrap(bytes)) + channel.force(true) + } + + private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { + val cleanedPath = if (path.startsWith("/")) path else "/$path" + val entries = readSyncJournal() + val nextSequence = (entries.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L + val entry = StorageSyncJournalEntry( + path = cleanedPath, + operation = operation, + revision = StorageSyncRevision( + sequence = nextSequence, + actorId = syncActorId, + createdAt = Instant.now(), + ), + ) + appendSyncJournal(listOf(entry)) + } + companion object { // Файлы, которые можно использовать для чтения и записи, но не отображаются в хранилище private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-local-storage-meta-dir" private const val META_INFO_POSTFIX = ".wallenc-meta" private const val DATA_PAGE_LENGTH = 10 + private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" + private const val SYNC_LOCK_FILENAME = "sync-lock.json" private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } } } \ No newline at end of file diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt index bac871d..5083af3 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt @@ -13,6 +13,10 @@ import com.github.nullptroma.wallenc.domain.datatypes.DataPage import com.github.nullptroma.wallenc.domain.interfaces.IDirectory import com.github.nullptroma.wallenc.domain.interfaces.IFile import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -30,6 +34,8 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.File @@ -53,6 +59,8 @@ class YandexStorageAccessor( private val accessorScope: CoroutineScope, private val reportAuthFailure: () -> Unit, ) : IStorageAccessor { + private val syncActorId = UUID.randomUUID().toString() + private val syncLockMutex = Mutex() private val diskRoot = "app:/$storageUuid" @@ -440,6 +448,7 @@ class YandexStorageAccessor( _numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1 persistStatsImmediate() } + appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) } override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) { @@ -478,6 +487,7 @@ class YandexStorageAccessor( } guard { repo.delete(diskPath, permanently = true) } scheduleStatsPersist() + appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) { @@ -509,6 +519,7 @@ class YandexStorageAccessor( info?.let { _filesUpdates.emit(DataPage(listOf(it), pageLength = 1, pageIndex = 0)) } + appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT) } finally { tmp.delete() } @@ -522,6 +533,7 @@ class YandexStorageAccessor( override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) { patchCustomProps(path, mapOf(PROP_DELETED to "true")) + appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) } override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) { @@ -549,6 +561,98 @@ class YandexStorageAccessor( } } + override suspend fun readSyncJournal(): List = withContext(ioDispatcher) { + val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } + if (bytes.isEmpty()) { + return@withContext emptyList() + } + return@withContext runCatching { + val javaType = statsMapper.typeFactory.constructCollectionType( + List::class.java, + StorageSyncJournalEntry::class.java, + ) + @Suppress("UNCHECKED_CAST") + (statsMapper.readValue(bytes, javaType) as List) + }.getOrElse { + emptyList() + } + } + + override suspend fun appendSyncJournal(entries: List) = withContext(ioDispatcher) { + if (entries.isEmpty()) { + return@withContext + } + val next = readSyncJournal().toMutableList().apply { + addAll(entries) + } + openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out -> + statsMapper.writeValue(out, next) + } + } + + override suspend fun rewriteSyncJournal(entries: List) = withContext(ioDispatcher) { + openWriteSystemFile(SYNC_JOURNAL_FILENAME).use { out -> + statsMapper.writeValue(out, entries) + } + } + + override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) { + val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } + if (bytes.isEmpty()) { + return@withContext null + } + return@withContext runCatching { + statsMapper.readValue(bytes, StorageSyncLock::class.java) + }.getOrNull() + } + + override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = withContext(ioDispatcher) { + return@withContext syncLockMutex.withLock { + val current = readSyncLock() + if (current != null && current.holderId != holderId) { + return@withLock false + } + val next = StorageSyncLock( + holderId = holderId, + leaseUntil = leaseUntil, + updatedAt = Instant.now(), + ) + openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> + statsMapper.writeValue(out, next) + } + readSyncLock()?.holderId == holderId + } + } + + override suspend fun releaseSyncLock(holderId: String) = withContext(ioDispatcher) { + syncLockMutex.withLock { + val current = readSyncLock() ?: return@withLock + if (current.holderId != holderId) { + return@withLock + } + openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> + out.write(ByteArray(0)) + } + } + } + + private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { + val cleanedPath = if (path.startsWith("/")) path else "/$path" + val entries = readSyncJournal() + val nextSequence = (entries.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L + val entry = StorageSyncJournalEntry( + path = cleanedPath, + operation = operation, + revision = StorageSyncRevision( + sequence = nextSequence, + actorId = syncActorId, + createdAt = Instant.now(), + ), + originStorageUuid = storageUuid, + ) + appendSyncJournal(listOf(entry)) + } + private suspend fun touchParentDirs(path: String) { val normalized = path.removeSuffix("/") if (normalized == "/" || normalized.isBlank()) return @@ -571,6 +675,8 @@ class YandexStorageAccessor( private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system" private const val STATS_FILENAME = "yandex-vault-stats.json" + private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" + private const val SYNC_LOCK_FILENAME = "sync-lock.json" private const val STATS_DEBOUNCE_MS = 450L private const val DATA_PAGE_LENGTH = 10 private const val API_LIST_LIMIT = 200 diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageSyncModels.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageSyncModels.kt new file mode 100644 index 0000000..6075cf9 --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageSyncModels.kt @@ -0,0 +1,29 @@ +package com.github.nullptroma.wallenc.domain.datatypes + +import java.time.Instant +import java.util.UUID + +enum class StorageSyncOperation { + UPSERT, + DELETE, +} + +data class StorageSyncRevision( + val sequence: Long, + val actorId: String, + val createdAt: Instant, +) + +data class StorageSyncJournalEntry( + val path: String, + val operation: StorageSyncOperation, + val revision: StorageSyncRevision, + val size: Long? = null, + val originStorageUuid: UUID? = null, +) + +data class StorageSyncLock( + val holderId: String, + val leaseUntil: Instant, + val updatedAt: Instant, +) diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt index 6efa973..c4913b1 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt @@ -1,11 +1,14 @@ package com.github.nullptroma.wallenc.domain.interfaces import com.github.nullptroma.wallenc.domain.datatypes.DataPage +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import java.io.InputStream import java.io.OutputStream +import java.time.Instant interface IStorageAccessor { val size: StateFlow @@ -48,4 +51,12 @@ interface IStorageAccessor { */ suspend fun openReadSystemFile(name: String): InputStream suspend fun openWriteSystemFile(name: String): OutputStream + + suspend fun readSyncJournal(): List + suspend fun appendSyncJournal(entries: List) + suspend fun rewriteSyncJournal(entries: List) + + suspend fun readSyncLock(): StorageSyncLock? + suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean + suspend fun releaseSyncLock(holderId: String) } \ No newline at end of file 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 new file mode 100644 index 0000000..0a22ec5 --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/StorageSyncContracts.kt @@ -0,0 +1,24 @@ +package com.github.nullptroma.wallenc.domain.interfaces + +import java.util.UUID + +data class StorageSyncGroup( + val id: String, + val storageUuids: Set, +) + +interface IStorageSyncGroupStore { + suspend fun getGroups(): List + suspend fun putGroup(group: StorageSyncGroup) + suspend fun removeGroup(groupId: String) +} + +interface IStorageSyncEngine { + suspend fun syncAllGroups( + reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)? = null, + ) + suspend fun syncGroup( + groupId: String, + reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)? = null, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ea4ee4..f85181f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,8 @@ ksp = "2.3.7" room = "2.8.4" retrofit = "3.0.0" okhttp = "5.3.2" +workRuntime = "2.10.0" +hiltWork = "1.3.0" [libraries] jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } @@ -50,6 +52,9 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = retrofit-converter-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", version.ref = "retrofit" } retrofit-converter-jackson = { group = "com.squareup.retrofit2", name = "converter-jackson", version.ref = "retrofit" } okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt index f974f8a..d4b382b 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Sync import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -40,6 +41,9 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineS import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute import com.github.nullptroma.wallenc.ui.screens.settings.SettingsScreen import com.github.nullptroma.wallenc.ui.screens.settings.SettingsViewModel +import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute +import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncScreen +import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncViewModel import com.github.nullptroma.wallenc.ui.theme.WallencTheme @@ -70,6 +74,7 @@ fun WallencNavRoot( val mainViewModel: MainViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel() + val storageSyncViewModel: StorageSyncViewModel = hiltViewModel() val topLevelRoutes = viewModel.routes @@ -90,6 +95,11 @@ fun WallencNavRoot( Icons.AutoMirrored.Rounded.List, R.string.task_pipeline_open, ), + StorageSyncRoute::class.qualifiedName!! to NavBarItemData( + R.string.nav_label_sync, + StorageSyncRoute::class.qualifiedName!!, + Icons.Rounded.Sync, + ), SettingsRoute::class.qualifiedName!! to NavBarItemData( R.string.nav_label_settings, SettingsRoute::class.qualifiedName!!, @@ -158,6 +168,20 @@ fun WallencNavRoot( }) { SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel) } + composable( + deepLinks = listOf( + navDeepLink { uriPattern = WallencDeepLinks.SYNC_URI_PATTERN }, + ), + enterTransition = { + fadeIn(tween(200)) + }, exitTransition = { + fadeOut(tween(200)) + }) { + StorageSyncScreen( + modifier = Modifier.padding(innerPaddings), + viewModel = storageSyncViewModel, + ) + } composable( deepLinks = listOf( navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN }, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencViewModel.kt index 638e95f..be6bbd0 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencViewModel.kt @@ -8,6 +8,7 @@ import com.github.nullptroma.wallenc.ui.screens.ScreenRoute import com.github.nullptroma.wallenc.ui.screens.main.MainRoute import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute +import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlin.collections.set @@ -20,6 +21,7 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS mapOf( MainRoute::class.qualifiedName!! to MainRoute(), TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(), + StorageSyncRoute::class.qualifiedName!! to StorageSyncRoute(), SettingsRoute::class.qualifiedName!! to SettingsRoute() ) ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinks.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinks.kt index 7c5c1e1..6e216b8 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinks.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinks.kt @@ -16,11 +16,13 @@ object WallencDeepLinks { object Host { const val MAIN = "main" const val TASKS = "tasks" + const val SYNC = "sync" const val SETTINGS = "settings" } const val MAIN_URI_PATTERN = "$SCHEME://${Host.MAIN}" const val TASKS_URI_PATTERN = "$SCHEME://${Host.TASKS}" + const val SYNC_URI_PATTERN = "$SCHEME://${Host.SYNC}" const val SETTINGS_URI_PATTERN = "$SCHEME://${Host.SETTINGS}" } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/settings/SettingsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/settings/SettingsScreen.kt index e1eb86f..19ec6a3 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/settings/SettingsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/settings/SettingsScreen.kt @@ -12,8 +12,11 @@ import com.github.nullptroma.wallenc.ui.R @Composable fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) { - Column (modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { Text(text = stringResource(id = R.string.settings_title)) -// Text(text = viewModel) } } \ No newline at end of file diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncRoute.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncRoute.kt new file mode 100644 index 0000000..d520bfb --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncRoute.kt @@ -0,0 +1,9 @@ +package com.github.nullptroma.wallenc.ui.screens.sync + +import com.github.nullptroma.wallenc.ui.screens.ScreenRoute +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +class StorageSyncRoute : ScreenRoute() 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 new file mode 100644 index 0000000..45e80d0 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt @@ -0,0 +1,427 @@ +package com.github.nullptroma.wallenc.ui.screens.sync + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.nullptroma.wallenc.ui.R +import java.util.UUID + +@Composable +fun StorageSyncScreen( + modifier: Modifier = Modifier, + viewModel: StorageSyncViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var pendingRemoveGroupId by remember { mutableStateOf(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 } + + Scaffold( + modifier = modifier, + floatingActionButton = { + FloatingActionButton( + onClick = viewModel::createGroup, + ) { + Text("+") + } + }, + ) { inner -> + Column( + modifier = Modifier + .padding(inner) + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = viewModel::runSyncNow, enabled = !state.isBusy) { + Text(stringResource(id = R.string.sync_run_now)) + } + } + + state.message?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium) + } + + Text( + text = stringResource(id = R.string.sync_groups_title), + style = MaterialTheme.typography.titleMedium, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(state.groups, key = { it.id }) { group -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = group.id, style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton( + onClick = { viewModel.openPicker(group.id) }, + enabled = !state.isBusy, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(id = R.string.sync_add_storage), + ) + } + IconButton( + onClick = { pendingRemoveGroupId = group.id }, + enabled = !state.isBusy, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(id = R.string.sync_remove_group), + ) + } + } + + if (group.storageUuids.isEmpty()) { + Text( + text = stringResource(id = R.string.sync_group_empty), + style = MaterialTheme.typography.bodySmall, + ) + } else { + val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults) + if (hasMixedEncryption) { + Text( + text = stringResource(id = R.string.sync_group_mixed_encryption_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + group.storageUuids.forEach { storageUuid -> + val storage = storageByUuid[storageUuid] + val storageLabel = storage?.name ?: storageUuid.toString() + val encryptionStatus = storage?.encryptionStatus ?: "Unknown" + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "$storageLabel ($storageUuid) | $encryptionStatus", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { pendingRemoveStorage = group.id to storageUuid }, + enabled = !state.isBusy, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(id = R.string.sync_remove_storage), + ) + } + } + } + } + } + } + } + } + } + } + } + + if (pendingRemoveGroupId != null) { + AlertDialog( + onDismissRequest = { pendingRemoveGroupId = null }, + title = { Text(text = stringResource(id = R.string.sync_remove_group_confirm_title)) }, + text = { + Text( + text = stringResource( + id = R.string.sync_remove_group_confirm_message, + pendingRemoveGroupId.orEmpty(), + ), + ) + }, + confirmButton = { + Button( + onClick = { + val groupId = pendingRemoveGroupId + pendingRemoveGroupId = null + if (groupId != null) { + viewModel.removeGroup(groupId) + } + }, + ) { + Text(text = stringResource(id = R.string.sync_confirm_delete)) + } + }, + dismissButton = { + Button(onClick = { pendingRemoveGroupId = null }) { + Text(text = stringResource(id = R.string.sync_cancel)) + } + }, + ) + } + + if (pendingRemoveStorage != null) { + AlertDialog( + onDismissRequest = { pendingRemoveStorage = null }, + title = { Text(text = stringResource(id = R.string.sync_remove_storage_confirm_title)) }, + text = { + Text( + text = stringResource( + id = R.string.sync_remove_storage_confirm_message, + pendingRemoveStorage?.second.toString(), + ), + ) + }, + confirmButton = { + Button( + onClick = { + val payload = pendingRemoveStorage + pendingRemoveStorage = null + if (payload != null) { + viewModel.removeStorageFromGroup(payload.first, payload.second) + } + }, + ) { + Text(text = stringResource(id = R.string.sync_confirm_delete)) + } + }, + dismissButton = { + Button(onClick = { pendingRemoveStorage = null }) { + Text(text = stringResource(id = R.string.sync_cancel)) + } + }, + ) + } +} + +@Composable +private fun StoragePickerScreen( + modifier: Modifier, + state: StorageSyncScreenState, + groupId: String, + onBack: () -> Unit, + onAddStorage: (UUID) -> Unit, + onToggleVault: (UUID) -> Unit, +) { + val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet() + Scaffold(modifier = modifier) { inner -> + Column( + modifier = Modifier + .padding(inner) + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onBack) { + Text(stringResource(id = R.string.sync_picker_back)) + } + Text( + text = stringResource(id = R.string.sync_picker_title, groupId), + style = MaterialTheme.typography.titleMedium, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(state.vaults, key = { it.uuid }) { vault -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val expanded = vault.uuid in state.expandedVaultUuids + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleVault(vault.uuid) }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = vault.title, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = if (expanded) "Hide" else "Show", + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = "${vault.type} | ${vault.uuid}", + style = MaterialTheme.typography.bodySmall, + ) + + if (expanded) { + if (vault.storages.isEmpty()) { + Text( + text = stringResource(id = R.string.sync_picker_no_storages), + style = MaterialTheme.typography.bodySmall, + ) + } else { + vault.storages.forEach { storage -> + StoragePickerNode( + node = storage, + depth = 0, + selected = selected, + isBusy = state.isBusy, + onAddStorage = onAddStorage, + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun StoragePickerNode( + node: StorageSyncStorageUi, + depth: Int, + selected: Set, + isBusy: Boolean, + onAddStorage: (UUID) -> Unit, +) { + val isSelected = node.uuid in selected + Card( + modifier = Modifier + .fillMaxWidth() + .padding(start = (depth * 14).dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = node.name, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "${node.uuid} | ${node.encryptionStatus}", + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + enabled = !isSelected && !isBusy, + onClick = { onAddStorage(node.uuid) }, + ) { + Text( + text = if (isSelected) { + stringResource(id = R.string.sync_picker_added) + } else { + stringResource(id = R.string.sync_picker_add) + }, + ) + } + } + } + node.children.forEach { child -> + StoragePickerNode( + node = child, + depth = depth + 1, + selected = selected, + isBusy = isBusy, + onAddStorage = onAddStorage, + ) + } +} + +private fun flattenStorageTree(nodes: List): List { + return nodes.flatMap { node -> + listOf(node) + flattenStorageTree(node.children) + } +} + +private fun hasEncryptionMismatch( + group: StorageSyncGroupUi, + vaults: List, +): Boolean { + if (group.storageUuids.isEmpty()) return false + val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid } + val statuses = group.storageUuids.mapNotNull { byUuid[it]?.encryptionStatus }.toSet() + val hasEncrypted = statuses.any { it.startsWith("Encrypted") } + val hasPlain = statuses.any { it == "Not encrypted" } + return hasEncrypted && hasPlain +} 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 new file mode 100644 index 0000000..01a0bdc --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt @@ -0,0 +1,31 @@ +package com.github.nullptroma.wallenc.ui.screens.sync + +import java.util.UUID + +data class StorageSyncStorageUi( + val uuid: UUID, + val name: String, + val encryptionStatus: String, + val children: List = emptyList(), +) + +data class StorageSyncVaultUi( + val uuid: UUID, + val title: String, + val type: String, + val storages: List, +) + +data class StorageSyncGroupUi( + val id: String, + val storageUuids: Set, +) + +data class StorageSyncScreenState( + val groups: List = emptyList(), + val vaults: List = emptyList(), + val expandedVaultUuids: Set = emptySet(), + val pickerGroupId: String? = null, + val isBusy: Boolean = false, + val message: String? = null, +) 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 new file mode 100644 index 0000000..ae6bb8f --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt @@ -0,0 +1,272 @@ +package com.github.nullptroma.wallenc.ui.screens.sync + +import androidx.lifecycle.viewModelScope +import com.github.nullptroma.wallenc.domain.interfaces.IStorage +import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import com.github.nullptroma.wallenc.ui.ViewModelBase +import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase +import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase +import com.github.nullptroma.wallenc.vault.contract.DescribedVault +import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import java.util.UUID + +@HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) +class StorageSyncViewModel @javax.inject.Inject constructor( + private val groupsUseCase: ManageStorageSyncGroupsUseCase, + private val runStorageSyncUseCase: RunStorageSyncUseCase, + private val vaultsManager: IVaultsManager, +) : ViewModelBase(StorageSyncScreenState()) { + + init { + refreshGroups() + observeVaults() + } + + fun refreshGroups() { + viewModelScope.launch { + val groups = groupsUseCase.getGroups() + updateState( + state.value.copy( + groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, + ), + ) + } + } + + fun createGroup() { + viewModelScope.launch { + updateState(state.value.copy(isBusy = true, message = null)) + val group = groupsUseCase.createGroup() + val groups = groupsUseCase.getGroups() + updateState( + state.value.copy( + groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, + pickerGroupId = null, + isBusy = false, + message = "Group ${group.id} created", + ), + ) + } + } + + fun removeGroup(groupId: String) { + viewModelScope.launch { + updateState(state.value.copy(isBusy = true, message = null)) + groupsUseCase.removeGroup(groupId) + val groups = groupsUseCase.getGroups() + updateState( + state.value.copy( + groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, + isBusy = false, + message = "Group removed", + ), + ) + } + } + + fun openPicker(groupId: String) { + updateState( + state.value.copy( + pickerGroupId = groupId, + message = null, + ), + ) + } + + fun closePicker() { + updateState( + state.value.copy( + pickerGroupId = null, + message = null, + ), + ) + } + + fun toggleVaultExpanded(vaultUuid: UUID) { + val expanded = state.value.expandedVaultUuids.toMutableSet() + if (!expanded.add(vaultUuid)) { + expanded.remove(vaultUuid) + } + updateState(state.value.copy(expandedVaultUuids = expanded)) + } + + fun addStorageToCurrentGroup(storageUuid: UUID) { + val groupId = state.value.pickerGroupId ?: return + viewModelScope.launch { + updateState(state.value.copy(isBusy = true, message = null)) + groupsUseCase.addStorageToGroup(groupId, storageUuid) + val groups = groupsUseCase.getGroups() + updateState( + state.value.copy( + groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, + isBusy = false, + message = "Storage added to $groupId", + ), + ) + } + } + + fun removeStorageFromGroup(groupId: String, storageUuid: UUID) { + viewModelScope.launch { + updateState(state.value.copy(isBusy = true, message = null)) + groupsUseCase.removeStorageFromGroup(groupId, storageUuid) + val groups = groupsUseCase.getGroups() + updateState( + state.value.copy( + groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, + isBusy = false, + message = "Storage removed from $groupId", + ), + ) + } + } + + fun runSyncNow() { + runStorageSyncUseCase.enqueue("sync-tab") + updateState(state.value.copy(message = "Sync task enqueued")) + } + + private fun observeVaults() { + viewModelScope.launch { + vaultsManager.vaults + .flatMapLatest { vaults -> + if (vaults.isEmpty()) { + flowOf(emptyList()) + } else { + combine(vaults.map { it.storages }) { rootStoragesByVault -> + vaults.zip(rootStoragesByVault.toList()) + }.flatMapLatest { vaultWithRoots -> + vaultsManager.unlockManager.openedStorages.flatMapLatest { opened -> + val vaultNodes = vaultWithRoots.map { (vault, roots) -> + vault to roots.map { root -> + buildStorageTree( + root = root, + opened = opened, + ) + } + } + val allStorages = vaultNodes + .flatMap { (_, trees) -> trees.flatMap(::flattenStorages) } + .distinctBy { it.uuid } + + if (allStorages.isEmpty()) { + flowOf( + vaultNodes.map { (vault, trees) -> + StorageSyncVaultUi( + uuid = vault.uuid, + title = vaultTitle(vault as? DescribedVault), + type = vaultType(vault as? DescribedVault), + storages = trees.map { tree -> + toStorageUi(tree) + }, + ) + }, + ) + } else { + combine(allStorages.map { it.metaInfo }) { + val metaByStorageUuid = allStorages + .mapIndexed { index, storage -> storage.uuid to it[index] } + .toMap() + + vaultNodes.map { (vault, trees) -> + StorageSyncVaultUi( + uuid = vault.uuid, + title = vaultTitle(vault as? DescribedVault), + type = vaultType(vault as? DescribedVault), + storages = trees.map { tree -> + toStorageUi(tree, metaByStorageUuid) + }, + ) + } + } + } + } + } + } + } + .collect { mapped -> + updateState(state.value.copy(vaults = mapped)) + } + } + } + + private fun vaultType(vault: DescribedVault?): String { + val descriptor = vault?.descriptor + return when (descriptor) { + is VaultDescriptor.LocalDevice -> "Local device" + is VaultDescriptor.LinkedRemote -> "Remote ${descriptor.brand.name.lowercase()}" + null -> "Unknown" + } + } + + private fun vaultTitle(vault: DescribedVault?): String { + val descriptor = vault?.descriptor + return when (descriptor) { + is VaultDescriptor.LocalDevice -> "Local vault" + is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName + null -> "Unknown vault" + } + } + + private fun buildStorageTree( + root: IStorage, + opened: Map, + visited: MutableSet = mutableSetOf(), + ): StorageTreeNode { + if (!visited.add(root.uuid)) { + return StorageTreeNode(storage = root, children = emptyList()) + } + val child = opened[root.uuid] + val children = if (child == null) { + emptyList() + } else { + listOf( + buildStorageTree( + root = child, + opened = opened, + visited = visited, + ), + ) + } + return StorageTreeNode(storage = root, children = children) + } + + private fun flattenStorages(node: StorageTreeNode): List { + return listOf(node.storage) + node.children.flatMap(::flattenStorages) + } + + private fun toStorageUi( + node: StorageTreeNode, + metaByStorageUuid: Map = emptyMap(), + ): StorageSyncStorageUi { + val meta = metaByStorageUuid[node.storage.uuid] ?: node.storage.metaInfo.value + val encryptionStatus = when { + meta.encInfo == null -> "Not encrypted" + node.storage.isVirtualStorage -> "Encrypted (opened)" + else -> "Encrypted" + } + return StorageSyncStorageUi( + uuid = node.storage.uuid, + name = meta.name ?: "", + encryptionStatus = encryptionStatus, + children = node.children.map { child -> + toStorageUi( + node = child, + metaByStorageUuid = metaByStorageUuid, + ) + }, + ) + } + + private data class StorageTreeNode( + val storage: IStorage, + val children: List, + ) +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 185522a..1892d1e 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -3,9 +3,29 @@ Local Remotes Main + Sync Settings - Settings Screen Title! + Settings + Sync groups + Run sync now + Refresh + Add + Remove group + No storages in group + Remove + Back + Select storage for %1$s + Add + Added + No storages in this vault + Mixed encryption in group: define one canonical encryption mode + Remove group? + Delete sync group \"%1$s\"? + Remove storage? + Remove storage \"%1$s\" from the group? + Delete + Cancel <noname> Show storage item menu Rename 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 new file mode 100644 index 0000000..e09da78 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageStorageSyncGroupsUseCase.kt @@ -0,0 +1,42 @@ +package com.github.nullptroma.wallenc.usecases + +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore +import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup +import java.util.UUID + +class ManageStorageSyncGroupsUseCase( + private val groupStore: IStorageSyncGroupStore, +) { + suspend fun getGroups(): List = groupStore.getGroups() + + suspend fun createGroup(): StorageSyncGroup { + val existingIds = getGroups().map { it.id }.toSet() + var index = 1 + var candidate = "group-$index" + while (candidate in existingIds) { + index++ + candidate = "group-$index" + } + val group = StorageSyncGroup(id = candidate, storageUuids = emptySet()) + groupStore.putGroup(group) + return group + } + + suspend fun removeGroup(groupId: String) { + groupStore.removeGroup(groupId.trim()) + } + + suspend fun addStorageToGroup(groupId: String, storageUuid: UUID) { + val current = getGroups().firstOrNull { it.id == groupId } ?: return + groupStore.putGroup( + current.copy(storageUuids = current.storageUuids + storageUuid), + ) + } + + suspend fun removeStorageFromGroup(groupId: String, storageUuid: UUID) { + val current = getGroups().firstOrNull { it.id == groupId } ?: return + groupStore.putGroup( + current.copy(storageUuids = current.storageUuids - storageUuid), + ) + } +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt new file mode 100644 index 0000000..c7be295 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt @@ -0,0 +1,41 @@ +package com.github.nullptroma.wallenc.usecases + +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine +import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator +import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel +import kotlinx.coroutines.Dispatchers +import java.util.concurrent.atomic.AtomicBoolean + +class RunStorageSyncUseCase( + private val orchestrator: ITaskOrchestrator, + private val syncEngine: IStorageSyncEngine, +) { + private val running = AtomicBoolean(false) + + fun enqueue(reason: String) { + orchestrator.enqueue( + title = "Storage sync ($reason)", + dispatcher = Dispatchers.IO, + work = { ctx -> + if (!running.compareAndSet(false, true)) { + ctx.log(TaskLogLevel.Info, "Storage sync skipped: already running") + return@enqueue + } + try { + ctx.log(TaskLogLevel.Info, "Storage sync started") + ctx.reportProgress(null, "Storage sync: started") + syncEngine.syncAllGroups { fraction, label -> + ctx.reportProgress(fraction, label) + } + ctx.reportProgress(null, "Storage sync: completed") + } finally { + running.set(false) + } + }, + ) + } + + suspend fun runBlocking() { + syncEngine.syncAllGroups() + } +} 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 new file mode 100644 index 0000000..81f1032 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt @@ -0,0 +1,226 @@ +package com.github.nullptroma.wallenc.usecases + +import com.github.nullptroma.wallenc.domain.interfaces.IStorage +import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore +import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.time.Instant +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +class StorageSyncEngine( + private val vaultsManager: IVaultsManager, + private val groupStore: IStorageSyncGroupStore, +) : IStorageSyncEngine { + private val holderId: String = UUID.randomUUID().toString() + private val groupMutexes = ConcurrentHashMap() + private val syncGeneration = AtomicLong(0) + + override suspend fun syncAllGroups( + reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)?, + ): Unit = withContext(Dispatchers.IO) { + val reporter = reportProgress ?: { _: Float?, _: String? -> } + val groups = groupStore.getGroups() + if (groups.isEmpty()) { + reporter(null, "Storage sync: no groups configured") + return@withContext + } + reporter(null, "Storage sync: preparing ${groups.size} groups") + for (group in groups) { + syncGroupInternal( + groupId = group.id, + reportProgress = reporter, + ) + } + reporter(null, "Storage sync: completed") + } + + override suspend fun syncGroup( + groupId: String, + reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)?, + ): Unit = withContext(Dispatchers.IO) { + val reporter = reportProgress ?: { _: Float?, _: String? -> } + syncGroupInternal( + groupId = groupId, + reportProgress = reporter, + ) + } + + private suspend fun syncGroupInternal( + groupId: String, + reportProgress: suspend (fraction: Float?, label: String?) -> Unit, + ) { + reportProgress(null, "Storage sync: group \"$groupId\" preparing") + val mutex = groupMutexes.getOrPut(groupId) { Mutex() } + mutex.withLock { + val generationSnapshot = syncGeneration.incrementAndGet() + val group = groupStore.getGroups().firstOrNull { it.id == groupId } + if (group == null) { + reportProgress(null, "Storage sync: group \"$groupId\" not found") + return + } + val storages = resolveStorages(group.storageUuids) + if (storages.size < 2) { + reportProgress(null, "Storage sync: group \"$groupId\" skipped (need at least 2 storages)") + return + } + + val leaseUntil = Instant.MAX + val lockedAccessors = mutableListOf() + try { + reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks") + for ((lockIndex, storage) in storages.withIndex()) { + reportProgress(null, "Storage sync: group \"$groupId\" lock ${lockIndex + 1}/${storages.size}") + val locked = storage.accessor.tryAcquireSyncLock(holderId, leaseUntil) + if (!locked) { + reportProgress(null, "Storage sync: group \"$groupId\" lock failed, group skipped") + return + } + lockedAccessors.add(storage.accessor) + } + + val latestByPath = mutableMapOf() + val entriesByStorage = mutableMapOf>() + + reportProgress(null, "Storage sync: group \"$groupId\" reading journals") + for ((journalIndex, storage) in storages.withIndex()) { + if (syncGeneration.get() != generationSnapshot) { + reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run") + return + } + reportProgress(null, "Storage sync: group \"$groupId\" journal ${journalIndex + 1}/${storages.size}") + val latestEntries = latestByPath(storage.accessor.readSyncJournal()) + entriesByStorage[storage.uuid] = latestEntries + for ((path, entry) in latestEntries) { + val current = latestByPath[path] + if (current == null || compareEntries(entry, current) > 0) { + latestByPath[path] = entry + } + } + } + + val mergedEntries = latestByPath.entries.toList() + if (mergedEntries.isEmpty()) { + reportProgress(null, "Storage sync: group \"$groupId\" no journal entries") + return + } + + reportProgress(null, "Storage sync: group \"$groupId\" processing ${mergedEntries.size} entries") + for ((pathIndex, merged) in mergedEntries.withIndex()) { + val path = merged.key + val winnerEntry = merged.value + reportProgress(null, "Storage sync: group \"$groupId\" entry ${pathIndex + 1}/${mergedEntries.size}") + if (syncGeneration.get() != generationSnapshot) { + reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run") + return + } + val sourceStorage = findSourceStorage(storages, entriesByStorage, path, winnerEntry) + if (sourceStorage == null && winnerEntry.operation == StorageSyncOperation.UPSERT) { + continue + } + + for (target in storages) { + if (target.uuid == sourceStorage?.uuid) { + continue + } + val targetEntry = entriesByStorage[target.uuid]?.get(path) + if (targetEntry != null && compareEntries(targetEntry, winnerEntry) >= 0) { + continue + } + val applied = applyEntry( + source = sourceStorage, + target = target, + entry = winnerEntry, + ) + if (applied) { + target.accessor.appendSyncJournal(listOf(winnerEntry)) + } + } + } + reportProgress(null, "Storage sync: group \"$groupId\" completed") + } finally { + for (accessor in lockedAccessors) { + runCatching { + accessor.releaseSyncLock(holderId) + } + } + } + } + } + + private fun resolveStorages(uuids: Set): List { + val byUuid = vaultsManager.allStorages.value.associateBy { it.uuid } + return uuids.mapNotNull { byUuid[it] } + } + + private fun latestByPath(entries: List): Map { + val map = mutableMapOf() + for (entry in entries) { + val current = map[entry.path] + if (current == null || compareEntries(entry, current) > 0) { + map[entry.path] = entry + } + } + return map + } + + private fun findSourceStorage( + storages: List, + entriesByStorage: Map>, + path: String, + winnerEntry: StorageSyncJournalEntry, + ): IStorage? { + if (winnerEntry.operation == StorageSyncOperation.DELETE) { + return storages.firstOrNull() + } + return storages.firstOrNull { storage -> + val entry = entriesByStorage[storage.uuid]?.get(path) ?: return@firstOrNull false + compareEntries(entry, winnerEntry) == 0 + } + } + + private suspend fun applyEntry( + source: IStorage?, + target: IStorage, + entry: StorageSyncJournalEntry, + ): Boolean { + when (entry.operation) { + StorageSyncOperation.DELETE -> { + return runCatching { + target.accessor.delete(entry.path) + }.isSuccess + } + + StorageSyncOperation.UPSERT -> { + val sourceAccessor = source?.accessor ?: return false + return runCatching { + sourceAccessor.openRead(entry.path).use { input -> + target.accessor.openWrite(entry.path).use { output -> + input.copyTo(output) + } + } + }.isSuccess + } + } + } + + private fun compareEntries(a: StorageSyncJournalEntry, b: StorageSyncJournalEntry): Int { + val seqCmp = a.revision.sequence.compareTo(b.revision.sequence) + if (seqCmp != 0) { + return seqCmp + } + val actorCmp = a.revision.actorId.compareTo(b.revision.actorId) + if (actorCmp != 0) { + return actorCmp + } + return a.revision.createdAt.compareTo(b.revision.createdAt) + } +}