Улучшена фоновая синхронизация, обработана ошибка

This commit is contained in:
2026-05-23 23:36:35 +03:00
parent 6e719e7f52
commit adc3730b8d
13 changed files with 284 additions and 97 deletions

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -72,6 +73,14 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:description="@string/fgs_task_pipeline_description" /> android:description="@string/fgs_task_pipeline_description" />
<receiver
android:name=".sync.StorageSyncBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.app.di
import com.github.nullptroma.wallenc.app.sync.StorageSyncScheduler
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@EntryPoint
@InstallIn(SingletonComponent::class)
interface StorageSyncBootEntryPoint {
fun storageSyncScheduler(): StorageSyncScheduler
}

View File

@@ -0,0 +1,22 @@
package com.github.nullptroma.wallenc.app.sync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.github.nullptroma.wallenc.app.di.StorageSyncBootEntryPoint
import dagger.hilt.android.EntryPointAccessors
import timber.log.Timber
class StorageSyncBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) {
return
}
val scheduler = EntryPointAccessors.fromApplication(
context.applicationContext,
StorageSyncBootEntryPoint::class.java,
).storageSyncScheduler()
scheduler.ensureScheduled()
Timber.d("Rescheduled periodic storage sync after boot")
}
}

View File

@@ -1,9 +1,11 @@
package com.github.nullptroma.wallenc.app.sync package com.github.nullptroma.wallenc.app.sync
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.usecases.StorageSyncReadiness
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -11,10 +13,13 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -24,11 +29,15 @@ class StorageSyncBootstrap @Inject constructor(
private val scheduler: StorageSyncScheduler, private val scheduler: StorageSyncScheduler,
private val vaultsManager: IVaultsManager, private val vaultsManager: IVaultsManager,
private val syncRunner: RunStorageSyncUseCase, private val syncRunner: RunStorageSyncUseCase,
private val syncReadiness: StorageSyncReadiness,
private val groupStore: IStorageSyncGroupStore,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val startupSyncScheduled = AtomicBoolean(false)
fun start() { fun start() {
scheduler.ensureScheduled() scheduler.ensureScheduled()
scheduleStartupSyncOnce()
scope.launch { scope.launch {
combine( combine(
vaultsManager.allStorages, vaultsManager.allStorages,
@@ -77,4 +86,31 @@ class StorageSyncBootstrap @Inject constructor(
} }
return System.currentTimeMillis() >= syncRunner.debounceSuppressUntilMs.value return System.currentTimeMillis() >= syncRunner.debounceSuppressUntilMs.value
} }
/**
* Одна синхронизация после готовности хранилищ при старте процесса — не ждать только WorkManager
* (особенно если periodic work откладывался из‑за перезапусков процесса).
*/
private fun scheduleStartupSyncOnce() {
scope.launch {
combine(
vaultsManager.allStorages,
vaultsManager.unlockManager.openedStorages,
) { rootStorages, opened ->
(rootStorages + opened.values).distinctBy { it.uuid }
}
.map { it.isNotEmpty() }
.distinctUntilChanged()
.filter { it }
.first()
if (!startupSyncScheduled.compareAndSet(false, true)) {
return@launch
}
if (groupStore.getGroups().isEmpty()) {
return@launch
}
syncReadiness.awaitReady()
syncRunner.enqueue(StorageSyncTriggerReason.Background)
}
}
} }

View File

@@ -27,9 +27,10 @@ class StorageSyncScheduler @Inject constructor(
) )
.build() .build()
// KEEP: UPDATE сбрасывает таймер periodic work при каждом onCreate процесса.
WorkManager.getInstance(app).enqueueUniquePeriodicWork( WorkManager.getInstance(app).enqueueUniquePeriodicWork(
StorageSyncWorker.UNIQUE_WORK_NAME, StorageSyncWorker.UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE, ExistingPeriodicWorkPolicy.KEEP,
request, request,
) )
} }

View File

