feat(sync): добавил механизм синхронизации хранилищ и управление группами
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -63,8 +63,11 @@ dependencies {
|
|||||||
// Hilt
|
// Hilt
|
||||||
implementation(libs.dagger.hilt)
|
implementation(libs.dagger.hilt)
|
||||||
ksp(libs.dagger.hilt.compiler)
|
ksp(libs.dagger.hilt.compiler)
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
package com.github.nullptroma.wallenc.app
|
package com.github.nullptroma.wallenc.app
|
||||||
|
|
||||||
import android.app.Application
|
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 com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class WallencApplication : Application() {
|
class WallencApplication : Application(), Configuration.Provider {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
|
lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var storageSyncBootstrap: StorageSyncBootstrap
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
taskPipelineForegroundBootstrap.start()
|
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.network.yandexuserinfo.repository.YandexUserInfoRepository
|
||||||
import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore
|
import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore
|
||||||
import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore
|
import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore
|
||||||
|
import com.github.nullptroma.wallenc.app.sync.StorageSyncGroupStore
|
||||||
import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator
|
import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator
|
||||||
import com.github.nullptroma.wallenc.infrastructure.vaults.VaultsManager
|
import com.github.nullptroma.wallenc.infrastructure.vaults.VaultsManager
|
||||||
import com.github.nullptroma.wallenc.infrastructure.vaults.local.LocalVault
|
import com.github.nullptroma.wallenc.infrastructure.vaults.local.LocalVault
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
|
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.IVault
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||||
@@ -133,6 +135,12 @@ class SingletonModule {
|
|||||||
return StorageMetaInfoRepository(dao, ioDispatcher)
|
return StorageMetaInfoRepository(dao, ioDispatcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideStorageSyncGroupStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): IStorageSyncGroupStore = StorageSyncGroupStore(context)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideYandexAccountRepository(
|
fun provideYandexAccountRepository(
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package com.github.nullptroma.wallenc.app.di.modules.domain
|
package com.github.nullptroma.wallenc.app.di.modules.domain
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
|
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.domain.interfaces.IVaultsManager
|
||||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
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 com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -58,4 +64,27 @@ class UseCasesModule {
|
|||||||
): RemoveStorageUseCase {
|
): RemoveStorageUseCase {
|
||||||
return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
|
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
|
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.infrastructure.utils.CloseHandledStreamExtension.Companion.onClosing
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonFile
|
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.IFile
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.DisposableHandle
|
import kotlinx.coroutines.DisposableHandle
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -21,8 +26,12 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlin.io.path.pathString
|
import kotlin.io.path.pathString
|
||||||
|
|
||||||
@@ -33,6 +42,7 @@ class EncryptedStorageAccessor(
|
|||||||
private val systemHiddenDirName: String,
|
private val systemHiddenDirName: String,
|
||||||
private val scope: CoroutineScope
|
private val scope: CoroutineScope
|
||||||
) : IStorageAccessor, DisposableHandle {
|
) : IStorageAccessor, DisposableHandle {
|
||||||
|
private val syncActorId = UUID.randomUUID().toString()
|
||||||
private val _size = MutableStateFlow<Long?>(null)
|
private val _size = MutableStateFlow<Long?>(null)
|
||||||
override val size: StateFlow<Long?> = _size
|
override val size: StateFlow<Long?> = _size
|
||||||
|
|
||||||
@@ -50,6 +60,7 @@ class EncryptedStorageAccessor(
|
|||||||
private val dataEncryptor = Encryptor(key.toAesKey())
|
private val dataEncryptor = Encryptor(key.toAesKey())
|
||||||
private val pathEncryptor: EncryptorWithStaticIv? = if(pathIv != null) EncryptorWithStaticIv(key.toAesKey(), pathIv) else null
|
private val pathEncryptor: EncryptorWithStaticIv? = if(pathIv != null) EncryptorWithStaticIv(key.toAesKey(), pathIv) else null
|
||||||
|
|
||||||
|
private val syncLockMutex = Mutex()
|
||||||
private var systemHiddenFilesIsActual = false
|
private var systemHiddenFilesIsActual = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -239,6 +250,7 @@ class EncryptedStorageAccessor(
|
|||||||
|
|
||||||
override suspend fun touchFile(path: String) {
|
override suspend fun touchFile(path: String) {
|
||||||
source.touchFile(encryptPath(path))
|
source.touchFile(encryptPath(path))
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun touchDir(path: String) {
|
override suspend fun touchDir(path: String) {
|
||||||
@@ -247,11 +259,16 @@ class EncryptedStorageAccessor(
|
|||||||
|
|
||||||
override suspend fun delete(path: String) {
|
override suspend fun delete(path: String) {
|
||||||
source.delete(encryptPath(path))
|
source.delete(encryptPath(path))
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openWrite(path: String): OutputStream {
|
override suspend fun openWrite(path: String): OutputStream {
|
||||||
val stream = source.openWrite(encryptPath(path))
|
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 {
|
override suspend fun openRead(path: String): InputStream {
|
||||||
@@ -261,6 +278,7 @@ class EncryptedStorageAccessor(
|
|||||||
|
|
||||||
override suspend fun moveToTrash(path: String) {
|
override suspend fun moveToTrash(path: String) {
|
||||||
source.moveToTrash(encryptPath(path))
|
source.moveToTrash(encryptPath(path))
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dispose() {
|
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> {
|
private fun Iterable<IFile>.filterSystemHiddenFiles(): List<IFile> {
|
||||||
return this.filter { file ->
|
return this.filter { file ->
|
||||||
!file.metaInfo.path.contains(
|
!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.IFile
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -26,9 +30,14 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlin.io.path.absolute
|
import kotlin.io.path.absolute
|
||||||
import kotlin.io.path.fileSize
|
import kotlin.io.path.fileSize
|
||||||
@@ -39,6 +48,7 @@ class LocalStorageAccessor(
|
|||||||
filesystemBasePath: String,
|
filesystemBasePath: String,
|
||||||
private val ioDispatcher: CoroutineDispatcher
|
private val ioDispatcher: CoroutineDispatcher
|
||||||
) : IStorageAccessor {
|
) : IStorageAccessor {
|
||||||
|
private val syncActorId = UUID.randomUUID().toString()
|
||||||
private val _filesystemBasePath: Path = Path(filesystemBasePath).normalize().absolute()
|
private val _filesystemBasePath: Path = Path(filesystemBasePath).normalize().absolute()
|
||||||
|
|
||||||
private val _size = MutableStateFlow<Long?>(null)
|
private val _size = MutableStateFlow<Long?>(null)
|
||||||
@@ -478,8 +488,16 @@ class LocalStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
|
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
|
||||||
|
touchFileInternal(path, recordJournal = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun touchFileInternal(path: String, recordJournal: Boolean) {
|
||||||
createFile(path)
|
createFile(path)
|
||||||
|
|
||||||
|
if (recordJournal) {
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
|
||||||
|
}
|
||||||
|
|
||||||
// перебор все каталогов и обновление их времени модификации
|
// перебор все каталогов и обновление их времени модификации
|
||||||
var parent = Path(path).parent
|
var parent = Path(path).parent
|
||||||
while(parent != null) {
|
while(parent != null) {
|
||||||
@@ -502,17 +520,19 @@ class LocalStorageAccessor(
|
|||||||
else pair.file.delete()
|
else pair.file.delete()
|
||||||
pair.metaFile.delete()
|
pair.metaFile.delete()
|
||||||
scanSizeAndNumOfFiles()
|
scanSizeAndNumOfFiles()
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
|
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
|
||||||
touchFile(path)
|
touchFileInternal(path, recordJournal = false)
|
||||||
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
|
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
|
||||||
?: throw Exception("Файла нет") // TODO
|
?: throw Exception("Файла нет") // TODO
|
||||||
return@withContext pair.file.outputStream().onClosed {
|
return@withContext pair.file.outputStream().onClosed {
|
||||||
CoroutineScope(ioDispatcher).launch {
|
CoroutineScope(ioDispatcher).launch {
|
||||||
touchFile(path)
|
touchFileInternal(path, recordJournal = false)
|
||||||
scanSizeAndNumOfFiles()
|
scanSizeAndNumOfFiles()
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,6 +548,7 @@ class LocalStorageAccessor(
|
|||||||
?: throw Exception("Файла нет") // TODO
|
?: throw Exception("Файла нет") // TODO
|
||||||
val newMeta = pair.meta.copy(isDeleted = true)
|
val newMeta = pair.meta.copy(isDeleted = true)
|
||||||
writeMeta(pair.metaFile, newMeta)
|
writeMeta(pair.metaFile, newMeta)
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
|
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
|
||||||
@@ -554,11 +575,152 @@ class LocalStorageAccessor(
|
|||||||
return@withContext file.outputStream()
|
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 {
|
companion object {
|
||||||
// Файлы, которые можно использовать для чтения и записи, но не отображаются в хранилище
|
// Файлы, которые можно использовать для чтения и записи, но не отображаются в хранилище
|
||||||
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-local-storage-meta-dir"
|
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-local-storage-meta-dir"
|
||||||
private const val META_INFO_POSTFIX = ".wallenc-meta"
|
private const val META_INFO_POSTFIX = ".wallenc-meta"
|
||||||
private const val DATA_PAGE_LENGTH = 10
|
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() }
|
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.IDirectory
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
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.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -30,6 +34,8 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -53,6 +59,8 @@ class YandexStorageAccessor(
|
|||||||
private val accessorScope: CoroutineScope,
|
private val accessorScope: CoroutineScope,
|
||||||
private val reportAuthFailure: () -> Unit,
|
private val reportAuthFailure: () -> Unit,
|
||||||
) : IStorageAccessor {
|
) : IStorageAccessor {
|
||||||
|
private val syncActorId = UUID.randomUUID().toString()
|
||||||
|
private val syncLockMutex = Mutex()
|
||||||
|
|
||||||
private val diskRoot = "app:/$storageUuid"
|
private val diskRoot = "app:/$storageUuid"
|
||||||
|
|
||||||
@@ -440,6 +448,7 @@ class YandexStorageAccessor(
|
|||||||
_numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1
|
_numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1
|
||||||
persistStatsImmediate()
|
persistStatsImmediate()
|
||||||
}
|
}
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
|
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
|
||||||
@@ -478,6 +487,7 @@ class YandexStorageAccessor(
|
|||||||
}
|
}
|
||||||
guard { repo.delete(diskPath, permanently = true) }
|
guard { repo.delete(diskPath, permanently = true) }
|
||||||
scheduleStatsPersist()
|
scheduleStatsPersist()
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
|
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
|
||||||
@@ -509,6 +519,7 @@ class YandexStorageAccessor(
|
|||||||
info?.let {
|
info?.let {
|
||||||
_filesUpdates.emit(DataPage(listOf(it), pageLength = 1, pageIndex = 0))
|
_filesUpdates.emit(DataPage(listOf(it), pageLength = 1, pageIndex = 0))
|
||||||
}
|
}
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.UPSERT)
|
||||||
} finally {
|
} finally {
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
}
|
}
|
||||||
@@ -522,6 +533,7 @@ class YandexStorageAccessor(
|
|||||||
|
|
||||||
override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) {
|
override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) {
|
||||||
patchCustomProps(path, mapOf(PROP_DELETED to "true"))
|
patchCustomProps(path, mapOf(PROP_DELETED to "true"))
|
||||||
|
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
|
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) {
|
private suspend fun touchParentDirs(path: String) {
|
||||||
val normalized = path.removeSuffix("/")
|
val normalized = path.removeSuffix("/")
|
||||||
if (normalized == "/" || normalized.isBlank()) return
|
if (normalized == "/" || normalized.isBlank()) return
|
||||||
@@ -571,6 +675,8 @@ class YandexStorageAccessor(
|
|||||||
|
|
||||||
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system"
|
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system"
|
||||||
private const val STATS_FILENAME = "yandex-vault-stats.json"
|
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 STATS_DEBOUNCE_MS = 450L
|
||||||
private const val DATA_PAGE_LENGTH = 10
|
private const val DATA_PAGE_LENGTH = 10
|
||||||
private const val API_LIST_LIMIT = 200
|
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
|
package com.github.nullptroma.wallenc.domain.interfaces
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.DataPage
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
interface IStorageAccessor {
|
interface IStorageAccessor {
|
||||||
val size: StateFlow<Long?>
|
val size: StateFlow<Long?>
|
||||||
@@ -48,4 +51,12 @@ interface IStorageAccessor {
|
|||||||
*/
|
*/
|
||||||
suspend fun openReadSystemFile(name: String): InputStream
|
suspend fun openReadSystemFile(name: String): InputStream
|
||||||
suspend fun openWriteSystemFile(name: String): OutputStream
|
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"
|
room = "2.8.4"
|
||||||
retrofit = "3.0.0"
|
retrofit = "3.0.0"
|
||||||
okhttp = "5.3.2"
|
okhttp = "5.3.2"
|
||||||
|
workRuntime = "2.10.0"
|
||||||
|
hiltWork = "1.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
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-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", version.ref = "retrofit" }
|
||||||
retrofit-converter-jackson = { group = "com.squareup.retrofit2", name = "converter-jackson", 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" }
|
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" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
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.automirrored.rounded.List
|
||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material.icons.rounded.Sync
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
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.SettingsRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsScreen
|
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.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
|
import com.github.nullptroma.wallenc.ui.theme.WallencTheme
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ fun WallencNavRoot(
|
|||||||
|
|
||||||
val mainViewModel: MainViewModel = hiltViewModel()
|
val mainViewModel: MainViewModel = hiltViewModel()
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
val storageSyncViewModel: StorageSyncViewModel = hiltViewModel()
|
||||||
|
|
||||||
val topLevelRoutes = viewModel.routes
|
val topLevelRoutes = viewModel.routes
|
||||||
|
|
||||||
@@ -90,6 +95,11 @@ fun WallencNavRoot(
|
|||||||
Icons.AutoMirrored.Rounded.List,
|
Icons.AutoMirrored.Rounded.List,
|
||||||
R.string.task_pipeline_open,
|
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(
|
SettingsRoute::class.qualifiedName!! to NavBarItemData(
|
||||||
R.string.nav_label_settings,
|
R.string.nav_label_settings,
|
||||||
SettingsRoute::class.qualifiedName!!,
|
SettingsRoute::class.qualifiedName!!,
|
||||||
@@ -158,6 +168,20 @@ fun WallencNavRoot(
|
|||||||
}) {
|
}) {
|
||||||
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
|
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>(
|
composable<TaskPipelineRoute>(
|
||||||
deepLinks = listOf(
|
deepLinks = listOf(
|
||||||
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },
|
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.MainRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute
|
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.settings.SettingsRoute
|
||||||
|
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS
|
|||||||
mapOf(
|
mapOf(
|
||||||
MainRoute::class.qualifiedName!! to MainRoute(),
|
MainRoute::class.qualifiedName!! to MainRoute(),
|
||||||
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
|
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
|
||||||
|
StorageSyncRoute::class.qualifiedName!! to StorageSyncRoute(),
|
||||||
SettingsRoute::class.qualifiedName!! to SettingsRoute()
|
SettingsRoute::class.qualifiedName!! to SettingsRoute()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ object WallencDeepLinks {
|
|||||||
object Host {
|
object Host {
|
||||||
const val MAIN = "main"
|
const val MAIN = "main"
|
||||||
const val TASKS = "tasks"
|
const val TASKS = "tasks"
|
||||||
|
const val SYNC = "sync"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
const val MAIN_URI_PATTERN = "$SCHEME://${Host.MAIN}"
|
const val MAIN_URI_PATTERN = "$SCHEME://${Host.MAIN}"
|
||||||
const val TASKS_URI_PATTERN = "$SCHEME://${Host.TASKS}"
|
const val TASKS_URI_PATTERN = "$SCHEME://${Host.TASKS}"
|
||||||
|
const val SYNC_URI_PATTERN = "$SCHEME://${Host.SYNC}"
|
||||||
const val SETTINGS_URI_PATTERN = "$SCHEME://${Host.SETTINGS}"
|
const val SETTINGS_URI_PATTERN = "$SCHEME://${Host.SETTINGS}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import com.github.nullptroma.wallenc.ui.R
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) {
|
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 = 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_local_vault">Local</string>
|
||||||
<string name="nav_label_remote_vaults">Remotes</string>
|
<string name="nav_label_remote_vaults">Remotes</string>
|
||||||
<string name="nav_label_main">Main</string>
|
<string name="nav_label_main">Main</string>
|
||||||
|
<string name="nav_label_sync">Sync</string>
|
||||||
<string name="nav_label_settings">Settings</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="no_name"><noname></string>
|
||||||
<string name="show_storage_item_menu">Show storage item menu</string>
|
<string name="show_storage_item_menu">Show storage item menu</string>
|
||||||
<string name="rename">Rename</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