diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt index e57dc2c..98bf535 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt @@ -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? = 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) { + 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): 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) { + 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, + ) } } diff --git a/app/src/main/res/layout/notification_wallenc_tasks_big.xml b/app/src/main/res/layout/notification_wallenc_tasks_big.xml new file mode 100644 index 0000000..9d39717 --- /dev/null +++ b/app/src/main/res/layout/notification_wallenc_tasks_big.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0d265b..b774352 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ Preparing… Working… Cancel + %d tasks running + +%d more \ No newline at end of file diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/tasks/TaskOrchestrator.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/tasks/TaskOrchestrator.kt index 76ab0ca..159febf 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/tasks/TaskOrchestrator.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/tasks/TaskOrchestrator.kt @@ -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(Channel.UNLIMITED) - private val tasksById = Collections.synchronizedMap(linkedMapOf()) private val cancelRequested = ConcurrentHashMap() - private val currentRunJob = AtomicReference(null) - private val runningTaskId = AtomicReference(null) + private val runningJobs = ConcurrentHashMap() + private val delayedForegroundJobs = ConcurrentHashMap() + private val visibleForegroundTaskIds = Collections.synchronizedSet(linkedSetOf()) - private val _pipelineState = MutableStateFlow(PipelineState(emptyList(), null)) + private val _pipelineState = MutableStateFlow(PipelineState(emptyList(), emptySet())) override val pipelineState: StateFlow = _pipelineState.asStateFlow() private val logLock = Any() @@ -55,87 +51,25 @@ class TaskOrchestrator( private val _foregroundUi = MutableStateFlow(TaskForegroundUiState.Hidden) override val foregroundUi: StateFlow = _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 = 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 } } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt index 7d0b624..ef2ee6c 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt @@ -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 @@ -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() } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineState.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineState.kt index a7f9350..154c9d7 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineState.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineState.kt @@ -2,5 +2,5 @@ package com.github.nullptroma.wallenc.domain.tasks data class PipelineState( val tasks: List, - val currentTaskId: TaskId?, + val runningTaskIds: Set, ) diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt index bd459c8..af601da 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt @@ -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, ) diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskForegroundUiState.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskForegroundUiState.kt index bc4a809..dab102d 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskForegroundUiState.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskForegroundUiState.kt @@ -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, ) : TaskForegroundUiState() } diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt index 679aecd..d93edf2 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt @@ -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…") diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineScreen.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineScreen.kt index dcba3a4..773adae 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineScreen.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineScreen.kt @@ -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) { diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineViewModel.kt index 6f8f2d1..5a59016 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineViewModel.kt @@ -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") diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 658181a..c8a3777 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -18,12 +18,9 @@ Task pipeline Jobs Log - Cancel current Cancel all Open task pipeline Run test task - Current task - No running task Test task setup Duration: %1$d s Start