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

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.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.graphics.Color
import android.os.IBinder import android.os.IBinder
import android.view.View
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.github.nullptroma.wallenc.app.R 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.TaskForegroundUiState import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
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
@@ -23,6 +24,11 @@ import kotlinx.coroutines.launch
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
* states. Multiple progress bars are implemented via [RemoteViews] — the standard
* [NotificationCompat.Builder.setProgress] API only supports one bar per notification.
*/
@AndroidEntryPoint @AndroidEntryPoint
class TaskPipelineForegroundService : Service() { class TaskPipelineForegroundService : Service() {
@@ -32,23 +38,23 @@ class TaskPipelineForegroundService : Service() {
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: Intent?): IBinder? = null override fun onBind(intent: android.content.Intent?): IBinder? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ensureChannel() ensureChannel()
startForeground(NOTIFICATION_ID, buildPlaceholderNotification()) startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification())
serviceScope.launch { serviceScope.launch {
var sawVisible = false var sawVisible = false
var pendingUi: TaskForegroundUiState.Visible? = null var pendingTasks: List<TaskForegroundItem>? = null
var lastNotificationAtMs = 0L var lastNotificationAtMs = 0L
var delayedFlushJob: Job? = null var delayedFlushJob: Job? = null
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
fun pushVisible(ui: TaskForegroundUiState.Visible) { fun pushVisibleTasks(tasks: List<TaskForegroundItem>) {
val notification = buildProgressNotification(ui.title, ui.progress) if (tasks.isEmpty()) return
nm.notify(NOTIFICATION_ID, notification) nm.notify(FOREGROUND_NOTIFICATION_ID, buildAccumulatedNotification(tasks))
lastNotificationAtMs = System.currentTimeMillis() lastNotificationAtMs = System.currentTimeMillis()
} }
@@ -56,21 +62,21 @@ class TaskPipelineForegroundService : Service() {
when (ui) { when (ui) {
is TaskForegroundUiState.Visible -> { is TaskForegroundUiState.Visible -> {
sawVisible = true sawVisible = true
pendingUi = ui pendingTasks = ui.tasks
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val elapsed = now - lastNotificationAtMs val elapsed = now - lastNotificationAtMs
if (elapsed >= MIN_NOTIFICATION_UPDATE_INTERVAL_MS) { if (elapsed >= MIN_NOTIFICATION_UPDATE_INTERVAL_MS) {
delayedFlushJob?.cancel() delayedFlushJob?.cancel()
delayedFlushJob = null delayedFlushJob = null
pushVisible(ui) pushVisibleTasks(ui.tasks)
pendingUi = null pendingTasks = null
} else if (delayedFlushJob == null) { } else if (delayedFlushJob == null) {
delayedFlushJob = serviceScope.launch { delayedFlushJob = serviceScope.launch {
delay(MIN_NOTIFICATION_UPDATE_INTERVAL_MS - elapsed) delay(MIN_NOTIFICATION_UPDATE_INTERVAL_MS - elapsed)
pendingUi?.let { last -> pendingTasks?.let { last ->
pushVisible(last) pushVisibleTasks(last)
pendingUi = null pendingTasks = null
} }
delayedFlushJob = null delayedFlushJob = null
} }
@@ -81,11 +87,11 @@ class TaskPipelineForegroundService : Service() {
if (sawVisible) { if (sawVisible) {
delayedFlushJob?.cancel() delayedFlushJob?.cancel()
delayedFlushJob = null delayedFlushJob = null
pendingUi?.let { last -> pendingTasks?.let { last ->
// Flush latest state before removing notification. pushVisibleTasks(last)
pushVisible(last) pendingTasks = null
pendingUi = null
} }
nm.cancel(FOREGROUND_NOTIFICATION_ID)
sawVisible = false sawVisible = false
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() 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() { override fun onDestroy() {
serviceScope.cancel() serviceScope.cancel()
super.onDestroy() super.onDestroy()
@@ -127,45 +126,144 @@ class TaskPipelineForegroundService : Service() {
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.build() .build()
private fun buildProgressNotification(title: String, progress: TaskProgress?): Notification { private fun buildAccumulatedNotification(tasks: List<TaskForegroundItem>): Notification {
val cancelIntent = Intent(this, TaskPipelineForegroundService::class.java).apply { val sorted = tasks.sortedBy { it.taskId.uuid }
action = ACTION_CANCEL_ALL val big = RemoteViews(packageName, R.layout.notification_wallenc_tasks_big)
} applyNotificationTemplateTextColor(big)
val cancelPending = PendingIntent.getService( bindTaskRows(big, sorted)
this,
1, return NotificationCompat.Builder(this, CHANNEL_ID)
cancelIntent, .setContentTitle(getString(R.string.task_notification_title))
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, .setContentText(
getString(R.string.task_notification_group_subtext, sorted.size),
) )
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.addAction( .setStyle(NotificationCompat.DecoratedCustomViewStyle())
android.R.drawable.ic_menu_close_clear_cancel, .setCustomBigContentView(big)
getString(R.string.task_notification_cancel), .build()
cancelPending, }
)
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) { if (fraction != null) {
val pct = (fraction.coerceIn(0f, 1f) * 100).roundToInt() val pct = (fraction.coerceIn(0f, 1f) * 100).roundToInt()
builder.setContentText(label ?: "$pct%") remoteViews.setProgressBar(TASK_PROGRESS_IDS[index], 100, pct, false)
builder.setProgress(100, pct, false)
} else { } else {
builder.setContentText(label ?: getString(R.string.task_notification_indeterminate)) remoteViews.setProgressBar(TASK_PROGRESS_IDS[index], 0, 0, true)
builder.setProgress(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 { companion object {
private const val CHANNEL_ID = "wallenc_task_pipeline" 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 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_preparing">Preparing…</string>
<string name="task_notification_indeterminate">Working…</string> <string name="task_notification_indeterminate">Working…</string>
<string name="task_notification_cancel">Cancel</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> </resources>

View File

@@ -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.PipelineTask
import com.github.nullptroma.wallenc.domain.tasks.PipelineWork import com.github.nullptroma.wallenc.domain.tasks.PipelineWork
import com.github.nullptroma.wallenc.domain.tasks.TaskContext 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.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel 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 com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
import java.util.Collections import java.util.Collections
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
class TaskOrchestrator( class TaskOrchestrator(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
@@ -35,16 +32,15 @@ class TaskOrchestrator(
private val pipelineSupervisor = SupervisorJob() private val pipelineSupervisor = SupervisorJob()
private val scope = CoroutineScope(pipelineSupervisor + ioDispatcher) private val scope = CoroutineScope(pipelineSupervisor + ioDispatcher)
private val channel = Channel<TaskEnvelope>(Channel.UNLIMITED)
private val tasksById = private val tasksById =
Collections.synchronizedMap(linkedMapOf<TaskId, PipelineTask>()) Collections.synchronizedMap(linkedMapOf<TaskId, PipelineTask>())
private val cancelRequested = ConcurrentHashMap<TaskId, Boolean>() private val cancelRequested = ConcurrentHashMap<TaskId, Boolean>()
private val currentRunJob = AtomicReference<Job?>(null) private val runningJobs = ConcurrentHashMap<TaskId, Job>()
private val runningTaskId = AtomicReference<TaskId?>(null) 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() override val pipelineState: StateFlow<PipelineState> = _pipelineState.asStateFlow()
private val logLock = Any() private val logLock = Any()
@@ -55,87 +51,25 @@ class TaskOrchestrator(
private val _foregroundUi = MutableStateFlow<TaskForegroundUiState>(TaskForegroundUiState.Hidden) private val _foregroundUi = MutableStateFlow<TaskForegroundUiState>(TaskForegroundUiState.Hidden)
override val foregroundUi: StateFlow<TaskForegroundUiState> = _foregroundUi.asStateFlow() override val foregroundUi: StateFlow<TaskForegroundUiState> = _foregroundUi.asStateFlow()
init { private fun onRunningProgress(taskId: TaskId, progress: TaskProgress) {
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,
) {
replaceTask(taskId) { it.copy(state = TaskRunState.Running(progress)) } replaceTask(taskId) { it.copy(state = TaskRunState.Running(progress)) }
if (requiresForeground) { emitState()
_foregroundUi.value = TaskForegroundUiState.Visible(title, progress) emitForegroundUiState()
}
emitState(taskId)
} }
override fun enqueue(title: String, requiresForeground: Boolean, work: PipelineWork): TaskId { override fun enqueue(title: String, dispatcher: CoroutineDispatcher, work: PipelineWork): TaskId {
val id = TaskId() val id = TaskId()
val task = PipelineTask( val task = PipelineTask(
id = id, id = id,
title = title, title = title,
requiresForeground = requiresForeground, dispatcher = dispatcher,
state = TaskRunState.Queued, state = TaskRunState.Queued,
) )
synchronized(tasksById) { synchronized(tasksById) {
tasksById[id] = task tasksById[id] = task
} }
emitState(runningTaskId.get()) emitState()
channel.trySend(TaskEnvelope(id, title, requiresForeground, work)) launchTask(id, work)
return id return id
} }
@@ -143,23 +77,18 @@ class TaskOrchestrator(
val exists = synchronized(tasksById) { tasksById.containsKey(taskId) } val exists = synchronized(tasksById) { tasksById.containsKey(taskId) }
if (!exists) return false if (!exists) return false
cancelRequested[taskId] = true cancelRequested[taskId] = true
if (runningTaskId.get() == taskId) { runningJobs[taskId]?.cancel()
currentRunJob.get()?.cancel() delayedForegroundJobs.remove(taskId)?.cancel()
}
return true return true
} }
override fun cancelCurrent(): Boolean {
val id = runningTaskId.get() ?: return false
return cancel(id)
}
override fun cancelAll() { override fun cancelAll() {
val ids = synchronized(tasksById) { tasksById.keys.toList() } val ids = synchronized(tasksById) { tasksById.keys.toList() }
for (id in ids) { for (id in ids) {
cancelRequested[id] = true cancelRequested[id] = true
runningJobs[id]?.cancel()
delayedForegroundJobs.remove(id)?.cancel()
} }
currentRunJob.get()?.cancel()
} }
private fun replaceTask(id: TaskId, fn: (PipelineTask) -> PipelineTask) { 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) { val snapshot = synchronized(tasksById) {
tasksById.values.toList() tasksById.values.toList()
} }
val running = snapshot
.asSequence()
.filter { it.state is TaskRunState.Running }
.map { it.id }
.toSet()
_pipelineState.value = PipelineState( _pipelineState.value = PipelineState(
tasks = snapshot, 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) { private fun appendLogLine(level: TaskLogLevel, message: String) {
val line = TaskLogLine( val line = TaskLogLine(
timestampMs = System.currentTimeMillis(), timestampMs = System.currentTimeMillis(),
@@ -194,12 +147,53 @@ class TaskOrchestrator(
} }
} }
private class TaskEnvelope( private fun launchTask(taskId: TaskId, work: PipelineWork) {
val id: TaskId, replaceTask(taskId) { it.copy(state = TaskRunState.Running(null)) }
val title: String, emitState()
val requiresForeground: Boolean,
val work: PipelineWork, 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( private class TaskContextImpl(
override val taskId: TaskId, override val taskId: TaskId,
@@ -217,5 +211,6 @@ class TaskOrchestrator(
companion object { companion object {
private const val MAX_LOG_LINES = 500 private const val MAX_LOG_LINES = 500
private const val FOREGROUND_DELAY_MS = 1_000L
} }
} }

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.tasks package com.github.nullptroma.wallenc.domain.tasks
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.CoroutineDispatcher
interface ITaskOrchestrator { interface ITaskOrchestrator {
val pipelineState: StateFlow<PipelineState> val pipelineState: StateFlow<PipelineState>
@@ -9,14 +10,11 @@ interface ITaskOrchestrator {
fun enqueue( fun enqueue(
title: String, title: String,
requiresForeground: Boolean = true, dispatcher: CoroutineDispatcher,
work: PipelineWork, work: PipelineWork,
): TaskId ): TaskId
fun cancel(taskId: TaskId): Boolean fun cancel(taskId: TaskId): Boolean
/** Cancels the currently running task, if any. */
fun cancelCurrent(): Boolean
fun cancelAll() fun cancelAll()
} }

View File

@@ -2,5 +2,5 @@ package com.github.nullptroma.wallenc.domain.tasks
data class PipelineState( data class PipelineState(
val tasks: List<PipelineTask>, val tasks: List<PipelineTask>,
val currentTaskId: TaskId?, val runningTaskIds: Set<TaskId>,
) )

View File

@@ -1,8 +1,10 @@
package com.github.nullptroma.wallenc.domain.tasks package com.github.nullptroma.wallenc.domain.tasks
import kotlinx.coroutines.CoroutineDispatcher
data class PipelineTask( data class PipelineTask(
val id: TaskId, val id: TaskId,
val title: String, val title: String,
val requiresForeground: Boolean, val dispatcher: CoroutineDispatcher,
val state: TaskRunState, val state: TaskRunState,
) )

View File

@@ -1,9 +1,14 @@
package com.github.nullptroma.wallenc.domain.tasks package com.github.nullptroma.wallenc.domain.tasks
data class TaskForegroundItem(
val taskId: TaskId,
val title: String,
val progress: TaskProgress?,
)
sealed class TaskForegroundUiState { sealed class TaskForegroundUiState {
data object Hidden : TaskForegroundUiState() data object Hidden : TaskForegroundUiState()
data class Visible( data class Visible(
val title: String, val tasks: List<TaskForegroundItem>,
val progress: TaskProgress?,
) : TaskForegroundUiState() ) : TaskForegroundUiState()
} }

View File

@@ -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.ViewModelBase
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -113,7 +114,7 @@ class LocalVaultViewModel @Inject constructor(
fun createStorage() { fun createStorage() {
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Create storage", title = "Create storage",
requiresForeground = false, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
ctx.log(TaskLogLevel.Info, "Creating storage…") ctx.log(TaskLogLevel.Info, "Creating storage…")
manageLocalVaultUseCase.createStorage() manageLocalVaultUseCase.createStorage()
@@ -192,7 +193,7 @@ class LocalVaultViewModel @Inject constructor(
fun disableEncryption(storage: IStorageInfo) { fun disableEncryption(storage: IStorageInfo) {
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Disable encryption", title = "Disable encryption",
requiresForeground = true, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Disabling encryption…") ctx.log(TaskLogLevel.Info, "Disabling encryption…")
@@ -218,7 +219,7 @@ class LocalVaultViewModel @Inject constructor(
fun remove(storage: IStorageInfo) { fun remove(storage: IStorageInfo) {
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Remove storage", title = "Remove storage",
requiresForeground = true, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Removing storage…") ctx.log(TaskLogLevel.Info, "Removing storage…")

View File

@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -43,7 +42,7 @@ fun TaskPipelineScreen(
val pipeline by viewModel.orchestrator.pipelineState.collectAsStateWithLifecycle() val pipeline by viewModel.orchestrator.pipelineState.collectAsStateWithLifecycle()
val logs by viewModel.orchestrator.logLines.collectAsStateWithLifecycle() val logs by viewModel.orchestrator.logLines.collectAsStateWithLifecycle()
val hasAnyTask = pipeline.tasks.isNotEmpty() 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 showTestDialog by remember { mutableStateOf(false) }
var testDurationSec by remember { mutableFloatStateOf(10f) } var testDurationSec by remember { mutableFloatStateOf(10f) }
@@ -67,33 +66,13 @@ fun TaskPipelineScreen(
Text(stringResource(R.string.task_pipeline_run_test)) 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( Button(
onClick = { viewModel.orchestrator.cancelAll() }, onClick = { viewModel.orchestrator.cancelAll() },
enabled = hasAnyTask, enabled = hasAnyTask,
modifier = Modifier.fillMaxWidth(),
) { ) {
Text(stringResource(R.string.task_pipeline_cancel_all)) Text(stringResource(R.string.task_pipeline_cancel_all))
} }
}
Text( Text(
stringResource(R.string.task_pipeline_jobs), stringResource(R.string.task_pipeline_jobs),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -103,7 +82,7 @@ fun TaskPipelineScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(pipeline.tasks, key = { it.id.uuid }) { task -> 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( Text(
@@ -171,11 +150,11 @@ fun TaskPipelineScreen(
} }
@Composable @Composable
private fun TaskRow(task: PipelineTask, isCurrent: Boolean) { private fun TaskRow(task: PipelineTask, isRunning: Boolean) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text( Text(
task.title, task.title,
style = if (isCurrent) MaterialTheme.typography.titleSmall style = if (isRunning) MaterialTheme.typography.titleSmall
else MaterialTheme.typography.bodyMedium, else MaterialTheme.typography.bodyMedium,
) )
val stateLabel = when (val s = task.state) { val stateLabel = when (val s = task.state) {

View File

@@ -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.PipelineWork
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
@@ -17,7 +18,7 @@ class TaskPipelineViewModel @Inject constructor(
val safeDurationSec = durationSec.coerceIn(0, 60) val safeDurationSec = durationSec.coerceIn(0, 60)
orchestrator.enqueue( orchestrator.enqueue(
title = "Test task (${safeDurationSec}s)", title = "Test task (${safeDurationSec}s)",
requiresForeground = true, dispatcher = Dispatchers.Default,
work = { ctx -> work = { ctx ->
val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10 val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10
ctx.log(TaskLogLevel.Info, "Test task started for ${safeDurationSec}s") ctx.log(TaskLogLevel.Info, "Test task started for ${safeDurationSec}s")

View File

@@ -18,12 +18,9 @@
<string name="task_pipeline_title">Task pipeline</string> <string name="task_pipeline_title">Task pipeline</string>
<string name="task_pipeline_jobs">Jobs</string> <string name="task_pipeline_jobs">Jobs</string>
<string name="task_pipeline_log">Log</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_cancel_all">Cancel all</string>
<string name="task_pipeline_open">Open task pipeline</string> <string name="task_pipeline_open">Open task pipeline</string>
<string name="task_pipeline_run_test">Run test task</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_title">Test task setup</string>
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string> <string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
<string name="task_pipeline_test_dialog_start">Start</string> <string name="task_pipeline_test_dialog_start">Start</string>