diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5f0a3a3..7537efd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt
index c74bbc6..8095d1c 100644
--- a/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt
+++ b/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt
@@ -1,9 +1,14 @@
package com.github.nullptroma.wallenc.app
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
import com.github.nullptroma.wallenc.presentation.WallencUi
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
@@ -15,6 +20,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ requestNotificationPermissionIfNeeded()
Timber.plant(Timber.DebugTree())
// 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) {
// when (result) {
// is YandexAuthResult.Success -> Toast.makeText(applicationContext, "Success: ${result.token}", Toast.LENGTH_SHORT).show()
diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt
index b2fc6c0..ad6b3f5 100644
--- a/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt
+++ b/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt
@@ -1,7 +1,18 @@
package com.github.nullptroma.wallenc.app
import android.app.Application
+import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
@HiltAndroidApp
-class WallencApplication : Application()
\ No newline at end of file
+class WallencApplication : Application() {
+
+ @Inject
+ lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
+
+ override fun onCreate() {
+ super.onCreate()
+ taskPipelineForegroundBootstrap.start()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt
index 5443d3e..8722bc1 100644
--- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt
+++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt
@@ -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.StorageMetaInfoRepository
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.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
+import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -21,6 +23,12 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class SingletonModule {
+ @Provides
+ @Singleton
+ fun provideTaskOrchestrator(
+ @IoDispatcher ioDispatcher: CoroutineDispatcher,
+ ): ITaskOrchestrator = TaskOrchestrator(ioDispatcher)
+
@Provides
@Singleton
fun provideVaultsManager(
diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundBootstrap.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundBootstrap.kt
new file mode 100644
index 0000000..3276b92
--- /dev/null
+++ b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundBootstrap.kt
@@ -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),
+ )
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..868a6a8
--- /dev/null
+++ b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt
@@ -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"
+
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6413aa8..c0d265b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,8 @@
Wallenc
+ Background tasks
+ Wallenc tasks
+ Preparing…
+ Working…
+ Cancel
\ No newline at end of file
diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt
index 61715dd..bb61cff 100644
--- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt
+++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt
@@ -147,7 +147,7 @@ class UnlockManager(
}
}
- private suspend fun closeBySourceUuid(opened: MutableMap, sourceUuid: UUID) {
+ private fun closeBySourceUuid(opened: MutableMap, sourceUuid: UUID) {
val enc = opened[sourceUuid] ?: return
val nestedSourceUuid = enc.uuid
if (nestedSourceUuid != sourceUuid && opened.containsKey(nestedSourceUuid)) {
diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt
index 166ed0b..01c14e8 100644
--- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt
+++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt
@@ -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.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
+import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle
@@ -16,9 +17,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.UUID
+import kotlin.coroutines.coroutineContext
class EncryptedStorage private constructor(
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 dirs = accessor.getAllDirs()
val paths = buildList {
@@ -124,8 +127,22 @@ class EncryptedStorage private constructor(
}
.filter { it != "/" && it.isNotBlank() }
.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)
+ 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 {
+ private const val PROGRESS_REPORT_INTERVAL = 16
suspend fun create(
source: IStorage,
key: EncryptKey,
diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt
index d4356b4..404d1c5 100644
--- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt
+++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt
@@ -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.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
+import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.InputStream
+import kotlin.coroutines.coroutineContext
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 dirs = accessor.getAllDirs()
val paths = buildList {
@@ -103,12 +106,27 @@ class LocalStorage(
}
.filter { it != "/" && it.isNotBlank() }
.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)
+ if (index % PROGRESS_REPORT_INTERVAL == 0 || index == paths.lastIndex) {
+ onProgress(
+ TaskProgress(
+ fraction = (index + 1).toFloat() / total,
+ label = null,
+ ),
+ )
+ coroutineContext.ensureActive()
+ }
}
}
companion object {
+ private const val PROGRESS_REPORT_INTERVAL = 16
const val STORAGE_INFO_FILE_POSTFIX = ".storage-info"
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
}
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
new file mode 100644
index 0000000..0ef29fb
--- /dev/null
+++ b/data/src/main/java/com/github/nullptroma/wallenc/data/tasks/TaskOrchestrator.kt
@@ -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(Channel.UNLIMITED)
+
+ private val tasksById =
+ Collections.synchronizedMap(linkedMapOf())
+
+ private val cancelRequested = ConcurrentHashMap()
+ private val currentRunJob = AtomicReference(null)
+ private val runningTaskId = AtomicReference(null)
+
+ private val _pipelineState = MutableStateFlow(PipelineState(emptyList(), null))
+ override val pipelineState: StateFlow = _pipelineState.asStateFlow()
+
+ private val logLock = Any()
+ private val logBuffer = ArrayDeque(MAX_LOG_LINES + 1)
+ private val _logLines = MutableStateFlow>(emptyList())
+ override val logLines: StateFlow> = _logLines.asStateFlow()
+
+ 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 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
+ }
+}
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt
index cf05179..dfe8f98 100644
--- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt
@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.interfaces
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.StateFlow
import java.time.Instant
@@ -21,7 +22,7 @@ interface IStorage: IStorageInfo {
suspend fun rename(newName: String)
suspend fun setEncInfo(encInfo: StorageEncryptionInfo?)
- suspend fun clearAllContent()
+ suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit = {})
}
interface IStorageMetaInfo {
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
new file mode 100644
index 0000000..7d0b624
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt
@@ -0,0 +1,22 @@
+package com.github.nullptroma.wallenc.domain.tasks
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface ITaskOrchestrator {
+ val pipelineState: StateFlow
+ val logLines: StateFlow>
+ val foregroundUi: StateFlow
+
+ 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()
+}
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
new file mode 100644
index 0000000..a7f9350
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineState.kt
@@ -0,0 +1,6 @@
+package com.github.nullptroma.wallenc.domain.tasks
+
+data class PipelineState(
+ val tasks: List,
+ val currentTaskId: TaskId?,
+)
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
new file mode 100644
index 0000000..bd459c8
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt
@@ -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,
+)
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineWork.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineWork.kt
new file mode 100644
index 0000000..daa81b1
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineWork.kt
@@ -0,0 +1,5 @@
+package com.github.nullptroma.wallenc.domain.tasks
+
+fun interface PipelineWork {
+ suspend fun run(ctx: TaskContext)
+}
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt
new file mode 100644
index 0000000..ea6c8b2
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt
@@ -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)
+}
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
new file mode 100644
index 0000000..bc4a809
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskForegroundUiState.kt
@@ -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()
+}
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskId.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskId.kt
new file mode 100644
index 0000000..58d4d61
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskId.kt
@@ -0,0 +1,5 @@
+package com.github.nullptroma.wallenc.domain.tasks
+
+import java.util.UUID
+
+data class TaskId(val uuid: UUID = UUID.randomUUID())
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogLevel.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogLevel.kt
new file mode 100644
index 0000000..f0c3f74
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogLevel.kt
@@ -0,0 +1,8 @@
+package com.github.nullptroma.wallenc.domain.tasks
+
+enum class TaskLogLevel {
+ Debug,
+ Info,
+ Warn,
+ Error,
+}
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogLine.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogLine.kt
new file mode 100644
index 0000000..e16580f
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskLogLine.kt
@@ -0,0 +1,7 @@
+package com.github.nullptroma.wallenc.domain.tasks
+
+data class TaskLogLine(
+ val timestampMs: Long,
+ val level: TaskLogLevel,
+ val message: String,
+)
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgress.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgress.kt
new file mode 100644
index 0000000..ee54cff
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskProgress.kt
@@ -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?,
+)
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt
new file mode 100644
index 0000000..6ea47e1
--- /dev/null
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt
@@ -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()
+}
diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt
index f380b00..169b7da 100644
--- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt
+++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt
@@ -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.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
+import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.first
class ManageStoragesEncryptionUseCase(
@@ -53,13 +54,12 @@ class ManageStoragesEncryptionUseCase(
}
}
- suspend fun disableEncryption(storage: IStorageInfo) {
- clearAndDisableEncryption(storage)
- }
-
- suspend fun clearAndDisableEncryption(storage: IStorageInfo) {
+ suspend fun clearAndDisableEncryption(
+ storage: IStorageInfo,
+ onClearProgress: suspend (TaskProgress) -> Unit = {},
+ ) {
if (storage !is IStorage) return
- storage.clearAllContent()
+ storage.clearAllContent(onClearProgress)
storage.setEncInfo(null)
unlockManager.close(storage)
}
diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencUi.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencUi.kt
index 41114b7..59d4080 100644
--- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencUi.kt
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencUi.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
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.Settings
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.MainScreen
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.SettingsScreen
import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsViewModel
@@ -65,6 +68,11 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
MainRoute::class.qualifiedName!! to NavBarItemData(
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(
R.string.nav_label_settings,
SettingsRoute::class.qualifiedName!!,
@@ -125,6 +133,15 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
}) {
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
}
+ composable(enterTransition = {
+ fadeIn(tween(200))
+ }, exitTransition = {
+ fadeOut(tween(200))
+ }) {
+ TaskPipelineScreen(
+ modifier = Modifier.padding(innerPaddings)
+ )
+ }
}
}
}
\ No newline at end of file
diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencViewModel.kt
index 28e0df7..69c4e1f 100644
--- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencViewModel.kt
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/WallencViewModel.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
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.screens.tasks.TaskPipelineRoute
import com.github.nullptroma.wallenc.presentation.screens.settings.SettingsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlin.collections.set
@@ -18,6 +19,7 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS
mutableStateOf(
mapOf(
MainRoute::class.qualifiedName!! to MainRoute(),
+ TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
SettingsRoute::class.qualifiedName!! to SettingsRoute()
)
)
diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/MainScreen.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/MainScreen.kt
index 129ccbc..acaf60d 100644
--- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/MainScreen.kt
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/MainScreen.kt
@@ -101,10 +101,11 @@ fun MainScreen(
val route: LocalVaultRoute = it.toRoute()
LocalVaultScreen(
modifier = Modifier.padding(innerPaddings),
- viewModel = localVaultViewModel
- ) { text ->
- navState.push(TextEditRoute(text))
- }
+ viewModel = localVaultViewModel,
+ openTextEdit = { text ->
+ navState.push(TextEditRoute(text))
+ },
+ )
}
composable(enterTransition = {
fadeIn(tween(200))
diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt
index 9fb9dd7..7c322e9 100644
--- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -38,7 +37,7 @@ import kotlinx.coroutines.flow.collect
fun LocalVaultScreen(
modifier: Modifier = Modifier,
viewModel: LocalVaultViewModel = hiltViewModel(),
- openTextEdit: (String) -> Unit
+ openTextEdit: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
@@ -50,7 +49,10 @@ fun LocalVaultScreen(
}
Box {
- Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = {
+ Scaffold(
+ modifier = modifier,
+ contentWindowInsets = WindowInsets(0.dp),
+ floatingActionButton = {
FloatingActionButton(
onClick = {
viewModel.createStorage()
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 d3ac29b..8c95ff3 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
@@ -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.RenameStorageUseCase
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.extensions.toPrintable
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -32,6 +35,7 @@ class LocalVaultViewModel @Inject constructor(
private val storageFileManagementUseCase: StorageFileManagementUseCase,
private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
private val renameStorageUseCase: RenameStorageUseCase,
+ private val taskOrchestrator: ITaskOrchestrator,
private val logger: ILogger
) : ViewModelBase(LocalVaultScreenState(listOf(), true)) {
private val _messages = MutableSharedFlow()
@@ -111,11 +115,15 @@ class LocalVaultViewModel @Inject constructor(
}
fun createStorage() {
- tasksCount++
- viewModelScope.launch {
- manageLocalVaultUseCase.createStorage()
- tasksCount--
- }
+ taskOrchestrator.enqueue(
+ title = "Create storage",
+ requiresForeground = false,
+ work = PipelineWork { ctx ->
+ ctx.log(TaskLogLevel.Info, "Creating storage…")
+ manageLocalVaultUseCase.createStorage()
+ ctx.log(TaskLogLevel.Info, "Storage created")
+ },
+ )
}
private val runningStorages = mutableSetOf()
@@ -186,14 +194,23 @@ class LocalVaultViewModel @Inject constructor(
}
fun disableEncryption(storage: IStorageInfo) {
- viewModelScope.launch {
- try {
- manageStoragesEncryptionUseCase.disableEncryption(storage)
- _messages.emit("Encryption disabled")
- } catch (e: Exception) {
- _messages.emit(e.message ?: "Failed to disable encryption")
- }
- }
+ taskOrchestrator.enqueue(
+ title = "Disable encryption",
+ requiresForeground = true,
+ work = PipelineWork { ctx ->
+ try {
+ ctx.log(TaskLogLevel.Info, "Disabling encryption…")
+ manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p ->
+ ctx.reportProgress(p)
+ }
+ ctx.log(TaskLogLevel.Info, "Encryption disabled")
+ _messages.emit("Encryption disabled")
+ } catch (e: Exception) {
+ ctx.log(TaskLogLevel.Error, e.message ?: "Failed")
+ _messages.emit(e.message ?: "Failed to disable encryption")
+ }
+ },
+ )
}
fun rename(storage: IStorageInfo, newName: String) {
@@ -203,9 +220,19 @@ class LocalVaultViewModel @Inject constructor(
}
fun remove(storage: IStorageInfo) {
- viewModelScope.launch {
- removeStorageUseCase.remove(storage)
- }
+ taskOrchestrator.enqueue(
+ title = "Remove storage",
+ requiresForeground = true,
+ work = PipelineWork { ctx ->
+ try {
+ ctx.log(TaskLogLevel.Info, "Removing 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 {
diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineRoute.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineRoute.kt
new file mode 100644
index 0000000..66545b9
--- /dev/null
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineRoute.kt
@@ -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()
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
new file mode 100644
index 0000000..dcba3a4
--- /dev/null
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineScreen.kt
@@ -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),
+ )
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..be0a657
--- /dev/null
+++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/tasks/TaskPipelineViewModel.kt
@@ -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")
+ },
+ )
+ }
+}
diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml
index 9789831..83c5c4f 100644
--- a/presentation/src/main/res/values/strings.xml
+++ b/presentation/src/main/res/values/strings.xml
@@ -15,4 +15,23 @@
Delete storage "%1$s"?
Storage encryption actions
+ 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
+ Cancel
+ Queued
+ Running
+ Completed
+ Cancelled
+ Failed: %1$s
+
\ No newline at end of file