Новый конвейер задач и уведомлений
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
201
app/src/main/res/layout/notification_wallenc_tasks_big.xml
Normal file
201
app/src/main/res/layout/notification_wallenc_tasks_big.xml
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user