Новый конвейер задач и уведомлений
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>
|
||||
@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.tasks.PipelineState
|
||||
import com.github.nullptroma.wallenc.domain.tasks.PipelineTask
|
||||
import com.github.nullptroma.wallenc.domain.tasks.PipelineWork
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskContext
|
||||
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 com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
@@ -13,20 +14,16 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class TaskOrchestrator(
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
@@ -35,16 +32,15 @@ class TaskOrchestrator(
|
||||
private val pipelineSupervisor = SupervisorJob()
|
||||
private val scope = CoroutineScope(pipelineSupervisor + ioDispatcher)
|
||||
|
||||
private val channel = Channel<TaskEnvelope>(Channel.UNLIMITED)
|
||||
|
||||
private val tasksById =
|
||||
Collections.synchronizedMap(linkedMapOf<TaskId, PipelineTask>())
|
||||
|
||||
private val cancelRequested = ConcurrentHashMap<TaskId, Boolean>()
|
||||
private val currentRunJob = AtomicReference<Job?>(null)
|
||||
private val runningTaskId = AtomicReference<TaskId?>(null)
|
||||
private val runningJobs = ConcurrentHashMap<TaskId, Job>()
|
||||
private val delayedForegroundJobs = ConcurrentHashMap<TaskId, Job>()
|
||||
private val visibleForegroundTaskIds = Collections.synchronizedSet(linkedSetOf<TaskId>())
|
||||
|
||||
private val _pipelineState = MutableStateFlow(PipelineState(emptyList(), null))
|
||||
private val _pipelineState = MutableStateFlow(PipelineState(emptyList(), emptySet()))
|
||||
override val pipelineState: StateFlow<PipelineState> = _pipelineState.asStateFlow()
|
||||
|
||||
private val logLock = Any()
|
||||
@@ -55,87 +51,25 @@ class TaskOrchestrator(
|
||||
private val _foregroundUi = MutableStateFlow<TaskForegroundUiState>(TaskForegroundUiState.Hidden)
|
||||
override val foregroundUi: StateFlow<TaskForegroundUiState> = _foregroundUi.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
processLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processLoop() {
|
||||
while (true) {
|
||||
_foregroundUi.value = TaskForegroundUiState.Hidden
|
||||
val envelope = channel.receive()
|
||||
val id = envelope.id
|
||||
if (cancelRequested[id] == true) {
|
||||
replaceTask(id) { it.copy(state = TaskRunState.Cancelled) }
|
||||
cancelRequested.remove(id)
|
||||
emitState(null)
|
||||
continue
|
||||
}
|
||||
runningTaskId.set(id)
|
||||
replaceTask(id) { it.copy(state = TaskRunState.Running(null)) }
|
||||
if (envelope.requiresForeground) {
|
||||
_foregroundUi.value = TaskForegroundUiState.Visible(envelope.title, null)
|
||||
}
|
||||
emitState(id)
|
||||
|
||||
val ctx = TaskContextImpl(
|
||||
taskId = id,
|
||||
onRunningProgress = { p -> onRunningProgress(id, envelope.title, envelope.requiresForeground, p) },
|
||||
appendLog = { level, msg -> appendLogLine(level, msg) },
|
||||
)
|
||||
try {
|
||||
coroutineScope {
|
||||
val runJob: Deferred<Unit> = async(ioDispatcher) {
|
||||
envelope.work.run(ctx)
|
||||
}
|
||||
currentRunJob.set(runJob)
|
||||
runJob.await()
|
||||
}
|
||||
replaceTask(id) { it.copy(state = TaskRunState.Completed) }
|
||||
cancelRequested.remove(id)
|
||||
} catch (_: CancellationException) {
|
||||
cancelRequested.remove(id)
|
||||
replaceTask(id) { it.copy(state = TaskRunState.Cancelled) }
|
||||
} catch (e: Exception) {
|
||||
cancelRequested.remove(id)
|
||||
replaceTask(id) {
|
||||
it.copy(state = TaskRunState.Failed(e.message ?: e.toString()))
|
||||
}
|
||||
} finally {
|
||||
currentRunJob.set(null)
|
||||
runningTaskId.set(null)
|
||||
emitState(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRunningProgress(
|
||||
taskId: TaskId,
|
||||
title: String,
|
||||
requiresForeground: Boolean,
|
||||
progress: TaskProgress,
|
||||
) {
|
||||
private fun onRunningProgress(taskId: TaskId, progress: TaskProgress) {
|
||||
replaceTask(taskId) { it.copy(state = TaskRunState.Running(progress)) }
|
||||
if (requiresForeground) {
|
||||
_foregroundUi.value = TaskForegroundUiState.Visible(title, progress)
|
||||
}
|
||||
emitState(taskId)
|
||||
emitState()
|
||||
emitForegroundUiState()
|
||||
}
|
||||
|
||||
override fun enqueue(title: String, requiresForeground: Boolean, work: PipelineWork): TaskId {
|
||||
override fun enqueue(title: String, dispatcher: CoroutineDispatcher, work: PipelineWork): TaskId {
|
||||
val id = TaskId()
|
||||
val task = PipelineTask(
|
||||
id = id,
|
||||
title = title,
|
||||
requiresForeground = requiresForeground,
|
||||
dispatcher = dispatcher,
|
||||
state = TaskRunState.Queued,
|
||||
)
|
||||
synchronized(tasksById) {
|
||||
tasksById[id] = task
|
||||
}
|
||||
emitState(runningTaskId.get())
|
||||
channel.trySend(TaskEnvelope(id, title, requiresForeground, work))
|
||||
emitState()
|
||||
launchTask(id, work)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -143,23 +77,18 @@ class TaskOrchestrator(
|
||||
val exists = synchronized(tasksById) { tasksById.containsKey(taskId) }
|
||||
if (!exists) return false
|
||||
cancelRequested[taskId] = true
|
||||
if (runningTaskId.get() == taskId) {
|
||||
currentRunJob.get()?.cancel()
|
||||
}
|
||||
runningJobs[taskId]?.cancel()
|
||||
delayedForegroundJobs.remove(taskId)?.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun cancelCurrent(): Boolean {
|
||||
val id = runningTaskId.get() ?: return false
|
||||
return cancel(id)
|
||||
}
|
||||
|
||||
override fun cancelAll() {
|
||||
val ids = synchronized(tasksById) { tasksById.keys.toList() }
|
||||
for (id in ids) {
|
||||
cancelRequested[id] = true
|
||||
runningJobs[id]?.cancel()
|
||||
delayedForegroundJobs.remove(id)?.cancel()
|
||||
}
|
||||
currentRunJob.get()?.cancel()
|
||||
}
|
||||
|
||||
private fun replaceTask(id: TaskId, fn: (PipelineTask) -> PipelineTask) {
|
||||
@@ -169,16 +98,40 @@ class TaskOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitState(currentId: TaskId?) {
|
||||
private fun emitState() {
|
||||
val snapshot = synchronized(tasksById) {
|
||||
tasksById.values.toList()
|
||||
}
|
||||
val running = snapshot
|
||||
.asSequence()
|
||||
.filter { it.state is TaskRunState.Running }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
_pipelineState.value = PipelineState(
|
||||
tasks = snapshot,
|
||||
currentTaskId = currentId ?: runningTaskId.get(),
|
||||
runningTaskIds = running,
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitForegroundUiState() {
|
||||
val visibleItems = synchronized(tasksById) {
|
||||
tasksById.values
|
||||
.filter { visibleForegroundTaskIds.contains(it.id) && it.state is TaskRunState.Running }
|
||||
.map {
|
||||
TaskForegroundItem(
|
||||
taskId = it.id,
|
||||
title = it.title,
|
||||
progress = (it.state as TaskRunState.Running).progress,
|
||||
)
|
||||
}
|
||||
}
|
||||
_foregroundUi.value = if (visibleItems.isEmpty()) {
|
||||
TaskForegroundUiState.Hidden
|
||||
} else {
|
||||
TaskForegroundUiState.Visible(visibleItems)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendLogLine(level: TaskLogLevel, message: String) {
|
||||
val line = TaskLogLine(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
@@ -194,12 +147,53 @@ class TaskOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
private class TaskEnvelope(
|
||||
val id: TaskId,
|
||||
val title: String,
|
||||
val requiresForeground: Boolean,
|
||||
val work: PipelineWork,
|
||||
)
|
||||
private fun launchTask(taskId: TaskId, work: PipelineWork) {
|
||||
replaceTask(taskId) { it.copy(state = TaskRunState.Running(null)) }
|
||||
emitState()
|
||||
|
||||
val showForegroundJob = scope.launch {
|
||||
delay(FOREGROUND_DELAY_MS)
|
||||
val shouldShow = synchronized(tasksById) {
|
||||
val task = tasksById[taskId] ?: return@synchronized false
|
||||
task.state is TaskRunState.Running
|
||||
}
|
||||
if (shouldShow) {
|
||||
visibleForegroundTaskIds.add(taskId)
|
||||
emitForegroundUiState()
|
||||
}
|
||||
}
|
||||
delayedForegroundJobs[taskId] = showForegroundJob
|
||||
|
||||
val dispatcher = synchronized(tasksById) { tasksById[taskId]?.dispatcher } ?: ioDispatcher
|
||||
val runJob = scope.launch(dispatcher) {
|
||||
val ctx = TaskContextImpl(
|
||||
taskId = taskId,
|
||||
onRunningProgress = { p -> onRunningProgress(taskId, p) },
|
||||
appendLog = { level, msg -> appendLogLine(level, msg) },
|
||||
)
|
||||
try {
|
||||
if (cancelRequested[taskId] == true) {
|
||||
throw CancellationException("Task cancelled before start")
|
||||
}
|
||||
work.run(ctx)
|
||||
replaceTask(taskId) { it.copy(state = TaskRunState.Completed) }
|
||||
} catch (_: CancellationException) {
|
||||
replaceTask(taskId) { it.copy(state = TaskRunState.Cancelled) }
|
||||
} catch (e: Exception) {
|
||||
replaceTask(taskId) {
|
||||
it.copy(state = TaskRunState.Failed(e.message ?: e.toString()))
|
||||
}
|
||||
} finally {
|
||||
cancelRequested.remove(taskId)
|
||||
runningJobs.remove(taskId)
|
||||
delayedForegroundJobs.remove(taskId)?.cancel()
|
||||
visibleForegroundTaskIds.remove(taskId)
|
||||
emitState()
|
||||
emitForegroundUiState()
|
||||
}
|
||||
}
|
||||
runningJobs[taskId] = runJob
|
||||
}
|
||||
|
||||
private class TaskContextImpl(
|
||||
override val taskId: TaskId,
|
||||
@@ -217,5 +211,6 @@ class TaskOrchestrator(
|
||||
|
||||
companion object {
|
||||
private const val MAX_LOG_LINES = 500
|
||||
private const val FOREGROUND_DELAY_MS = 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
interface ITaskOrchestrator {
|
||||
val pipelineState: StateFlow<PipelineState>
|
||||
@@ -9,14 +10,11 @@ interface ITaskOrchestrator {
|
||||
|
||||
fun enqueue(
|
||||
title: String,
|
||||
requiresForeground: Boolean = true,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
work: PipelineWork,
|
||||
): TaskId
|
||||
|
||||
fun cancel(taskId: TaskId): Boolean
|
||||
|
||||
/** Cancels the currently running task, if any. */
|
||||
fun cancelCurrent(): Boolean
|
||||
|
||||
fun cancelAll()
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
data class PipelineState(
|
||||
val tasks: List<PipelineTask>,
|
||||
val currentTaskId: TaskId?,
|
||||
val runningTaskIds: Set<TaskId>,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
data class PipelineTask(
|
||||
val id: TaskId,
|
||||
val title: String,
|
||||
val requiresForeground: Boolean,
|
||||
val dispatcher: CoroutineDispatcher,
|
||||
val state: TaskRunState,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
data class TaskForegroundItem(
|
||||
val taskId: TaskId,
|
||||
val title: String,
|
||||
val progress: TaskProgress?,
|
||||
)
|
||||
|
||||
sealed class TaskForegroundUiState {
|
||||
data object Hidden : TaskForegroundUiState()
|
||||
data class Visible(
|
||||
val title: String,
|
||||
val progress: TaskProgress?,
|
||||
val tasks: List<TaskForegroundItem>,
|
||||
) : TaskForegroundUiState()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCas
|
||||
import com.github.nullptroma.wallenc.presentation.ViewModelBase
|
||||
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -113,7 +114,7 @@ class LocalVaultViewModel @Inject constructor(
|
||||
fun createStorage() {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Create storage",
|
||||
requiresForeground = false,
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
ctx.log(TaskLogLevel.Info, "Creating storage…")
|
||||
manageLocalVaultUseCase.createStorage()
|
||||
@@ -192,7 +193,7 @@ class LocalVaultViewModel @Inject constructor(
|
||||
fun disableEncryption(storage: IStorageInfo) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Disable encryption",
|
||||
requiresForeground = true,
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Disabling encryption…")
|
||||
@@ -218,7 +219,7 @@ class LocalVaultViewModel @Inject constructor(
|
||||
fun remove(storage: IStorageInfo) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Remove storage",
|
||||
requiresForeground = true,
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Removing storage…")
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -43,7 +42,7 @@ fun TaskPipelineScreen(
|
||||
val pipeline by viewModel.orchestrator.pipelineState.collectAsStateWithLifecycle()
|
||||
val logs by viewModel.orchestrator.logLines.collectAsStateWithLifecycle()
|
||||
val hasAnyTask = pipeline.tasks.isNotEmpty()
|
||||
val currentTask = pipeline.tasks.firstOrNull { it.id == pipeline.currentTaskId }
|
||||
val runningTaskIds = pipeline.runningTaskIds
|
||||
var showTestDialog by remember { mutableStateOf(false) }
|
||||
var testDurationSec by remember { mutableFloatStateOf(10f) }
|
||||
|
||||
@@ -67,32 +66,12 @@ fun TaskPipelineScreen(
|
||||
Text(stringResource(R.string.task_pipeline_run_test))
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_current_task),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (currentTask == null) {
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_no_current_task),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
TaskRow(task = currentTask, isCurrent = true)
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.orchestrator.cancelCurrent() },
|
||||
enabled = currentTask != null,
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_cancel_current))
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.orchestrator.cancelAll() },
|
||||
enabled = hasAnyTask,
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_cancel_all))
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.orchestrator.cancelAll() },
|
||||
enabled = hasAnyTask,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_cancel_all))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_jobs),
|
||||
@@ -103,7 +82,7 @@ fun TaskPipelineScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(pipeline.tasks, key = { it.id.uuid }) { task ->
|
||||
TaskRow(task = task, isCurrent = task.id == pipeline.currentTaskId)
|
||||
TaskRow(task = task, isRunning = task.id in runningTaskIds)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
@@ -171,11 +150,11 @@ fun TaskPipelineScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(task: PipelineTask, isCurrent: Boolean) {
|
||||
private fun TaskRow(task: PipelineTask, isRunning: Boolean) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
task.title,
|
||||
style = if (isCurrent) MaterialTheme.typography.titleSmall
|
||||
style = if (isRunning) MaterialTheme.typography.titleSmall
|
||||
else MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val stateLabel = when (val s = task.state) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.domain.tasks.PipelineWork
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -17,7 +18,7 @@ class TaskPipelineViewModel @Inject constructor(
|
||||
val safeDurationSec = durationSec.coerceIn(0, 60)
|
||||
orchestrator.enqueue(
|
||||
title = "Test task (${safeDurationSec}s)",
|
||||
requiresForeground = true,
|
||||
dispatcher = Dispatchers.Default,
|
||||
work = { ctx ->
|
||||
val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10
|
||||
ctx.log(TaskLogLevel.Info, "Test task started for ${safeDurationSec}s")
|
||||
|
||||
@@ -18,12 +18,9 @@
|
||||
<string name="task_pipeline_title">Task pipeline</string>
|
||||
<string name="task_pipeline_jobs">Jobs</string>
|
||||
<string name="task_pipeline_log">Log</string>
|
||||
<string name="task_pipeline_cancel_current">Cancel current</string>
|
||||
<string name="task_pipeline_cancel_all">Cancel all</string>
|
||||
<string name="task_pipeline_open">Open task pipeline</string>
|
||||
<string name="task_pipeline_run_test">Run test task</string>
|
||||
<string name="task_pipeline_current_task">Current task</string>
|
||||
<string name="task_pipeline_no_current_task">No running task</string>
|
||||
<string name="task_pipeline_test_dialog_title">Test task setup</string>
|
||||
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
|
||||
<string name="task_pipeline_test_dialog_start">Start</string>
|
||||
|
||||
Reference in New Issue
Block a user