Новый конвейер задач и уведомлений
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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_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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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…")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user