Добавлен foreground сервис

This commit is contained in:
2026-04-18 21:38:09 +03:00
parent db9463c2c6
commit d806e3a8a1
33 changed files with 972 additions and 36 deletions

View File

@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -24,6 +28,11 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".tasks.TaskPipelineForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>

View File

@@ -1,9 +1,14 @@
package com.github.nullptroma.wallenc.app package com.github.nullptroma.wallenc.app
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.github.nullptroma.wallenc.presentation.WallencUi import com.github.nullptroma.wallenc.presentation.WallencUi
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber import timber.log.Timber
@@ -15,6 +20,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
// val sdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext, true)) // val sdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext, true))
@@ -27,6 +33,24 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (granted) return
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
companion object {
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 100
}
// private fun handleResult(result: YandexAuthResult) { // private fun handleResult(result: YandexAuthResult) {
// when (result) { // when (result) {
// is YandexAuthResult.Success -> Toast.makeText(applicationContext, "Success: ${result.token}", Toast.LENGTH_SHORT).show() // is YandexAuthResult.Success -> Toast.makeText(applicationContext, "Success: ${result.token}", Toast.LENGTH_SHORT).show()

View File

@@ -1,7 +1,18 @@
package com.github.nullptroma.wallenc.app package com.github.nullptroma.wallenc.app
import android.app.Application import android.app.Application
import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WallencApplication : Application() class WallencApplication : Application() {
@Inject
lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
override fun onCreate() {
super.onCreate()
taskPipelineForegroundBootstrap.start()
}
}

View File

@@ -7,9 +7,11 @@ import com.github.nullptroma.wallenc.data.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository
import com.github.nullptroma.wallenc.data.storages.UnlockManager import com.github.nullptroma.wallenc.data.storages.UnlockManager
import com.github.nullptroma.wallenc.data.tasks.TaskOrchestrator
import com.github.nullptroma.wallenc.data.vaults.VaultsManager import com.github.nullptroma.wallenc.data.vaults.VaultsManager
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -21,6 +23,12 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class SingletonModule { class SingletonModule {
@Provides
@Singleton
fun provideTaskOrchestrator(
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): ITaskOrchestrator = TaskOrchestrator(ioDispatcher)
@Provides @Provides
@Singleton @Singleton
fun provideVaultsManager( fun provideVaultsManager(

View File

@@ -0,0 +1,35 @@
package com.github.nullptroma.wallenc.app.tasks
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TaskPipelineForegroundBootstrap @Inject constructor(
@ApplicationContext private val app: Context,
private val orchestrator: ITaskOrchestrator,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun start() {
scope.launch {
orchestrator.foregroundUi.collect { ui ->
if (ui is TaskForegroundUiState.Visible) {
ContextCompat.startForegroundService(
app,
Intent(app, TaskPipelineForegroundService::class.java),
)
}
}
}
}
}

View File

@@ -0,0 +1,172 @@
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.os.Build
import android.os.IBinder
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.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class TaskPipelineForegroundService : Service() {
@Inject
lateinit var orchestrator: ITaskOrchestrator
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate)
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ensureChannel()
startForeground(NOTIFICATION_ID, buildPlaceholderNotification())
serviceScope.launch {
var sawVisible = false
var pendingUi: TaskForegroundUiState.Visible? = 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)
lastNotificationAtMs = System.currentTimeMillis()
}
orchestrator.foregroundUi.collect { ui ->
when (ui) {
is TaskForegroundUiState.Visible -> {
sawVisible = true
pendingUi = ui
val now = System.currentTimeMillis()
val elapsed = now - lastNotificationAtMs
if (elapsed >= MIN_NOTIFICATION_UPDATE_INTERVAL_MS) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pushVisible(ui)
pendingUi = null
} else if (delayedFlushJob == null) {
delayedFlushJob = serviceScope.launch {
delay(MIN_NOTIFICATION_UPDATE_INTERVAL_MS - elapsed)
pendingUi?.let { last ->
pushVisible(last)
pendingUi = null
}
delayedFlushJob = null
}
}
}
TaskForegroundUiState.Hidden -> {
if (sawVisible) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pendingUi?.let { last ->
// Flush latest state before removing notification.
pushVisible(last)
pendingUi = null
}
sawVisible = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
}
}
}
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()
}
private fun ensureChannel() {
val nm = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.task_notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
}
private fun buildPlaceholderNotification(): Notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.task_notification_title))
.setContentText(getString(R.string.task_notification_preparing))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.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)
.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,
)
val label = progress?.label
val fraction = progress?.fraction
if (fraction != null) {
val pct = (fraction.coerceIn(0f, 1f) * 100).roundToInt()
builder.setContentText(label ?: "$pct%")
builder.setProgress(100, pct, false)
} else {
builder.setContentText(label ?: getString(R.string.task_notification_indeterminate))
builder.setProgress(0, 0, true)
}
return builder.build()
}
companion object {
private const val CHANNEL_ID = "wallenc_task_pipeline"
private const val NOTIFICATION_ID = 1001
private const val MIN_NOTIFICATION_UPDATE_INTERVAL_MS = 500L
const val ACTION_CANCEL_ALL = "com.github.nullptroma.wallenc.CANCEL_ALL_TASKS"
}
}

