Рабочий UI уведомления
This commit is contained in:
@@ -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<TaskId, Int>()
|
||||
|
||||
private val foregroundUiQueue = Channel<TaskForegroundUiState>(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())
|
||||
|
||||
serviceScope.launch {
|
||||
var sawVisible = false
|
||||
var pendingTasks: List<TaskForegroundItem>? = null
|
||||
var lastNotificationAtMs = 0L
|
||||
var delayedFlushJob: Job? = null
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
|
||||
fun pushVisibleTasks(tasks: List<TaskForegroundItem>) {
|
||||
if (tasks.isEmpty()) return
|
||||
nm.notify(FOREGROUND_NOTIFICATION_ID, buildAccumulatedNotification(tasks))
|
||||
lastNotificationAtMs = System.currentTimeMillis()
|
||||
serviceScope.launch {
|
||||
var sawVisible = false
|
||||
while (true) {
|
||||
if(repeat && !foregroundUiQueue.isEmpty || !repeat) {
|
||||
lastUiState = foregroundUiQueue.receive()
|
||||
}
|
||||
canPush = true
|
||||
|
||||
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<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) {
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user