diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13d8983..cfab5b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + @@ -72,6 +73,14 @@ android:exported="false" android:foregroundServiceType="dataSync" android:description="@string/fgs_task_pipeline_description" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/StorageSyncBootEntryPoint.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/StorageSyncBootEntryPoint.kt new file mode 100644 index 0000000..5ecf878 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/StorageSyncBootEntryPoint.kt @@ -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 +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootReceiver.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootReceiver.kt new file mode 100644 index 0000000..46c3bd3 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootReceiver.kt @@ -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") + } +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt index dfffccd..0cf310d 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt @@ -1,9 +1,11 @@ package com.github.nullptroma.wallenc.app.sync 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.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase +import com.github.nullptroma.wallenc.usecases.StorageSyncReadiness import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -11,10 +13,13 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -24,11 +29,15 @@ class StorageSyncBootstrap @Inject constructor( private val scheduler: StorageSyncScheduler, private val vaultsManager: IVaultsManager, private val syncRunner: RunStorageSyncUseCase, + private val syncReadiness: StorageSyncReadiness, + private val groupStore: IStorageSyncGroupStore, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val startupSyncScheduled = AtomicBoolean(false) fun start() { scheduler.ensureScheduled() + scheduleStartupSyncOnce() scope.launch { combine( vaultsManager.allStorages, @@ -77,4 +86,31 @@ class StorageSyncBootstrap @Inject constructor( } 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) + } + } } diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt index f318b6d..2612755 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncScheduler.kt @@ -27,9 +27,10 @@ class StorageSyncScheduler @Inject constructor( ) .build() + // KEEP: UPDATE сбрасывает таймер periodic work при каждом onCreate процесса. WorkManager.getInstance(app).enqueueUniquePeriodicWork( StorageSyncWorker.UNIQUE_WORK_NAME, - ExistingPeriodicWorkPolicy.UPDATE, + ExistingPeriodicWorkPolicy.KEEP, request, ) } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt index 3ddc50a..0cef58f 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt @@ -29,7 +29,9 @@ private fun mapVaultIo(e: IOException): WallencException { WallencException.Auth.TokenMissing() msg.contains("HTTP 423", ignoreCase = true) || msg.contains("423 after retries", ignoreCase = true) -> 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() msg.contains("async operation failed", ignoreCase = true) -> WallencException.Network.OperationFailed() diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt index 300fbc9..1981a5e 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt @@ -10,6 +10,7 @@ import retrofit2.Retrofit import retrofit2.converter.jackson.JacksonConverterFactory import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit /** * Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault, @@ -27,14 +28,14 @@ class YandexDiskApiFactory( /** Без авторизации — только для одноразовых ссылок upload/download. */ val rawHttpClient: OkHttpClient by lazy { - OkHttpClient.Builder().build() + newHttpClientBuilder().build() } /** * [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД). */ fun createAuthenticatedApi(tokenProvider: () -> String?): YandexDiskApi { - val client = OkHttpClient.Builder() + val client = newHttpClientBuilder() .addInterceptor { chain -> val token = tokenProvider() ?: throw java.io.IOException("Yandex OAuth token is missing") @@ -74,11 +75,21 @@ class YandexDiskApiFactory( companion object { 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( oauthToken: String, ioDispatcher: CoroutineDispatcher, ): YandexDiskRepository { - val client = OkHttpClient.Builder() + val client = newHttpClientBuilder() .addInterceptor { chain -> chain.proceed( chain.request().newBuilder() diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt index d761522..b0ddc31 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt @@ -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.IStorageAccessor import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DisposableHandle -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.withContext import java.util.UUID @@ -25,8 +26,13 @@ class EncryptedStorage private constructor( metaInfoFilePostfix = STORAGE_INFO_FILE_POSTFIX, ), DisposableHandle { - private val job = Job() - private val scope = CoroutineScope(ioDispatcher + job) + private val job = SupervisorJob() + private val scope = CoroutineScope( + ioDispatcher + job + CoroutineExceptionHandler { _, throwable -> + System.err.println("EncryptedStorage: uncaught coroutine failure: ${throwable.message}") + throwable.printStackTrace() + }, + ) private val encInfo = source.metaInfo.value.encInfo ?: throw WallencException.Storage.NotEncrypted() diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt index 82deb21..5575c58 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorageAccessor.kt @@ -82,34 +82,40 @@ class EncryptedStorageAccessor( private fun collectSourceState() { scope.launch { launch { - source.filesUpdates.collect { page -> - val files = page.data.map(::decryptEntity).filterSystemHiddenFiles() - _filesUpdates.emit( - DataPage( - list = files, - isLoading = page.isLoading, - isError = page.isError, - hasNext = page.hasNext, - pageLength = page.pageLength, - pageIndex = page.pageIndex, + try { + source.filesUpdates.collect { page -> + val files = page.data.map(::decryptEntity).filterSystemHiddenFiles() + _filesUpdates.emit( + DataPage( + list = files, + isLoading = page.isLoading, + isError = page.isError, + hasNext = page.hasNext, + pageLength = page.pageLength, + pageIndex = page.pageIndex, + ), ) - ) + } + } catch (_: Exception) { } } launch { - source.dirsUpdates.collect { page -> - val dirs = page.data.map(::decryptEntity).filterSystemHiddenDirs() - _dirsUpdates.emit( - DataPage( - list = dirs, - isLoading = page.isLoading, - isError = page.isError, - hasNext = page.hasNext, - pageLength = page.pageLength, - pageIndex = page.pageIndex, + try { + source.dirsUpdates.collect { page -> + val dirs = page.data.map(::decryptEntity).filterSystemHiddenDirs() + _dirsUpdates.emit( + DataPage( + list = dirs, + isLoading = page.isLoading, + isError = page.isError, + hasNext = page.hasNext, + pageLength = page.pageLength, + pageIndex = page.pageIndex, + ), ) - ) + } + } catch (_: Exception) { } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt index bd97f02..85c2b15 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.delay import kotlin.coroutines.coroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -113,8 +114,14 @@ class YandexStorageAccessor( _size.value = persisted.totalBytes _numberOfFiles.value = persisted.fileCount } else { - scanSizeAndNumOfFiles() - writePersistedStatsInternal() + try { + scanSizeAndNumOfFiles() + writePersistedStatsInternal() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Полный обход дерева не обязателен для работы; при таймауте сети storage остаётся доступным. + } } _storageReady.value = true } catch (e: YandexDiskAuthException) { @@ -233,8 +240,8 @@ class YandexStorageAccessor( } catch (e: YandexDiskAuthException) { reportAuthFailure() throw e - } catch (_: IOException) { - // Запись stats — best-effort; сетевые сбои не роняем процесс (ошибки в лог UI не выводятся). + } catch (_: Exception) { + // Запись stats — best-effort; сетевые сбои не роняем процесс. } } } @@ -378,35 +385,15 @@ class YandexStorageAccessor( } override fun getFilesFlow(path: String): Flow> = flow { - val all = withContext(ioDispatcher) { listImmediateChildren(path).first } - 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, - ), - ) + val all = try { + withContext(ioDispatcher) { listImmediateChildren(path).first } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + emit(filesFlowErrorPage()) + return@flow } + emitAllFilesPages(all) }.flowOn(ioDispatcher) override suspend fun getAllDirs(): List = withContext(ioDispatcher) { @@ -432,35 +419,15 @@ class YandexStorageAccessor( } override fun getDirsFlow(path: String): Flow> = flow { - val all = withContext(ioDispatcher) { listImmediateChildren(path).second } - 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, - ), - ) + val all = try { + withContext(ioDispatcher) { listImmediateChildren(path).second } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + emit(dirsFlowErrorPage()) + return@flow } + emitAllDirsPages(all) }.flowOn(ioDispatcher) override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) { @@ -753,6 +720,88 @@ class YandexStorageAccessor( guard { repo.setCustomProperties(toDiskPath(path), props) } } + private suspend fun FlowCollector>.emitAllFilesPages(all: List) { + 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>.emitAllDirsPages(all: List) { + 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 = + DataPage( + list = emptyList(), + isLoading = false, + isError = true, + hasNext = false, + pageLength = DATA_PAGE_LENGTH, + pageIndex = 0, + ) + + private fun dirsFlowErrorPage(): DataPage = + DataPage( + list = emptyList(), + isLoading = false, + isError = true, + hasNext = false, + pageLength = DATA_PAGE_LENGTH, + pageIndex = 0, + ) + companion object { private val statsMapper = jacksonObjectMapper().apply { findAndRegisterModules() } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/VaultsManager.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/VaultsManager.kt index 61143c4..c15170c 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/VaultsManager.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/VaultsManager.kt @@ -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.VaultRegistration import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob @@ -38,7 +39,14 @@ class VaultsManager( private val yandexDiskRepositoryFactory: YandexDiskRepositoryFactory, ) : 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> = yandexAccountStore.observeAll() .map { rows -> diff --git a/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt index 268fb1d..0bcd4ad 100644 --- a/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt +++ b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt @@ -10,6 +10,7 @@ import retrofit2.HttpException import retrofit2.Response import java.io.FileNotFoundException import java.io.IOException +import java.net.SocketTimeoutException class VaultThrowableMappingTest { @@ -33,6 +34,12 @@ class VaultThrowableMappingTest { assertTrue(mapped is WallencException.Auth.TokenMissing) } + @Test + fun mapsSocketTimeoutToOperationTimedOut() { + val mapped = SocketTimeoutException("timeout").toVaultWallencException() + assertTrue(mapped is WallencException.Network.OperationTimedOut) + } + @Test fun mapsFileNotFoundToStorageFileNotFound() { val mapped = FileNotFoundException("x").toVaultWallencException() diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt index 768cfae..94b5572 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt @@ -1,5 +1,6 @@ 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.interfaces.IStorageSyncEngine 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.TaskRunState import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -87,12 +90,24 @@ class RunStorageSyncUseCase @Inject constructor( } val taskId = _activeSyncTaskId.value ?: return StorageSyncRunOutcome.Completed - syncRunning.filter { !it }.first() - val state = orchestrator.pipelineState.value.tasks.find { it.id == taskId }?.state - return when (state) { - is TaskRunState.Failed -> StorageSyncRunOutcome.Failed(state.error) - TaskRunState.Cancelled -> StorageSyncRunOutcome.Cancelled - else -> StorageSyncRunOutcome.Completed + return try { + withTimeout(SYNC_AWAIT_TIMEOUT_MS) { + syncRunning.filter { !it }.first() + } + val state = orchestrator.pipelineState.value.tasks.find { it.id == taskId }?.state + 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 { /** Пауза после последнего изменения перед debounce-sync; же окно подавления после sync. */ const val DEBOUNCE_AFTER_CHANGE_MS = 60_000L + + /** Максимальное ожидание фонового worker (WorkManager) — не блокировать periodic work навсегда. */ + private const val SYNC_AWAIT_TIMEOUT_MS = 25L * 60 * 1000 } }