View File

@@ -1,3 +1,8 @@
<resources> <resources>
<string name="app_name">Wallenc</string> <string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Background tasks</string>
<string name="task_notification_title">Wallenc tasks</string>
<string name="task_notification_preparing">Preparing…</string>
<string name="task_notification_indeterminate">Working…</string>
<string name="task_notification_cancel">Cancel</string>
</resources> </resources>

View File

@@ -147,7 +147,7 @@ class UnlockManager(
} }
} }
private suspend fun closeBySourceUuid(opened: MutableMap<UUID, EncryptedStorage>, sourceUuid: UUID) { private fun closeBySourceUuid(opened: MutableMap<UUID, EncryptedStorage>, sourceUuid: UUID) {
val enc = opened[sourceUuid] ?: return val enc = opened[sourceUuid] ?: return
val nestedSourceUuid = enc.uuid val nestedSourceUuid = enc.uuid
if (nestedSourceUuid != sourceUuid && opened.containsKey(nestedSourceUuid)) { if (nestedSourceUuid != sourceUuid && opened.containsKey(nestedSourceUuid)) {

View File

@@ -8,6 +8,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.DisposableHandle
@@ -16,9 +17,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.util.UUID import java.util.UUID
import kotlin.coroutines.coroutineContext
class EncryptedStorage private constructor( class EncryptedStorage private constructor(
private val source: IStorage, private val source: IStorage,
@@ -115,7 +118,7 @@ class EncryptedStorage private constructor(
) )
} }
override suspend fun clearAllContent() = scope.run { override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = scope.run {
val files = accessor.getAllFiles() val files = accessor.getAllFiles()
val dirs = accessor.getAllDirs() val dirs = accessor.getAllDirs()
val paths = buildList { val paths = buildList {
@@ -124,8 +127,22 @@ class EncryptedStorage private constructor(
} }
.filter { it != "/" && it.isNotBlank() } .filter { it != "/" && it.isNotBlank() }
.sortedByDescending { it.length } .sortedByDescending { it.length }
for (path in paths) { val total = paths.size
if (total == 0) {
onProgress(TaskProgress(1f, null))
return@run
}
paths.forEachIndexed { index, path ->
accessor.delete(path) accessor.delete(path)
if (index % PROGRESS_REPORT_INTERVAL == 0 || index == paths.lastIndex) {
onProgress(
TaskProgress(
fraction = (index + 1).toFloat() / total,
label = null,
),
)
coroutineContext.ensureActive()
}
} }
} }
@@ -135,6 +152,7 @@ class EncryptedStorage private constructor(
} }
companion object { companion object {
private const val PROGRESS_REPORT_INTERVAL = 16
suspend fun create( suspend fun create(
source: IStorage, source: IStorage,
key: EncryptKey, key: EncryptKey,

View File

@@ -6,13 +6,16 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import kotlin.coroutines.coroutineContext
import java.util.UUID import java.util.UUID
@@ -94,7 +97,7 @@ class LocalStorage(
)) ))
} }
override suspend fun clearAllContent() = withContext(ioDispatcher) { override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = withContext(ioDispatcher) {
val files = accessor.getAllFiles() val files = accessor.getAllFiles()
val dirs = accessor.getAllDirs() val dirs = accessor.getAllDirs()
val paths = buildList { val paths = buildList {
@@ -103,12 +106,27 @@ class LocalStorage(
} }
.filter { it != "/" && it.isNotBlank() } .filter { it != "/" && it.isNotBlank() }
.sortedByDescending { it.length } .sortedByDescending { it.length }
for (path in paths) { val total = paths.size
if (total == 0) {
onProgress(TaskProgress(1f, null))
return@withContext
}
paths.forEachIndexed { index, path ->
accessor.delete(path) accessor.delete(path)
if (index % PROGRESS_REPORT_INTERVAL == 0 || index == paths.lastIndex) {
onProgress(
TaskProgress(
fraction = (index + 1).toFloat() / total,
label = null,
),
)
coroutineContext.ensureActive()
}
} }
} }
companion object { companion object {
private const val PROGRESS_REPORT_INTERVAL = 16
const val STORAGE_INFO_FILE_POSTFIX = ".storage-info" const val STORAGE_INFO_FILE_POSTFIX = ".storage-info"
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
} }

View File

@@ -0,0 +1,217 @@
package com.github.nullptroma.wallenc.data.tasks
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
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.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine
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.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
class TaskOrchestrator(
private val ioDispatcher: CoroutineDispatcher,
) : ITaskOrchestrator {
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 _pipelineState = MutableStateFlow(PipelineState(emptyList(), null))
override val pipelineState: StateFlow<PipelineState> = _pipelineState.asStateFlow()
private val logLock = Any()
private val logBuffer = ArrayDeque<TaskLogLine>(MAX_LOG_LINES + 1)
private val _logLines = MutableStateFlow<List<TaskLogLine>>(emptyList())
override val logLines: StateFlow<List<TaskLogLine>> = _logLines.asStateFlow()
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 job = Job(pipelineSupervisor)
currentRunJob.set(job)
val ctx = TaskContextImpl(
taskId = id,
onRunningProgress = { p -> onRunningProgress(id, envelope.title, envelope.requiresForeground, p) },
appendLog = { level, msg -> appendLogLine(level, msg) },
)
try {
withContext(ioDispatcher + job) {
envelope.work.run(ctx)
}
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)) }
if (requiresForeground) {
_foregroundUi.value = TaskForegroundUiState.Visible(title, progress)
}
emitState(taskId)
}
override fun enqueue(title: String, requiresForeground: Boolean, work: PipelineWork): TaskId {
val id = TaskId()
val task = PipelineTask(
id = id,
title = title,
requiresForeground = requiresForeground,
state = TaskRunState.Queued,
)
synchronized(tasksById) {
tasksById[id] = task
}
emitState(runningTaskId.get())
channel.trySend(TaskEnvelope(id, title, requiresForeground, work))
return id
}
override fun cancel(taskId: TaskId): Boolean {
val exists = synchronized(tasksById) { tasksById.containsKey(taskId) }
if (!exists) return false
cancelRequested[taskId] = true
if (runningTaskId.get() == taskId) {
currentRunJob.get()?.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
}
currentRunJob.get()?.cancel()
}
private fun replaceTask(id: TaskId, fn: (PipelineTask) -> PipelineTask) {
synchronized(tasksById) {
val cur = tasksById[id] ?: return
tasksById[id] = fn(cur)
}
}
private fun emitState(currentId: TaskId?) {
val snapshot = synchronized(tasksById) {
tasksById.values.toList()
}
_pipelineState.value = PipelineState(
tasks = snapshot,
currentTaskId = currentId ?: runningTaskId.get(),
)
}
private fun appendLogLine(level: TaskLogLevel, message: String) {
val line = TaskLogLine(
timestampMs = System.currentTimeMillis(),
level = level,
message = message,
)
synchronized(logLock) {
if (logBuffer.size >= MAX_LOG_LINES) {
logBuffer.removeFirst()
}
logBuffer.addLast(line)
_logLines.value = logBuffer.toList()
}
}
private class TaskEnvelope(
val id: TaskId,
val title: String,
val requiresForeground: Boolean,
val work: PipelineWork,
)
private class TaskContextImpl(
override val taskId: TaskId,
private val onRunningProgress: (TaskProgress) -> Unit,
private val appendLog: (TaskLogLevel, String) -> Unit,
) : TaskContext {
override suspend fun reportProgress(fraction: Float?, label: String?) {
onRunningProgress(TaskProgress(fraction, label))
}
override fun log(level: TaskLogLevel, message: String) {
appendLog(level, message)
}
}
companion object {
private const val MAX_LOG_LINES = 500
}
}

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.interfaces package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.time.Instant import java.time.Instant
@@ -21,7 +22,7 @@ interface IStorage: IStorageInfo {
suspend fun rename(newName: String) suspend fun rename(newName: String)
suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) suspend fun setEncInfo(encInfo: StorageEncryptionInfo?)
suspend fun clearAllContent() suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit = {})
} }
interface IStorageMetaInfo { interface IStorageMetaInfo {

View File

@@ -0,0 +1,22 @@
package com.github.nullptroma.wallenc.domain.tasks
import kotlinx.coroutines.flow.StateFlow
interface ITaskOrchestrator {
val pipelineState: StateFlow<PipelineState>
val logLines: StateFlow<List<TaskLogLine>>
val foregroundUi: StateFlow<TaskForegroundUiState>
fun enqueue(
title: String,
requiresForeground: Boolean = true,
work: PipelineWork,
): TaskId
fun cancel(taskId: TaskId): Boolean
/** Cancels the currently running task, if any. */
fun cancelCurrent(): Boolean
fun cancelAll()
}

View File

@@ -0,0 +1,6 @@
package com.github.nullptroma.wallenc.domain.tasks
data class PipelineState(
val tasks: List<PipelineTask>,
val currentTaskId: TaskId?,
)

View File

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

View File

@@ -0,0 +1,5 @@
package com.github.nullptroma.wallenc.domain.tasks
fun interface PipelineWork {
suspend fun run(ctx: TaskContext)
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.domain.tasks
interface TaskContext {
val taskId: TaskId
suspend fun reportProgress(fraction: Float?, label: String?)
suspend fun reportProgress(progress: TaskProgress) = reportProgress(progress.fraction, progress.label)
fun log(level: TaskLogLevel, message: String)
}

View File

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

View File

@@ -0,0 +1,5 @@
package com.github.nullptroma.wallenc.domain.tasks
import java.util.UUID
data class TaskId(val uuid: UUID = UUID.randomUUID())

View File

@@ -0,0 +1,8 @@
package com.github.nullptroma.wallenc.domain.tasks
enum class TaskLogLevel {
Debug,
Info,
Warn,
Error,
}

View File

@@ -0,0 +1,7 @@
package com.github.nullptroma.wallenc.domain.tasks
data class TaskLogLine(
val timestampMs: Long,
val level: TaskLogLevel,
val message: String,
)

View File

@@ -0,0 +1,7 @@
package com.github.nullptroma.wallenc.domain.tasks
data class TaskProgress(
/** 0f..1f or null if indeterminate */
val fraction: Float?,
val label: String?,
)

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.domain.tasks
sealed class TaskRunState {
data object Queued : TaskRunState()
data class Running(val progress: TaskProgress?) : TaskRunState()
data object Completed : TaskRunState()
data object Cancelled : TaskRunState()
data class Failed(val message: String) : TaskRunState()
}

View File

@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
class ManageStoragesEncryptionUseCase( class ManageStoragesEncryptionUseCase(
@@ -53,13 +54,12 @@ class ManageStoragesEncryptionUseCase(
} }
} }
suspend fun disableEncryption(storage: IStorageInfo) { suspend fun clearAndDisableEncryption(
clearAndDisableEncryption(storage) storage: IStorageInfo,
} onClearProgress: suspend (TaskProgress) -> Unit = {},
) {
suspend fun clearAndDisableEncryption(storage: IStorageInfo) {
if (storage !is IStorage) return if (storage !is IStorage) return
storage.clearAllContent() storage.clearAllContent(onClearProgress)
storage.setEncInfo(null) storage.setEncInfo(null)
unlockManager.close(storage) unlockManager.close(storage)
} }

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,6 +35,8 @@ import com.github.nullptroma.wallenc.presentation.navigation.rememberNavigationS
import com.github.nullptroma.wallenc.presentation.screens.main.MainRoute import com.github.nullptroma.wallenc.presentation.screens.main.MainRoute
import com.github.nullptroma.wallenc.presentation.screens.main.MainScreen import com.github.nullptroma.wallenc.presentation.screens.main.MainScreen
import com.github.nullptroma.wallenc.presentation.screens.main.MainViewModel import com.github.nullptroma.wallenc.presentation.screens.main.MainViewModel
import com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks.TaskPipelineScreen
import com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks.TaskPipelineRoute
import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsRoute import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsRoute
import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsScreen import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsScreen
import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsViewModel import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsViewModel
@@ -65,6 +68,11 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
MainRoute::class.qualifiedName!! to NavBarItemData( MainRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_main, MainRoute::class.qualifiedName!!, Icons.Rounded.Menu R.string.nav_label_main, MainRoute::class.qualifiedName!!, Icons.Rounded.Menu
), ),
TaskPipelineRoute::class.qualifiedName!! to NavBarItemData(
R.string.task_pipeline_title,
TaskPipelineRoute::class.qualifiedName!!,
Icons.AutoMirrored.Rounded.List
),
SettingsRoute::class.qualifiedName!! to NavBarItemData( SettingsRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_settings, R.string.nav_label_settings,
SettingsRoute::class.qualifiedName!!, SettingsRoute::class.qualifiedName!!,
@@ -125,6 +133,15 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
}) { }) {
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel) SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
} }
composable<TaskPipelineRoute>(enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) {
TaskPipelineScreen(
modifier = Modifier.padding(innerPaddings)
)
}
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable import androidx.lifecycle.viewmodel.compose.saveable
import com.github.nullptroma.wallenc.presentation.screens.ScreenRoute import com.github.nullptroma.wallenc.presentation.screens.ScreenRoute
import com.github.nullptroma.wallenc.presentation.screens.main.MainRoute import com.github.nullptroma.wallenc.presentation.screens.main.MainRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks.TaskPipelineRoute
import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsRoute import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsRoute
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlin.collections.set import kotlin.collections.set
@@ -18,6 +19,7 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS
mutableStateOf( mutableStateOf(
mapOf<String, ScreenRoute>( mapOf<String, ScreenRoute>(
MainRoute::class.qualifiedName!! to MainRoute(), MainRoute::class.qualifiedName!! to MainRoute(),
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
SettingsRoute::class.qualifiedName!! to SettingsRoute() SettingsRoute::class.qualifiedName!! to SettingsRoute()
) )
) )