@@ -29,7 +29,9 @@ private fun mapVaultIo(e: IOException): WallencException {
WallencException.Auth.TokenMissing() WallencException.Auth.TokenMissing()
msg.contains("HTTP 423", ignoreCase = true) || msg.contains("423 after retries", ignoreCase = true) -> msg.contains("HTTP 423", ignoreCase = true) || msg.contains("423 after retries", ignoreCase = true) ->
WallencException.Network.ResourceLocked() WallencException.Network.ResourceLocked()
msg.contains("async operation timed out", ignoreCase = true) -> msg.equals("timeout", ignoreCase = true) ||
msg.contains("timed out", ignoreCase = true) ||
msg.contains("async operation timed out", ignoreCase = true) ->
WallencException.Network.OperationTimedOut() WallencException.Network.OperationTimedOut()
msg.contains("async operation failed", ignoreCase = true) -> msg.contains("async operation failed", ignoreCase = true) ->
WallencException.Network.OperationFailed() WallencException.Network.OperationFailed()

View File

@@ -10,6 +10,7 @@ import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory import retrofit2.converter.jackson.JacksonConverterFactory
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/** /**
* Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault, * Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault,
@@ -27,14 +28,14 @@ class YandexDiskApiFactory(
/** Без авторизации — только для одноразовых ссылок upload/download. */ /** Без авторизации — только для одноразовых ссылок upload/download. */
val rawHttpClient: OkHttpClient by lazy { val rawHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build() newHttpClientBuilder().build()
} }
/** /**
* [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД). * [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД).
*/ */
fun createAuthenticatedApi(tokenProvider: () -> String?): YandexDiskApi { fun createAuthenticatedApi(tokenProvider: () -> String?): YandexDiskApi {
val client = OkHttpClient.Builder() val client = newHttpClientBuilder()
.addInterceptor { chain -> .addInterceptor { chain ->
val token = tokenProvider() val token = tokenProvider()
?: throw java.io.IOException("Yandex OAuth token is missing") ?: throw java.io.IOException("Yandex OAuth token is missing")
@@ -74,11 +75,21 @@ class YandexDiskApiFactory(
companion object { companion object {
const val BASE_URL = "https://cloud-api.yandex.net/" const val BASE_URL = "https://cloud-api.yandex.net/"
private const val CONNECT_TIMEOUT_SEC = 30L
private const val READ_TIMEOUT_SEC = 120L
private const val WRITE_TIMEOUT_SEC = 120L
fun newHttpClientBuilder(): OkHttpClient.Builder =
OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SEC, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT_SEC, TimeUnit.SECONDS)
fun createRepositoryWithToken( fun createRepositoryWithToken(
oauthToken: String, oauthToken: String,
ioDispatcher: CoroutineDispatcher, ioDispatcher: CoroutineDispatcher,
): YandexDiskRepository { ): YandexDiskRepository {
val client = OkHttpClient.Builder() val client = newHttpClientBuilder()
.addInterceptor { chain -> .addInterceptor { chain ->
chain.proceed( chain.proceed(
chain.request().newBuilder() chain.request().newBuilder()

View File

@@ -8,9 +8,10 @@ import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -25,8 +26,13 @@ class EncryptedStorage private constructor(
metaInfoFilePostfix = STORAGE_INFO_FILE_POSTFIX, metaInfoFilePostfix = STORAGE_INFO_FILE_POSTFIX,
), DisposableHandle { ), DisposableHandle {
private val job = Job() private val job = SupervisorJob()
private val scope = CoroutineScope(ioDispatcher + job) private val scope = CoroutineScope(
ioDispatcher + job + CoroutineExceptionHandler { _, throwable ->
System.err.println("EncryptedStorage: uncaught coroutine failure: ${throwable.message}")
throwable.printStackTrace()
},
)
private val encInfo = private val encInfo =
source.metaInfo.value.encInfo ?: throw WallencException.Storage.NotEncrypted() source.metaInfo.value.encInfo ?: throw WallencException.Storage.NotEncrypted()

View File

@@ -82,34 +82,40 @@ class EncryptedStorageAccessor(
private fun collectSourceState() { private fun collectSourceState() {
scope.launch { scope.launch {
launch { launch {
source.filesUpdates.collect { page -> try {
val files = page.data.map(::decryptEntity).filterSystemHiddenFiles() source.filesUpdates.collect { page ->
_filesUpdates.emit( val files = page.data.map(::decryptEntity).filterSystemHiddenFiles()
DataPage( _filesUpdates.emit(
list = files, DataPage(
isLoading = page.isLoading, list = files,
isError = page.isError, isLoading = page.isLoading,
hasNext = page.hasNext, isError = page.isError,
pageLength = page.pageLength, hasNext = page.hasNext,
pageIndex = page.pageIndex, pageLength = page.pageLength,
pageIndex = page.pageIndex,
),
) )
) }
} catch (_: Exception) {
} }
} }
launch { launch {
source.dirsUpdates.collect { page -> try {
val dirs = page.data.map(::decryptEntity).filterSystemHiddenDirs() source.dirsUpdates.collect { page ->
_dirsUpdates.emit( val dirs = page.data.map(::decryptEntity).filterSystemHiddenDirs()
DataPage( _dirsUpdates.emit(
list = dirs, DataPage(
isLoading = page.isLoading, list = dirs,
isError = page.isError, isLoading = page.isLoading,
hasNext = page.hasNext, isError = page.isError,
pageLength = page.pageLength, hasNext = page.hasNext,
pageIndex = page.pageIndex, pageLength = page.pageLength,
pageIndex = page.pageIndex,
),
) )
) }
} catch (_: Exception) {
} }
} }

View File

@@ -31,6 +31,7 @@ import kotlinx.coroutines.delay
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -113,8 +114,14 @@ class YandexStorageAccessor(
_size.value = persisted.totalBytes _size.value = persisted.totalBytes
_numberOfFiles.value = persisted.fileCount _numberOfFiles.value = persisted.fileCount
} else { } else {
scanSizeAndNumOfFiles() try {
writePersistedStatsInternal() scanSizeAndNumOfFiles()
writePersistedStatsInternal()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Полный обход дерева не обязателен для работы; при таймауте сети storage остаётся доступным.
}
} }
_storageReady.value = true _storageReady.value = true
} catch (e: YandexDiskAuthException) { } catch (e: YandexDiskAuthException) {
@@ -233,8 +240,8 @@ class YandexStorageAccessor(
} catch (e: YandexDiskAuthException) { } catch (e: YandexDiskAuthException) {
reportAuthFailure() reportAuthFailure()
throw e throw e
} catch (_: IOException) { } catch (_: Exception) {
// Запись stats — best-effort; сетевые сбои не роняем процесс (ошибки в лог UI не выводятся). // Запись stats — best-effort; сетевые сбои не роняем процесс.
} }
} }
} }
@@ -378,35 +385,15 @@ class YandexStorageAccessor(
} }
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = flow { override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = flow {
val all = withContext(ioDispatcher) { listImmediateChildren(path).first } val all = try {
var pageIndex = 0 withContext(ioDispatcher) { listImmediateChildren(path).first }
var i = 0 } catch (e: CancellationException) {
while (i < all.size) { throw e
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList() } catch (_: Exception) {
emit( emit(filesFlowErrorPage())
DataPage( return@flow
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
} }
emitAllFilesPages(all)
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)
override suspend fun getAllDirs(): List<IDirectory> = withContext(ioDispatcher) { override suspend fun getAllDirs(): List<IDirectory> = withContext(ioDispatcher) {
@@ -432,35 +419,15 @@ class YandexStorageAccessor(
} }
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = flow { override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = flow {
val all = withContext(ioDispatcher) { listImmediateChildren(path).second } val all = try {
var pageIndex = 0 withContext(ioDispatcher) { listImmediateChildren(path).second }
var i = 0 } catch (e: CancellationException) {
while (i < all.size) { throw e
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList() } catch (_: Exception) {
emit( emit(dirsFlowErrorPage())
DataPage( return@flow
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
} }
emitAllDirsPages(all)
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)
override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) { override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) {
@@ -753,6 +720,88 @@ class YandexStorageAccessor(
guard { repo.setCustomProperties(toDiskPath(path), props) } guard { repo.setCustomProperties(toDiskPath(path), props) }
} }
private suspend fun FlowCollector<DataPage<IFile>>.emitAllFilesPages(all: List<IFile>) {
var pageIndex = 0
var i = 0
while (i < all.size) {
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
emit(
DataPage(
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
}
}
private suspend fun FlowCollector<DataPage<IDirectory>>.emitAllDirsPages(all: List<IDirectory>) {
var pageIndex = 0
var i = 0
while (i < all.size) {
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
emit(
DataPage(
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
}
}
private fun filesFlowErrorPage(): DataPage<IFile> =
DataPage(
list = emptyList(),
isLoading = false,
isError = true,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
)
private fun dirsFlowErrorPage(): DataPage<IDirectory> =
DataPage(
list = emptyList(),
isLoading = false,
isError = true,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
)
companion object { companion object {
private val statsMapper = jacksonObjectMapper().apply { findAndRegisterModules() } private val statsMapper = jacksonObjectMapper().apply { findAndRegisterModules() }

View File

@@ -15,6 +15,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
import com.github.nullptroma.wallenc.vault.contract.VaultRegistration import com.github.nullptroma.wallenc.vault.contract.VaultRegistration
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -38,7 +39,14 @@ class VaultsManager(
private val yandexDiskRepositoryFactory: YandexDiskRepositoryFactory, private val yandexDiskRepositoryFactory: YandexDiskRepositoryFactory,
) : IVaultsManager, VaultRegistrar { ) : IVaultsManager, VaultRegistrar {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) private val scope = CoroutineScope(
SupervisorJob() +
ioDispatcher +
CoroutineExceptionHandler { _, throwable ->
System.err.println("VaultsManager: uncaught coroutine failure: ${throwable.message}")
throwable.printStackTrace()
},
)
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountStore.observeAll() private val yandexVaults: StateFlow<List<IVault>> = yandexAccountStore.observeAll()
.map { rows -> .map { rows ->

View File

@@ -10,6 +10,7 @@ import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.net.SocketTimeoutException
class VaultThrowableMappingTest { class VaultThrowableMappingTest {
@@ -33,6 +34,12 @@ class VaultThrowableMappingTest {
assertTrue(mapped is WallencException.Auth.TokenMissing) assertTrue(mapped is WallencException.Auth.TokenMissing)
} }
@Test
fun mapsSocketTimeoutToOperationTimedOut() {
val mapped = SocketTimeoutException("timeout").toVaultWallencException()
assertTrue(mapped is WallencException.Network.OperationTimedOut)
}
@Test @Test
fun mapsFileNotFoundToStorageFileNotFound() { fun mapsFileNotFoundToStorageFileNotFound() {
val mapped = FileNotFoundException("x").toVaultWallencException() val mapped = FileNotFoundException("x").toVaultWallencException()

View File

@@ -1,5 +1,6 @@
package com.github.nullptroma.wallenc.usecases package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.errors.toWallencException import com.github.nullptroma.wallenc.domain.errors.toWallencException
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
@@ -11,11 +12,13 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -87,12 +90,24 @@ class RunStorageSyncUseCase @Inject constructor(
} }
val taskId = _activeSyncTaskId.value val taskId = _activeSyncTaskId.value
?: return StorageSyncRunOutcome.Completed ?: return StorageSyncRunOutcome.Completed
syncRunning.filter { !it }.first() return try {
val state = orchestrator.pipelineState.value.tasks.find { it.id == taskId }?.state withTimeout(SYNC_AWAIT_TIMEOUT_MS) {
return when (state) { syncRunning.filter { !it }.first()
is TaskRunState.Failed -> StorageSyncRunOutcome.Failed(state.error) }
TaskRunState.Cancelled -> StorageSyncRunOutcome.Cancelled val state = orchestrator.pipelineState.value.tasks.find { it.id == taskId }?.state
else -> StorageSyncRunOutcome.Completed when (state) {
is TaskRunState.Failed -> StorageSyncRunOutcome.Failed(state.error)
TaskRunState.Cancelled -> StorageSyncRunOutcome.Cancelled
else -> StorageSyncRunOutcome.Completed
}
} catch (_: TimeoutCancellationException) {
orchestrator.cancel(taskId)
clearRunningState()
StorageSyncRunOutcome.Failed(
WallencException.Unknown(
cause = IllegalStateException("Storage sync await timed out after ${SYNC_AWAIT_TIMEOUT_MS}ms"),
),
)
} }
} }
@@ -134,5 +149,8 @@ class RunStorageSyncUseCase @Inject constructor(
companion object { companion object {
/** Пауза после последнего изменения перед debounce-sync; же окно подавления после sync. */ /** Пауза после последнего изменения перед debounce-sync; же окно подавления после sync. */
const val DEBOUNCE_AFTER_CHANGE_MS = 60_000L const val DEBOUNCE_AFTER_CHANGE_MS = 60_000L
/** Максимальное ожидание фонового worker (WorkManager) — не блокировать periodic work навсегда. */
private const val SYNC_AWAIT_TIMEOUT_MS = 25L * 60 * 1000
} }
} }