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