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