Новый конвейер задач и уведомлений

This commit is contained in:
2026-04-21 01:52:31 +03:00
parent 52353ea4a0
commit 75162e2d64
12 changed files with 474 additions and 195 deletions

View File

@@ -3,15 +3,16 @@ package com.github.nullptroma.wallenc.app.tasks
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.graphics.Color
import android.os.IBinder
import android.view.View
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
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.TaskProgress
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -23,6 +24,11 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.math.roundToInt
/**
* Single foreground notification (one [NotificationManager.notify] id) that accumulates all task
* states. Multiple progress bars are implemented via [RemoteViews] — the standard
* [NotificationCompat.Builder.setProgress] API only supports one bar per notification.
*/
@AndroidEntryPoint
class TaskPipelineForegroundService : Service() {
@@ -32,23 +38,23 @@ class TaskPipelineForegroundService : Service() {
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate)
override fun onBind(intent: Intent?): IBinder? = null
override fun onBind(intent: android.content.Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ensureChannel()
startForeground(NOTIFICATION_ID, buildPlaceholderNotification())
startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification())
serviceScope.launch {
var sawVisible = false
var pendingUi: TaskForegroundUiState.Visible? = null
var pendingTasks: List<TaskForegroundItem>? = null
var lastNotificationAtMs = 0L
var delayedFlushJob: Job? = null
val nm = getSystemService(NotificationManager::class.java)
fun pushVisible(ui: TaskForegroundUiState.Visible) {
val notification = buildProgressNotification(ui.title, ui.progress)
nm.notify(NOTIFICATION_ID, notification)
fun pushVisibleTasks(tasks: List<TaskForegroundItem>) {
if (tasks.isEmpty()) return
nm.notify(FOREGROUND_NOTIFICATION_ID, buildAccumulatedNotification(tasks))
lastNotificationAtMs = System.currentTimeMillis()
}
@@ -56,21 +62,21 @@ class TaskPipelineForegroundService : Service() {
when (ui) {
is TaskForegroundUiState.Visible -> {
sawVisible = true
pendingUi = ui
pendingTasks = ui.tasks
val now = System.currentTimeMillis()
val elapsed = now - lastNotificationAtMs
if (elapsed >= MIN_NOTIFICATION_UPDATE_INTERVAL_MS) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pushVisible(ui)
pendingUi = null
pushVisibleTasks(ui.tasks)
pendingTasks = null
} else if (delayedFlushJob == null) {
delayedFlushJob = serviceScope.launch {
delay(MIN_NOTIFICATION_UPDATE_INTERVAL_MS - elapsed)
pendingUi?.let { last ->
pushVisible(last)
pendingUi = null
pendingTasks?.let { last ->
pushVisibleTasks(last)
pendingTasks = null
}
delayedFlushJob = null
}
@@ -81,11 +87,11 @@ class TaskPipelineForegroundService : Service() {
if (sawVisible) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pendingUi?.let { last ->
// Flush latest state before removing notification.
pushVisible(last)
pendingUi = null
pendingTasks?.let { last ->
pushVisibleTasks(last)
pendingTasks = null
}
nm.cancel(FOREGROUND_NOTIFICATION_ID)
sawVisible = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -96,13 +102,6 @@ class TaskPipelineForegroundService : Service() {
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL_ALL) {
orchestrator.cancelAll()
}
return START_NOT_STICKY
}
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
@@ -127,45 +126,144 @@ class TaskPipelineForegroundService : Service() {
.setOnlyAlertOnce(true)
.build()
private fun buildProgressNotification(title: String, progress: TaskProgress?): Notification {
val cancelIntent = Intent(this, TaskPipelineForegroundService::class.java).apply {
action = ACTION_CANCEL_ALL
}
val cancelPending = PendingIntent.getService(
this,
1,
cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
private fun buildAccumulatedNotification(tasks: List<TaskForegroundItem>): Notification {
val sorted = tasks.sortedBy { it.taskId.uuid }
val big = RemoteViews(packageName, R.layout.notification_wallenc_tasks_big)
applyNotificationTemplateTextColor(big)
bindTaskRows(big, sorted)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.task_notification_title))
.setContentText(
getString(R.string.task_notification_group_subtext, sorted.size),
)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
getString(R.string.task_notification_cancel),
cancelPending,
)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomBigContentView(big)
.build()
}
val label = progress?.label
val fraction = progress?.fraction
/**
* Uses only [android.R.attr.textColor] from [android.R.style.TextAppearance_Material_Notification_Title]
* so line size stays from layout (13sp) while color matches system notification title text.
*/
private fun applyNotificationTemplateTextColor(remoteViews: RemoteViews) {
val color = notificationTemplateTextColor()
for (i in 0 until MAX_TASK_ROWS) {
remoteViews.setTextColor(TASK_TITLE_IDS[i], color)
}
}
private fun notificationTemplateTextColor(): Int {
val attrs = intArrayOf(android.R.attr.textColor)
val a = theme.obtainStyledAttributes(
null,
attrs,
0,
android.R.style.TextAppearance_Material_Notification
)
val c = a.getColor(0, Color.WHITE)
a.recycle()
return c
}
private fun bindTaskRows(remoteViews: RemoteViews, sorted: List<TaskForegroundItem>) {
val n = sorted.size
if (n == 0) {
for (i in 0 until MAX_TASK_ROWS) {
hideRow(remoteViews, i)
}
return
}
if (n <= MAX_TASK_ROWS) {
for (i in 0 until n) {
showTaskRow(remoteViews, i, sorted[i])
}
for (i in n until MAX_TASK_ROWS) {
hideRow(remoteViews, i)
}
} else {
for (i in 0 until MAX_TASK_ROWS - 1) {
showTaskRow(remoteViews, i, sorted[i])
}
val remaining = n - (MAX_TASK_ROWS - 1)
showOverflowRow(remoteViews, MAX_TASK_ROWS - 1, remaining)
}
}
private fun hideRow(remoteViews: RemoteViews, index: Int) {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.GONE)
}
private fun showTaskRow(remoteViews: RemoteViews, index: Int, task: TaskForegroundItem) {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE)
remoteViews.setTextViewText(TASK_TITLE_IDS[index], taskTitleText(task))
remoteViews.setViewVisibility(TASK_PROGRESS_IDS[index], View.VISIBLE)
val fraction = task.progress?.fraction
if (fraction != null) {
val pct = (fraction.coerceIn(0f, 1f) * 100).roundToInt()
builder.setContentText(label ?: "$pct%")
builder.setProgress(100, pct, false)
remoteViews.setProgressBar(TASK_PROGRESS_IDS[index], 100, pct, false)
} else {
builder.setContentText(label ?: getString(R.string.task_notification_indeterminate))
builder.setProgress(0, 0, true)
remoteViews.setProgressBar(TASK_PROGRESS_IDS[index], 0, 0, true)
}
return builder.build()
}
private fun showOverflowRow(remoteViews: RemoteViews, index: Int, remainingCount: Int) {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE)
remoteViews.setTextViewText(
TASK_TITLE_IDS[index],
getString(R.string.task_notification_more_tasks, remainingCount),
)
remoteViews.setViewVisibility(TASK_PROGRESS_IDS[index], View.GONE)
}
private fun taskTitleText(task: TaskForegroundItem): String {
val label = task.progress?.label?.trim().orEmpty()
val title = task.title
return if (label.isNotEmpty()) "$title$label" else title
}
companion object {
private const val CHANNEL_ID = "wallenc_task_pipeline"
private const val NOTIFICATION_ID = 1001
private const val FOREGROUND_NOTIFICATION_ID = 1001
private const val MIN_NOTIFICATION_UPDATE_INTERVAL_MS = 500L
const val ACTION_CANCEL_ALL = "com.github.nullptroma.wallenc.CANCEL_ALL_TASKS"
/** Must match [R.layout.notification_wallenc_tasks_big] row count. */
private const val MAX_TASK_ROWS = 8
private val TASK_ROW_IDS = intArrayOf(
R.id.task_row_0,
R.id.task_row_1,
R.id.task_row_2,
R.id.task_row_3,
R.id.task_row_4,
R.id.task_row_5,
R.id.task_row_6,
R.id.task_row_7,
)
private val TASK_TITLE_IDS = intArrayOf(
R.id.task_title_0,
R.id.task_title_1,
R.id.task_title_2,
R.id.task_title_3,
R.id.task_title_4,
R.id.task_title_5,
R.id.task_title_6,
R.id.task_title_7,
)
private val TASK_PROGRESS_IDS = intArrayOf(
R.id.task_progress_0,
R.id.task_progress_1,
R.id.task_progress_2,
R.id.task_progress_3,
R.id.task_progress_4,
R.id.task_progress_5,
R.id.task_progress_6,
R.id.task_progress_7,
)
}
}

View File

@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<LinearLayout
android:id="@+id/task_row_0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_0"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_1"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_2"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_3"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_4"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_5"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_6"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/task_row_7"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/task_title_7"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/task_progress_7"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>

View File

@@ -5,4 +5,6 @@
<string name="task_notification_preparing">Preparing…</string>
<string name="task_notification_indeterminate">Working…</string>
<string name="task_notification_cancel">Cancel</string>
<string name="task_notification_group_subtext">%d tasks running</string>
<string name="task_notification_more_tasks">+%d more</string>
</resources>