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

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

View File

@@ -63,8 +63,11 @@ dependencies {
// Hilt
implementation(libs.dagger.hilt)
ksp(libs.dagger.hilt.compiler)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))

View File

@@ -1,18 +1,33 @@
package com.github.nullptroma.wallenc.app
import android.app.Application
import androidx.work.Configuration
import androidx.hilt.work.HiltWorkerFactory
import com.github.nullptroma.wallenc.app.sync.StorageSyncBootstrap
import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class WallencApplication : Application() {
class WallencApplication : Application(), Configuration.Provider {
@Inject
lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
@Inject
lateinit var storageSyncBootstrap: StorageSyncBootstrap
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
taskPipelineForegroundBootstrap.start()
storageSyncBootstrap.start()
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}

View File

@@ -15,10 +15,12 @@ import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.Yande
import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.repository.YandexUserInfoRepository
import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore
import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore
import com.github.nullptroma.wallenc.app.sync.StorageSyncGroupStore
import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator
import com.github.nullptroma.wallenc.infrastructure.vaults.VaultsManager
import com.github.nullptroma.wallenc.infrastructure.vaults.local.LocalVault
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
@@ -133,6 +135,12 @@ class SingletonModule {
return StorageMetaInfoRepository(dao, ioDispatcher)
}
@Provides
@Singleton
fun provideStorageSyncGroupStore(
@ApplicationContext context: Context,
): IStorageSyncGroupStore = StorageSyncGroupStore(context)
@Provides
@Singleton
fun provideYandexAccountRepository(

View File

@@ -1,12 +1,18 @@
package com.github.nullptroma.wallenc.app.di.modules.domain
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
import com.github.nullptroma.wallenc.usecases.StorageSyncEngine
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
import dagger.Module
import dagger.Provides
@@ -58,4 +64,27 @@ class UseCasesModule {
): RemoveStorageUseCase {
return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
}
@Provides
@Singleton
fun provideStorageSyncEngine(
vaultsManager: IVaultsManager,
groupStore: IStorageSyncGroupStore,
): IStorageSyncEngine = StorageSyncEngine(
vaultsManager = vaultsManager,
groupStore = groupStore,
)
@Provides
@Singleton
fun provideManageStorageSyncGroupsUseCase(
groupStore: IStorageSyncGroupStore,
): ManageStorageSyncGroupsUseCase = ManageStorageSyncGroupsUseCase(groupStore)
@Provides
@Singleton
fun provideRunStorageSyncUseCase(
orchestrator: ITaskOrchestrator,
syncEngine: IStorageSyncEngine,
): RunStorageSyncUseCase = RunStorageSyncUseCase(orchestrator, syncEngine)
}

View File

@@ -0,0 +1,52 @@
package com.github.nullptroma.wallenc.app.sync
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@OptIn(FlowPreview::class)
class StorageSyncBootstrap @Inject constructor(
private val scheduler: StorageSyncScheduler,
private val vaultsManager: IVaultsManager,
private val syncRunner: RunStorageSyncUseCase,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun start() {
scheduler.ensureScheduled()
scope.launch {
vaultsManager.allStorages.collectLatest { storages ->
if (storages.isEmpty()) {
return@collectLatest
}
val triggers = storages.flatMap { storage ->
listOf(
storage.accessor.filesUpdates.map { Unit },
storage.accessor.dirsUpdates.map { Unit },
)
}
merge(*triggers.toTypedArray())
.debounce(DEBOUNCE_AFTER_CHANGE_MS)
.collect {
syncRunner.enqueue("debounce")
}
}
}
}
private companion object {
private const val DEBOUNCE_AFTER_CHANGE_MS = 60_000L
}
}

View File

@@ -0,0 +1,87 @@
package com.github.nullptroma.wallenc.app.sync
import android.content.Context
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import androidx.core.content.edit
@Singleton
class StorageSyncGroupStore @Inject constructor(
@param:ApplicationContext private val app: Context,
) : IStorageSyncGroupStore {
private val prefs by lazy {
app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
override suspend fun getGroups(): List<StorageSyncGroup> {
val raw = prefs.getString(KEY_GROUPS, null).orEmpty()
return parse(raw)
}
override suspend fun putGroup(group: StorageSyncGroup) {
val current = getGroups().toMutableList()
val idx = current.indexOfFirst { it.id == group.id }
if (idx >= 0) {
current[idx] = group
} else {
current.add(group)
}
persist(current)
}
override suspend fun removeGroup(groupId: String) {
val current = getGroups().filterNot { it.id == groupId }
persist(current)
}
private fun parse(raw: String): List<StorageSyncGroup> {
if (raw.isBlank()) {
return emptyList()
}
return raw.lineSequence()
.mapNotNull { line ->
val trimmed = line.trim()
if (trimmed.isBlank()) {
return@mapNotNull null
}
val split = trimmed.split("=", limit = 2)
if (split.size != 2) {
return@mapNotNull null
}
val id = split[0].trim()
if (id.isBlank()) {
return@mapNotNull null
}
val uuids = split[1]
.split(",")
.mapNotNull { token ->
val value = token.trim()
if (value.isBlank()) {
null
} else {
runCatching { UUID.fromString(value) }.getOrNull()
}
}
.toSet()
StorageSyncGroup(id = id, storageUuids = uuids)
}
.toList()
}
private fun persist(groups: List<StorageSyncGroup>) {
val raw = groups.joinToString(separator = "\n") { group ->
val uuids = group.storageUuids.joinToString(separator = ",") { it.toString() }
"${group.id}=$uuids"
}
prefs.edit { putString(KEY_GROUPS, raw) }
}
private companion object {
private const val PREFS_NAME = "wallenc_storage_sync"
private const val KEY_GROUPS = "groups"
}
}

View File

@@ -0,0 +1,36 @@
package com.github.nullptroma.wallenc.app.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StorageSyncScheduler @Inject constructor(
@param:ApplicationContext private val app: Context,
) {
fun ensureScheduled() {
val request = PeriodicWorkRequestBuilder<StorageSyncWorker>(
repeatInterval = 30,
repeatIntervalTimeUnit = TimeUnit.MINUTES,
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
WorkManager.getInstance(app).enqueueUniquePeriodicWork(
StorageSyncWorker.UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}

View File

@@ -0,0 +1,30 @@
package com.github.nullptroma.wallenc.app.sync
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class StorageSyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val syncRunner: RunStorageSyncUseCase,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return runCatching {
syncRunner.runBlocking()
Result.success()
}.getOrElse {
Result.retry()
}
}
companion object {
const val UNIQUE_WORK_NAME = "wallenc-storage-sync-periodic"
}
}