Улучшена фоновая синхронизация, обработана ошибка
This commit is contained in:
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user