From 404ff201c43a7af5bf39f2ab372ad8474271b5b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Wed, 22 Apr 2026 01:20:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9=20UI?= =?UTF-8?q?=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tasks/TaskPipelineForegroundService.kt | 130 ++++++++++-------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt index 39e94bd..ac3af91 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt @@ -13,22 +13,26 @@ import com.github.nullptroma.wallenc.app.R import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState +import com.github.nullptroma.wallenc.domain.tasks.TaskId import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlin.math.roundToInt /** * Single foreground notification (one [NotificationManager.notify] id) that accumulates all task - * states. Each row: [task title], then a horizontal strip with a smaller **label** (ellipsis) - * beside a horizontal [ProgressBar] (fixed width). Indeterminate tasks hide the bar; the label - * line cycles suffix `""` → `.` → `..` → `...` by wall clock between [notify] calls. + * states. [orchestrator.foregroundUi] enqueues into [foregroundUiQueue] while [canPush] allows + * (at most one pending frame until the worker finishes an iteration). Indeterminate tasks: the + * worker reuses [lastUiState] when the queue is empty so dots advance without extra queue entries. + * Each handled state is followed by [NOTIFICATION_QUEUE_STEP_MS] before the next step. */ @AndroidEntryPoint class TaskPipelineForegroundService : Service() { @@ -36,74 +40,78 @@ class TaskPipelineForegroundService : Service() { @Inject lateinit var orchestrator: ITaskOrchestrator + private var repeat = false + private var canPush = true + private var lastUiState: TaskForegroundUiState? = null + + /** Dot-animation phase per task (0…3); absent means 0 for first paint. */ + private val indeterminateDotsPhaseByTaskId = ConcurrentHashMap() + + private val foregroundUiQueue = Channel(Channel.UNLIMITED) + private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate) override fun onBind(intent: android.content.Intent?): IBinder? = null + @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate() { super.onCreate() ensureChannel() startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification()) + val nm = getSystemService(NotificationManager::class.java) + serviceScope.launch { var sawVisible = false - var pendingTasks: List? = null - var lastNotificationAtMs = 0L - var delayedFlushJob: Job? = null - val nm = getSystemService(NotificationManager::class.java) + while (true) { + if(repeat && !foregroundUiQueue.isEmpty || !repeat) { + lastUiState = foregroundUiQueue.receive() + } + canPush = true - fun pushVisibleTasks(tasks: List) { - if (tasks.isEmpty()) return - nm.notify(FOREGROUND_NOTIFICATION_ID, buildAccumulatedNotification(tasks)) - lastNotificationAtMs = System.currentTimeMillis() - } - - orchestrator.foregroundUi.collect { ui -> + val ui = lastUiState ?: continue when (ui) { - is TaskForegroundUiState.Visible -> { - sawVisible = true - pendingTasks = ui.tasks - - val now = System.currentTimeMillis() - val elapsed = now - lastNotificationAtMs - if (elapsed >= MIN_NOTIFICATION_UPDATE_INTERVAL_MS) { - delayedFlushJob?.cancel() - delayedFlushJob = null - pushVisibleTasks(ui.tasks) - pendingTasks = null - } else if (delayedFlushJob == null) { - delayedFlushJob = serviceScope.launch { - delay(MIN_NOTIFICATION_UPDATE_INTERVAL_MS - elapsed) - pendingTasks?.let { last -> - pushVisibleTasks(last) - pendingTasks = null - } - delayedFlushJob = null - } - } - } - TaskForegroundUiState.Hidden -> { + repeat = false if (sawVisible) { - delayedFlushJob?.cancel() - delayedFlushJob = null - pendingTasks?.let { last -> - pushVisibleTasks(last) - pendingTasks = null - } nm.cancel(FOREGROUND_NOTIFICATION_ID) - sawVisible = false + indeterminateDotsPhaseByTaskId.clear() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() + return@launch } } + + is TaskForegroundUiState.Visible -> { + if (ui.tasks.isNotEmpty()) { + sawVisible = true + nm.notify( + FOREGROUND_NOTIFICATION_ID, + buildAccumulatedNotification(ui.tasks), + ) + } + // repeatedly receive Visible with same tasks while indeterminate dots animate + repeat = ui.tasks.any { it.progress?.fraction == null } + } + } + delay(NOTIFICATION_QUEUE_STEP_MS) + } + } + + serviceScope.launch { + orchestrator.foregroundUi.collect { ui -> + if(canPush || ui is TaskForegroundUiState.Hidden) { + foregroundUiQueue.send(ui) + canPush = false } } } } override fun onDestroy() { + foregroundUiQueue.close() + indeterminateDotsPhaseByTaskId.clear() serviceScope.cancel() super.onDestroy() } @@ -193,6 +201,24 @@ class TaskPipelineForegroundService : Service() { val remaining = n - (MAX_TASK_ROWS - 1) showOverflowRow(remoteViews, MAX_TASK_ROWS - 1, remaining) } + advanceIndeterminateDotPhasesAfterBind(sorted, n) + } + + private fun advanceIndeterminateDotPhasesAfterBind(sorted: List, n: Int) { + if (n == 0) return + val displayed = + if (n <= MAX_TASK_ROWS) sorted else sorted.take(MAX_TASK_ROWS - 1) + val presentIds = sorted.map { it.taskId }.toSet() + indeterminateDotsPhaseByTaskId.keys.retainAll { it in presentIds } + for (t in displayed) { + if (t.progress?.fraction == null) { + val id = t.taskId + indeterminateDotsPhaseByTaskId[id] = + ((indeterminateDotsPhaseByTaskId[id] ?: 0) + 1) % 4 + } else { + indeterminateDotsPhaseByTaskId.remove(t.taskId) + } + } } private fun hideRow(remoteViews: RemoteViews, index: Int) { @@ -222,7 +248,7 @@ class TaskPipelineForegroundService : Service() { remoteViews.setViewVisibility(TASK_SUBTITLE_IDS[index], View.VISIBLE) remoteViews.setTextViewText( TASK_SUBTITLE_IDS[index], - indeterminateSubtitleWithDots(label), + indeterminateSubtitleWithDots(task.taskId, label), ) remoteViews.setViewVisibility(TASK_PROGRESS_IDS[index], View.GONE) } @@ -238,12 +264,9 @@ class TaskPipelineForegroundService : Service() { remoteViews.setTextViewText(TASK_SUBTITLE_IDS[index], "") } - /** - * Cycles `""` → `.` → `..` → `...` (wall clock) so each [notify] can advance the suffix after - * the optional base label. - */ - private fun indeterminateSubtitleWithDots(baseLabel: String): String { - val dots = when (((System.currentTimeMillis() / DOTS_ANIMATION_STEP_MS) % 4L).toInt()) { + private fun indeterminateSubtitleWithDots(taskId: TaskId, baseLabel: String): String { + val phase = (indeterminateDotsPhaseByTaskId[taskId] ?: 0) % 4 + val dots = when (phase) { 0 -> "" 1 -> "." 2 -> ".." @@ -255,9 +278,8 @@ class TaskPipelineForegroundService : Service() { companion object { private const val CHANNEL_ID = "wallenc_task_pipeline" private const val FOREGROUND_NOTIFICATION_ID = 1001 - private const val MIN_NOTIFICATION_UPDATE_INTERVAL_MS = 500L - private const val DOTS_ANIMATION_STEP_MS = 400L + private const val NOTIFICATION_QUEUE_STEP_MS = 500L /** Must match [R.layout.notification_wallenc_tasks_big] row count. */ private const val MAX_TASK_ROWS = 8