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

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

View File

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

View File

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

View File

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

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.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
}
}

View File

@@ -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()
}

View File

@@ -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>,
)

View File

@@ -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,
)

View File

@@ -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()
}

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.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…")

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.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) {

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.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")

View File

@@ -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>