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 42e8e3b..c9472b0 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 @@ -4,6 +4,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.resources.UiStringResolver +import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -61,7 +62,7 @@ class StorageSyncBootstrap @Inject constructor( } syncRunner.enqueue( displayTitle = uiStrings(R.string.task_title_storage_sync_background), - logReason = "debounce", + reason = StorageSyncTriggerReason.Debounce, ) } } diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt index f9c2ca3..1d2c930 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -19,7 +20,7 @@ class StorageSyncWorker @AssistedInject constructor( override suspend fun doWork(): Result { Timber.d("Periodic storage sync started (attempt %d)", runAttemptCount) return runCatching { - syncRunner.runBlocking() + syncRunner.runBlocking(StorageSyncTriggerReason.Background) Timber.d("Periodic storage sync finished") Result.success() }.getOrElse { error -> diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt index 4b204df..c706492 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt @@ -20,4 +20,7 @@ interface ITaskOrchestrator { fun cancel(taskId: TaskId): Boolean fun cancelAll() + + /** Запись в общий лог пайплайна вне контекста [TaskContext] (например, WorkManager sync). */ + fun appendPipelineLog(level: TaskLogLevel, key: TaskLogKey) } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt index c7bb443..9c01538 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt @@ -6,6 +6,7 @@ import java.util.UUID data class PipelineTask( val id: TaskId, val title: String, + val enqueuedAtMs: Long, val dispatcher: CoroutineDispatcher, val state: TaskRunState, /** UUID storage, для которого идёт задача (кнопки только этой строки в UI). */ diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/StorageSyncTriggerReason.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/StorageSyncTriggerReason.kt new file mode 100644 index 0000000..09acaaf --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/StorageSyncTriggerReason.kt @@ -0,0 +1,8 @@ +package com.github.nullptroma.wallenc.domain.tasks + +/** Источник запуска синхронизации хранилищ (для логов пайплайна задач). */ +enum class StorageSyncTriggerReason { + Debounce, + SyncTab, + Background, +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogKey.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogKey.kt index 6341159..b2f0856 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogKey.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogKey.kt @@ -3,7 +3,10 @@ package com.github.nullptroma.wallenc.domain.tasks import com.github.nullptroma.wallenc.domain.errors.WallencException sealed class TaskLogKey { - data object SyncStarted : TaskLogKey() - data object SyncFinished : TaskLogKey() - data class SyncFailed(val error: WallencException) : TaskLogKey() + data class SyncStarted(val reason: StorageSyncTriggerReason) : TaskLogKey() + data class SyncFinished(val reason: StorageSyncTriggerReason) : TaskLogKey() + data class SyncFailed( + val error: WallencException, + val reason: StorageSyncTriggerReason, + ) : TaskLogKey() } diff --git a/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt b/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt index 7b40820..f6016f5 100644 --- a/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt +++ b/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt @@ -73,6 +73,7 @@ class TaskOrchestrator( val task = PipelineTask( id = id, title = title, + enqueuedAtMs = System.currentTimeMillis(), dispatcher = dispatcher, state = TaskRunState.Queued, busyStorageUuid = busyStorageUuid, @@ -104,6 +105,10 @@ class TaskOrchestrator( } } + override fun appendPipelineLog(level: TaskLogLevel, key: TaskLogKey) { + appendLogLine(level, message = "", logKey = key) + } + private fun replaceTask(id: TaskId, fn: (PipelineTask) -> PipelineTask) { synchronized(tasksById) { val cur = tasksById[id] ?: return diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskLogKeys.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskLogKeys.kt index cb39529..54999d0 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskLogKeys.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskLogKeys.kt @@ -1,13 +1,31 @@ package com.github.nullptroma.wallenc.ui.resources +import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey import com.github.nullptroma.wallenc.ui.R fun TaskLogKey.resolve(resolver: UiStringResolver): String = when (this) { - TaskLogKey.SyncStarted -> resolver(R.string.task_log_sync_started) - TaskLogKey.SyncFinished -> resolver(R.string.task_log_sync_finished) + is TaskLogKey.SyncStarted -> resolver( + R.string.task_log_sync_started, + resolver.resolveSyncTriggerReason(reason), + ) + is TaskLogKey.SyncFinished -> resolver( + R.string.task_log_sync_finished, + resolver.resolveSyncTriggerReason(reason), + ) is TaskLogKey.SyncFailed -> { val notification = error.toUserNotification().resolve(resolver) - resolver(R.string.task_log_sync_failed, notification) + resolver( + R.string.task_log_sync_failed, + resolver.resolveSyncTriggerReason(reason), + notification, + ) } } + +private fun UiStringResolver.resolveSyncTriggerReason(reason: StorageSyncTriggerReason): String = + when (reason) { + StorageSyncTriggerReason.Debounce -> this(R.string.task_sync_trigger_debounce) + StorageSyncTriggerReason.SyncTab -> this(R.string.task_sync_trigger_sync_tab) + StorageSyncTriggerReason.Background -> this(R.string.task_sync_trigger_background) + } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskPipelineTimeFormat.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskPipelineTimeFormat.kt new file mode 100644 index 0000000..67bac9e --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/TaskPipelineTimeFormat.kt @@ -0,0 +1,13 @@ +package com.github.nullptroma.wallenc.ui.resources + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val pipelineTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm:ss") + +fun formatTaskPipelineTime(timestampMs: Long): String = + Instant.ofEpochMilli(timestampMs) + .atZone(ZoneId.systemDefault()) + .format(pipelineTimeFormatter) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt index 920be22..ecf742a 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -36,7 +37,9 @@ import com.github.nullptroma.wallenc.domain.tasks.PipelineTask import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine import com.github.nullptroma.wallenc.ui.resources.displayText +import com.github.nullptroma.wallenc.ui.resources.formatTaskPipelineTime import com.github.nullptroma.wallenc.ui.resources.resolveText import com.github.nullptroma.wallenc.ui.resources.toUserNotification @@ -91,18 +94,7 @@ fun TaskPipelineScreen( verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(logs.size) { i -> - val line = logs[i] - val prefix = when (line.level) { - TaskLogLevel.Debug -> "D" - TaskLogLevel.Info -> "I" - TaskLogLevel.Warn -> "W" - TaskLogLevel.Error -> "E" - } - Text( - "[$prefix] ${line.displayText()}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PipelineLogRow(line = logs[i]) } } Button( @@ -178,14 +170,59 @@ fun TaskPipelineScreen( } } +@Composable +private fun PipelineLogRow(line: TaskLogLine) { + val prefix = when (line.level) { + TaskLogLevel.Debug -> "D" + TaskLogLevel.Info -> "I" + TaskLogLevel.Warn -> "W" + TaskLogLevel.Error -> "E" + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = "[$prefix] ${line.displayText()}", + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatTaskPipelineTime(line.timestampMs), + modifier = Modifier.widthIn(min = 56.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable private fun TaskRow(task: PipelineTask, isRunning: Boolean) { Column(Modifier.fillMaxWidth()) { - Text( - task.title, - style = if (isRunning) MaterialTheme.typography.titleSmall - else MaterialTheme.typography.bodyMedium, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = task.title, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + style = if (isRunning) MaterialTheme.typography.titleSmall + else MaterialTheme.typography.bodyMedium, + ) + Text( + text = formatTaskPipelineTime(task.enqueuedAtMs), + modifier = Modifier.widthIn(min = 56.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } val runningProgress = (task.state as? TaskRunState.Running)?.progress val progressLabel = runningProgress?.label?.resolveText() val stateLabel = when (val s = task.state) { diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt index a6776d3..9fca6c3 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt @@ -13,6 +13,7 @@ import com.github.nullptroma.wallenc.ui.resources.resolve import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase +import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.StorageSyncCompatibilityInput import com.github.nullptroma.wallenc.usecases.isStorageCompatibleWithGroup @@ -209,7 +210,7 @@ class StorageSyncViewModel @Inject constructor( fun runSyncNow() { val started = runStorageSyncUseCase.enqueue( displayTitle = uiStrings(R.string.task_title_storage_sync), - logReason = "sync-tab", + reason = StorageSyncTriggerReason.SyncTab, ) if (!started) { updateState( diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 24c851b..238e51c 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -289,9 +289,12 @@ Синхронизация: группа «%1$s» — продление блокировок Синхронизация: группа «%1$s» — не удалось продлить блокировку %1$d / %2$d - Синхронизация хранилищ запущена - Синхронизация хранилищ завершена - Синхронизация не удалась: %1$s + Синхронизация хранилищ запущена (%1$s) + Синхронизация хранилищ завершена (%1$s) + Синхронизация не удалась (%1$s): %2$s + debounce + sync-tab + background Перечисление файлов и папок… Создание хранилища… Хранилище создано diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index fa35c6d..3018961 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -289,9 +289,12 @@ Storage sync: group "%1$s" renewing locks Storage sync: group "%1$s" lock renewal failed %1$d / %2$d - Storage sync started - Storage sync finished - Storage sync failed: %1$s + Storage sync started (%1$s) + Storage sync finished (%1$s) + Storage sync failed (%1$s): %2$s + debounce + sync-tab + background Enumerating files and directories… Creating storage… Storage created 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 51b241a..98f933e 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 @@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.usecases import com.github.nullptroma.wallenc.domain.errors.toWallencException import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator +import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.domain.tasks.TaskId import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel @@ -31,10 +32,10 @@ class RunStorageSyncUseCase @Inject constructor( /** * @param displayTitle заголовок задачи в UI (локализованный на стороне вызова) - * @param logReason техническая метка для логов (не для UI) + * @param reason источник запуска — попадает в лог пайплайна * @return false, если синхронизация уже в очереди или выполняется — новая задача не создана */ - fun enqueue(displayTitle: String, logReason: String): Boolean { + fun enqueue(displayTitle: String, reason: StorageSyncTriggerReason): Boolean { if (!running.compareAndSet(false, true)) { return false } @@ -45,36 +46,68 @@ class RunStorageSyncUseCase @Inject constructor( dispatcher = Dispatchers.IO, work = { ctx -> try { - ctx.log(TaskLogLevel.Info, TaskLogKey.SyncStarted) - ctx.reportProgress(null, TaskProgressLabel.SyncStarted) - syncEngine.syncAllGroups { fraction, label -> - ctx.reportProgress(fraction, label) - } - ctx.log(TaskLogLevel.Info, TaskLogKey.SyncFinished) - ctx.reportProgress(null, TaskProgressLabel.SyncCompleted) + executeSync( + reason = reason, + reportProgress = { fraction, label -> + ctx.reportProgress(fraction, label) + }, + log = { level, key -> ctx.log(level, key) }, + ) } catch (e: Exception) { - val err = e.toWallencException() - ctx.log(TaskLogLevel.Error, TaskLogKey.SyncFailed(err)) - ctx.fail(err) + ctx.fail(e.toWallencException()) } finally { - running.set(false) - _syncRunning.value = false - _activeSyncTaskId.value = null + clearRunningState() } }, ) _activeSyncTaskId.value = taskId return true } catch (t: Throwable) { - running.set(false) - _syncRunning.value = false - _activeSyncTaskId.value = null + clearRunningState() throw t } } - suspend fun runBlocking() { + suspend fun runBlocking(reason: StorageSyncTriggerReason) { + if (!running.compareAndSet(false, true)) { + return + } + _syncRunning.value = true + try { + executeSync( + reason = reason, + reportProgress = { _, _ -> }, + log = { level, key -> orchestrator.appendPipelineLog(level, key) }, + ) + } finally { + clearRunningState() + } + } + + private suspend fun executeSync( + reason: StorageSyncTriggerReason, + reportProgress: suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit, + log: (TaskLogLevel, TaskLogKey) -> Unit, + ) { syncReadiness.awaitReady() - syncEngine.syncAllGroups() + log(TaskLogLevel.Info, TaskLogKey.SyncStarted(reason)) + reportProgress(null, TaskProgressLabel.SyncStarted) + try { + syncEngine.syncAllGroups { fraction, label -> + reportProgress(fraction, label) + } + log(TaskLogLevel.Info, TaskLogKey.SyncFinished(reason)) + reportProgress(null, TaskProgressLabel.SyncCompleted) + } catch (e: Exception) { + val err = e.toWallencException() + log(TaskLogLevel.Error, TaskLogKey.SyncFailed(err, reason)) + throw e + } + } + + private fun clearRunningState() { + running.set(false) + _syncRunning.value = false + _activeSyncTaskId.value = null } }