Рабочий UI уведомления

This commit is contained in:
2026-04-22 01:20:06 +03:00
parent e00455691a
commit 404ff201c4

View File

@@ -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.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Single foreground notification (one [NotificationManager.notify] id) that accumulates all task * 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) * states. [orchestrator.foregroundUi] enqueues into [foregroundUiQueue] while [canPush] allows
* beside a horizontal [ProgressBar] (fixed width). Indeterminate tasks hide the bar; the label * (at most one pending frame until the worker finishes an iteration). Indeterminate tasks: the
* line cycles suffix `""` → `.` → `..` → `...` by wall clock between [notify] calls. * 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 @AndroidEntryPoint
class TaskPipelineForegroundService : Service() { class TaskPipelineForegroundService : Service() {
@@ -36,74 +40,78 @@ class TaskPipelineForegroundService : Service() {
@Inject @Inject
lateinit var orchestrator: ITaskOrchestrator 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<TaskId, Int>()
private val foregroundUiQueue = Channel<TaskForegroundUiState>(Channel.UNLIMITED)
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate) private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate)
override fun onBind(intent: android.content.Intent?): IBinder? = null override fun onBind(intent: android.content.Intent?): IBinder? = null
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ensureChannel() ensureChannel()
startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification()) startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification())
serviceScope.launch {
var sawVisible = false
var pendingTasks: List<TaskForegroundItem>? = null
var lastNotificationAtMs = 0L
var delayedFlushJob: Job? = null
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
fun pushVisibleTasks(tasks: List<TaskForegroundItem>) { serviceScope.launch {
if (tasks.isEmpty()) return var sawVisible = false
nm.notify(FOREGROUND_NOTIFICATION_ID, buildAccumulatedNotification(tasks)) while (true) {
lastNotificationAtMs = System.currentTimeMillis() if(repeat && !foregroundUiQueue.isEmpty || !repeat) {
lastUiState = foregroundUiQueue.receive()
} }
canPush = true
orchestrator.foregroundUi.collect { ui -> val ui = lastUiState ?: continue
when (ui) { 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 -> { TaskForegroundUiState.Hidden -> {
repeat = false
if (sawVisible) { if (sawVisible) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pendingTasks?.let { last ->
pushVisibleTasks(last)
pendingTasks = null
}
nm.cancel(FOREGROUND_NOTIFICATION_ID) nm.cancel(FOREGROUND_NOTIFICATION_ID)
sawVisible = false indeterminateDotsPhaseByTaskId.clear()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() 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() { override fun onDestroy() {
foregroundUiQueue.close()
indeterminateDotsPhaseByTaskId.clear()
serviceScope.cancel() serviceScope.cancel()
super.onDestroy() super.onDestroy()
} }
@@ -193,6 +201,24 @@ class TaskPipelineForegroundService : Service() {
val remaining = n - (MAX_TASK_ROWS - 1) val remaining = n - (MAX_TASK_ROWS - 1)
showOverflowRow(remoteViews, MAX_TASK_ROWS - 1, remaining) showOverflowRow(remoteViews, MAX_TASK_ROWS - 1, remaining)
} }
advanceIndeterminateDotPhasesAfterBind(sorted, n)
}
private fun advanceIndeterminateDotPhasesAfterBind(sorted: List<TaskForegroundItem>, 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) { private fun hideRow(remoteViews: RemoteViews, index: Int) {
@@ -222,7 +248,7 @@ class TaskPipelineForegroundService : Service() {
remoteViews.setViewVisibility(TASK_SUBTITLE_IDS[index], View.VISIBLE) remoteViews.setViewVisibility(TASK_SUBTITLE_IDS[index], View.VISIBLE)
remoteViews.setTextViewText( remoteViews.setTextViewText(
TASK_SUBTITLE_IDS[index], TASK_SUBTITLE_IDS[index],
indeterminateSubtitleWithDots(label), indeterminateSubtitleWithDots(task.taskId, label),
) )
remoteViews.setViewVisibility(TASK_PROGRESS_IDS[index], View.GONE) remoteViews.setViewVisibility(TASK_PROGRESS_IDS[index], View.GONE)
} }
@@ -238,12 +264,9 @@ class TaskPipelineForegroundService : Service() {
remoteViews.setTextViewText(TASK_SUBTITLE_IDS[index], "") remoteViews.setTextViewText(TASK_SUBTITLE_IDS[index], "")
} }
/** private fun indeterminateSubtitleWithDots(taskId: TaskId, baseLabel: String): String {
* Cycles `""` → `.` → `..` → `...` (wall clock) so each [notify] can advance the suffix after val phase = (indeterminateDotsPhaseByTaskId[taskId] ?: 0) % 4
* the optional base label. val dots = when (phase) {
*/
private fun indeterminateSubtitleWithDots(baseLabel: String): String {
val dots = when (((System.currentTimeMillis() / DOTS_ANIMATION_STEP_MS) % 4L).toInt()) {
0 -> "" 0 -> ""
1 -> "." 1 -> "."
2 -> ".." 2 -> ".."
@@ -255,9 +278,8 @@ class TaskPipelineForegroundService : Service() {
companion object { companion object {
private const val CHANNEL_ID = "wallenc_task_pipeline" private const val CHANNEL_ID = "wallenc_task_pipeline"
private const val FOREGROUND_NOTIFICATION_ID = 1001 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. */ /** Must match [R.layout.notification_wallenc_tasks_big] row count. */
private const val MAX_TASK_ROWS = 8 private const val MAX_TASK_ROWS = 8