feat(sync): добавил механизм синхронизации хранилищ и управление группами

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 23:46:31 +03:00
parent d6bfdff077
commit f38b3dfbb4
27 changed files with 1819 additions and 7 deletions

View File

@@ -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))

View File

@@ -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()
}

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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,
)
}
}

View File

@@ -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"
}
}

View File

@@ -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() }
}
}

View File

@@ -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() }
}
}

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)
}

View File

@@ -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,
)
}

View File

@@ -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" }

View File

@@ -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 },

View File

@@ -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()
)
)

View File

@@ -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}"
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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>,
)
}

View File

@@ -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">&lt;noname&gt;</string>
<string name="show_storage_item_menu">Show storage item menu</string>
<string name="rename">Rename</string>

View File

@@ -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),
)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}