View File

@@ -101,10 +101,11 @@ fun MainScreen(
val route: LocalVaultRoute = it.toRoute() val route: LocalVaultRoute = it.toRoute()
LocalVaultScreen( LocalVaultScreen(
modifier = Modifier.padding(innerPaddings), modifier = Modifier.padding(innerPaddings),
viewModel = localVaultViewModel viewModel = localVaultViewModel,
) { text -> openTextEdit = { text ->
navState.push(TextEditRoute(text)) navState.push(TextEditRoute(text))
} },
)
} }
composable<RemoteVaultsRoute>(enterTransition = { composable<RemoteVaultsRoute>(enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -38,7 +37,7 @@ import kotlinx.coroutines.flow.collect
fun LocalVaultScreen( fun LocalVaultScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LocalVaultViewModel = hiltViewModel(), viewModel: LocalVaultViewModel = hiltViewModel(),
openTextEdit: (String) -> Unit openTextEdit: (String) -> Unit,
) { ) {
val uiState by viewModel.state.collectAsStateWithLifecycle() val uiState by viewModel.state.collectAsStateWithLifecycle()
@@ -50,7 +49,10 @@ fun LocalVaultScreen(
} }
Box { Box {
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = { Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
viewModel.createStorage() viewModel.createStorage()

View File

@@ -13,6 +13,9 @@ import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUse
import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
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 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
@@ -32,6 +35,7 @@ class LocalVaultViewModel @Inject constructor(
private val storageFileManagementUseCase: StorageFileManagementUseCase, private val storageFileManagementUseCase: StorageFileManagementUseCase,
private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase, private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
private val renameStorageUseCase: RenameStorageUseCase, private val renameStorageUseCase: RenameStorageUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val logger: ILogger private val logger: ILogger
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) { ) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) {
private val _messages = MutableSharedFlow<String>() private val _messages = MutableSharedFlow<String>()
@@ -111,11 +115,15 @@ class LocalVaultViewModel @Inject constructor(
} }
fun createStorage() { fun createStorage() {
tasksCount++ taskOrchestrator.enqueue(
viewModelScope.launch { title = "Create storage",
requiresForeground = false,
work = PipelineWork { ctx ->
ctx.log(TaskLogLevel.Info, "Creating storage…")
manageLocalVaultUseCase.createStorage() manageLocalVaultUseCase.createStorage()
tasksCount-- ctx.log(TaskLogLevel.Info, "Storage created")
} },
)
} }
private val runningStorages = mutableSetOf<java.util.UUID>() private val runningStorages = mutableSetOf<java.util.UUID>()
@@ -186,14 +194,23 @@ class LocalVaultViewModel @Inject constructor(
} }
fun disableEncryption(storage: IStorageInfo) { fun disableEncryption(storage: IStorageInfo) {
viewModelScope.launch { taskOrchestrator.enqueue(
title = "Disable encryption",
requiresForeground = true,
work = PipelineWork { ctx ->
try { try {
manageStoragesEncryptionUseCase.disableEncryption(storage) ctx.log(TaskLogLevel.Info, "Disabling encryption…")
manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p ->
ctx.reportProgress(p)
}
ctx.log(TaskLogLevel.Info, "Encryption disabled")
_messages.emit("Encryption disabled") _messages.emit("Encryption disabled")
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed")
_messages.emit(e.message ?: "Failed to disable encryption") _messages.emit(e.message ?: "Failed to disable encryption")
} }
} },
)
} }
fun rename(storage: IStorageInfo, newName: String) { fun rename(storage: IStorageInfo, newName: String) {
@@ -203,9 +220,19 @@ class LocalVaultViewModel @Inject constructor(
} }
fun remove(storage: IStorageInfo) { fun remove(storage: IStorageInfo) {
viewModelScope.launch { taskOrchestrator.enqueue(
title = "Remove storage",
requiresForeground = true,
work = PipelineWork { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Removing storage…")
removeStorageUseCase.remove(storage) removeStorageUseCase.remove(storage)
ctx.log(TaskLogLevel.Info, "Removed")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Remove failed")
} }
},
)
} }
fun getStorageStatus(storage: IStorageInfo): String { fun getStorageStatus(storage: IStorageInfo): String {

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks
import com.github.nullptroma.wallenc.presentation.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
class TaskPipelineRoute : ScreenRoute()

View File

@@ -0,0 +1,207 @@
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
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.tasks.PipelineTask
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.presentation.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskPipelineScreen(
modifier: Modifier = Modifier,
viewModel: TaskPipelineViewModel = hiltViewModel(),
) {
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 }
var showTestDialog by remember { mutableStateOf(false) }
var testDurationSec by remember { mutableFloatStateOf(10f) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
windowInsets = WindowInsets(0.dp),
title = { Text(stringResource(R.string.task_pipeline_title)) },
)
},
) { inner ->
Column(
Modifier
.padding(inner)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(onClick = { showTestDialog = true }) {
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))
}
}
Text(
stringResource(R.string.task_pipeline_jobs),
style = MaterialTheme.typography.titleMedium,
)
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(pipeline.tasks, key = { it.id.uuid }) { task ->
TaskRow(task = task, isCurrent = task.id == pipeline.currentTaskId)
}
}
Text(
stringResource(R.string.task_pipeline_log),
style = MaterialTheme.typography.titleMedium,
)
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(logs.size) { i ->
val line = logs[i]
val prefix = when (line.level) {
TaskLogLevel.Debug -> "D"
TaskLogLevel.Info -> "I"
TaskLogLevel.Warn -> "W"
TaskLogLevel.Error -> "E"
}
Text(
"[$prefix] ${line.message}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (showTestDialog) {
AlertDialog(
onDismissRequest = { showTestDialog = false },
title = { Text(stringResource(R.string.task_pipeline_test_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(
R.string.task_pipeline_test_dialog_duration,
testDurationSec.toInt(),
)
)
Slider(
value = testDurationSec,
onValueChange = { testDurationSec = it },
valueRange = 0f..60f,
)
}
},
confirmButton = {
Button(
onClick = {
viewModel.startTestTask(testDurationSec.toInt())
showTestDialog = false
},
) {
Text(stringResource(R.string.task_pipeline_test_dialog_start))
}
},
dismissButton = {
Button(onClick = { showTestDialog = false }) {
Text(stringResource(R.string.task_pipeline_test_dialog_cancel))
}
},
)
}
}
@Composable
private fun TaskRow(task: PipelineTask, isCurrent: Boolean) {
Column(Modifier.fillMaxWidth()) {
Text(
task.title,
style = if (isCurrent) MaterialTheme.typography.titleSmall
else MaterialTheme.typography.bodyMedium,
)
val stateLabel = when (val s = task.state) {
TaskRunState.Queued -> stringResource(R.string.task_state_queued)
is TaskRunState.Running -> stringResource(R.string.task_state_running)
TaskRunState.Completed -> stringResource(R.string.task_state_completed)
TaskRunState.Cancelled -> stringResource(R.string.task_state_cancelled)
is TaskRunState.Failed -> stringResource(R.string.task_state_failed, s.message)
}
Text(stateLabel, style = MaterialTheme.typography.bodySmall)
if (task.state is TaskRunState.Running) {
val frac = (task.state as TaskRunState.Running).progress?.fraction
if (frac != null) {
LinearProgressIndicator(
progress = { frac },
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
)
} else {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
)
}
}
}
}

