feat(sync): добавил механизм синхронизации хранилищ и управление группами
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<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"
|
||||
}
|
||||
}
|
||||
@@ -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<StorageSyncWorker>(
|
||||
repeatInterval = 30,
|
||||
repeatIntervalTimeUnit = TimeUnit.MINUTES,
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(app).enqueueUniquePeriodicWork(
|
||||
StorageSyncWorker.UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<Long?>(null)
|
||||
override val size: StateFlow<Long?> = _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<StorageSyncJournalEntry> {
|
||||
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<StorageSyncJournalEntry>)
|
||||
}.getOrElse {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) {
|
||||
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<StorageSyncJournalEntry>) {
|
||||
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<IFile>.filterSystemHiddenFiles(): List<IFile> {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -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<Long?>(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<StorageSyncJournalEntry> = withContext(ioDispatcher) {
|
||||
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() }
|
||||
if (bytes.isEmpty()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
return@withContext runCatching {
|
||||
jackson.readValue<List<StorageSyncJournalEntry>>(bytes)
|
||||
}.getOrElse {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) = 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<StorageSyncJournalEntry>) = 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<StorageSyncLock>(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() }
|
||||
}
|
||||
}
|
||||
@@ -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<StorageSyncJournalEntry> = 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<StorageSyncJournalEntry>)
|
||||
}.getOrElse {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) = 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<StorageSyncJournalEntry>) = 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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<Long?>
|
||||
@@ -48,4 +51,12 @@ interface IStorageAccessor {
|
||||
*/
|
||||
suspend fun openReadSystemFile(name: String): InputStream
|
||||
suspend fun openWriteSystemFile(name: String): OutputStream
|
||||
|
||||
suspend fun readSyncJournal(): List<StorageSyncJournalEntry>
|
||||
suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>)
|
||||
suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>)
|
||||
|
||||
suspend fun readSyncLock(): StorageSyncLock?
|
||||
suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean
|
||||
suspend fun releaseSyncLock(holderId: String)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.github.nullptroma.wallenc.domain.interfaces
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class StorageSyncGroup(
|
||||
val id: String,
|
||||
val storageUuids: Set<UUID>,
|
||||
)
|
||||
|
||||
interface IStorageSyncGroupStore {
|
||||
suspend fun getGroups(): List<StorageSyncGroup>
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<StorageSyncRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.SYNC_URI_PATTERN },
|
||||
),
|
||||
enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
StorageSyncScreen(
|
||||
modifier = Modifier.padding(innerPaddings),
|
||||
viewModel = storageSyncViewModel,
|
||||
)
|
||||
}
|
||||
composable<TaskPipelineRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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<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 }
|
||||
|
||||
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<UUID>,
|
||||
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<StorageSyncStorageUi>): List<StorageSyncStorageUi> {
|
||||
return nodes.flatMap { node ->
|
||||
listOf(node) + flattenStorageTree(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEncryptionMismatch(
|
||||
group: StorageSyncGroupUi,
|
||||
vaults: List<StorageSyncVaultUi>,
|
||||
): 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
|
||||
}
|
||||
@@ -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<StorageSyncStorageUi> = emptyList(),
|
||||
)
|
||||
|
||||
data class StorageSyncVaultUi(
|
||||
val uuid: UUID,
|
||||
val title: String,
|
||||
val type: String,
|
||||
val storages: List<StorageSyncStorageUi>,
|
||||
)
|
||||
|
||||
data class StorageSyncGroupUi(
|
||||
val id: String,
|
||||
val storageUuids: Set<UUID>,
|
||||
)
|
||||
|
||||
data class StorageSyncScreenState(
|
||||
val groups: List<StorageSyncGroupUi> = emptyList(),
|
||||
val vaults: List<StorageSyncVaultUi> = emptyList(),
|
||||
val expandedVaultUuids: Set<UUID> = emptySet(),
|
||||
val pickerGroupId: String? = null,
|
||||
val isBusy: Boolean = false,
|
||||
val message: String? = null,
|
||||
)
|
||||
@@ -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>(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<UUID, IStorage>,
|
||||
visited: MutableSet<UUID> = 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<IStorage> {
|
||||
return listOf(node.storage) + node.children.flatMap(::flattenStorages)
|
||||
}
|
||||
|
||||
private fun toStorageUi(
|
||||
node: StorageTreeNode,
|
||||
metaByStorageUuid: Map<UUID, com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo> = 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 ?: "<noname>",
|
||||
encryptionStatus = encryptionStatus,
|
||||
children = node.children.map { child ->
|
||||
toStorageUi(
|
||||
node = child,
|
||||
metaByStorageUuid = metaByStorageUuid,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private data class StorageTreeNode(
|
||||
val storage: IStorage,
|
||||
val children: List<StorageTreeNode>,
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,29 @@
|
||||
<string name="nav_label_local_vault">Local</string>
|
||||
<string name="nav_label_remote_vaults">Remotes</string>
|
||||
<string name="nav_label_main">Main</string>
|
||||
<string name="nav_label_sync">Sync</string>
|
||||
<string name="nav_label_settings">Settings</string>
|
||||
|
||||
<string name="settings_title">Settings Screen Title!</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="sync_groups_title">Sync groups</string>
|
||||
<string name="sync_run_now">Run sync now</string>
|
||||
<string name="sync_refresh">Refresh</string>
|
||||
<string name="sync_add_storage">Add</string>
|
||||
<string name="sync_remove_group">Remove group</string>
|
||||
<string name="sync_group_empty">No storages in group</string>
|
||||
<string name="sync_remove_storage">Remove</string>
|
||||
<string name="sync_picker_back">Back</string>
|
||||
<string name="sync_picker_title">Select storage for %1$s</string>
|
||||
<string name="sync_picker_add">Add</string>
|
||||
<string name="sync_picker_added">Added</string>
|
||||
<string name="sync_picker_no_storages">No storages in this vault</string>
|
||||
<string name="sync_group_mixed_encryption_warning">Mixed encryption in group: define one canonical encryption mode</string>
|
||||
<string name="sync_remove_group_confirm_title">Remove group?</string>
|
||||
<string name="sync_remove_group_confirm_message">Delete sync group \"%1$s\"?</string>
|
||||
<string name="sync_remove_storage_confirm_title">Remove storage?</string>
|
||||
<string name="sync_remove_storage_confirm_message">Remove storage \"%1$s\" from the group?</string>
|
||||
<string name="sync_confirm_delete">Delete</string>
|
||||
<string name="sync_cancel">Cancel</string>
|
||||
<string name="no_name"><noname></string>
|
||||
<string name="show_storage_item_menu">Show storage item menu</string>
|
||||
<string name="rename">Rename</string>
|
||||
|
||||
@@ -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<StorageSyncGroup> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, Mutex>()
|
||||
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<IStorageAccessor>()
|
||||
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<String, StorageSyncJournalEntry>()
|
||||
val entriesByStorage = mutableMapOf<UUID, Map<String, StorageSyncJournalEntry>>()
|
||||
|
||||
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<UUID>): List<IStorage> {
|
||||
val byUuid = vaultsManager.allStorages.value.associateBy { it.uuid }
|
||||
return uuids.mapNotNull { byUuid[it] }
|
||||
}
|
||||
|
||||
private fun latestByPath(entries: List<StorageSyncJournalEntry>): Map<String, StorageSyncJournalEntry> {
|
||||
val map = mutableMapOf<String, StorageSyncJournalEntry>()
|
||||
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<IStorage>,
|
||||
entriesByStorage: Map<UUID, Map<String, StorageSyncJournalEntry>>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user