View File

@@ -0,0 +1,37 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks
import androidx.lifecycle.ViewModel
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.delay
import javax.inject.Inject
@HiltViewModel
class TaskPipelineViewModel @Inject constructor(
val orchestrator: ITaskOrchestrator,
) : ViewModel() {
fun startTestTask(durationSec: Int) {
val safeDurationSec = durationSec.coerceIn(0, 60)
orchestrator.enqueue(
title = "Test task (${safeDurationSec}s)",
requiresForeground = true,
work = PipelineWork { ctx ->
val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10
ctx.log(TaskLogLevel.Info, "Test task started for ${safeDurationSec}s")
for (step in 0..steps) {
val fraction = step.toFloat() / steps.toFloat()
val elapsedMs = (fraction * safeDurationSec * 1000).toInt()
ctx.reportProgress(
fraction = fraction,
label = "Elapsed: ${elapsedMs / 1000}s / ${safeDurationSec}s",
)
if (step < steps) delay(100)
}
ctx.log(TaskLogLevel.Info, "Test task finished")
},
)
}
}

View File

@@ -15,4 +15,23 @@
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string> <string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
<string name="storage_lock_actions">Storage encryption actions</string> <string name="storage_lock_actions">Storage encryption actions</string>
<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>
<string name="task_pipeline_test_dialog_cancel">Cancel</string>
<string name="task_state_queued">Queued</string>
<string name="task_state_running">Running</string>
<string name="task_state_completed">Completed</string>
<string name="task_state_cancelled">Cancelled</string>
<string name="task_state_failed">Failed: %1$s</string>
</resources> </resources>