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

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

View File

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

View File

@@ -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()

View File

@@ -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()
class WallencApplication : Application() {
@Inject
lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
override fun onCreate() {
super.onCreate()
taskPipelineForegroundBootstrap.start()
}
}

View File

@@ -7,9 +7,11 @@ import com.github.nullptroma.wallenc.data.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.data.db.app.repository.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(

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.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,

View File

@@ -6,13 +6,16 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.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() }
}

View File

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

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.interfaces
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.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)
}

View File

@@ -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<TaskPipelineRoute>(enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) {
TaskPipelineScreen(
modifier = Modifier.padding(innerPaddings)
)
}
}
}
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import 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<String, ScreenRoute>(
MainRoute::class.qualifiedName!! to MainRoute(),
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
SettingsRoute::class.qualifiedName!! to SettingsRoute()
)
)

View File

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

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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()

View File

@@ -13,6 +13,9 @@ import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUse
import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.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>(LocalVaultScreenState(listOf(), true)) {
private val _messages = MutableSharedFlow<String>()
@@ -111,11 +115,15 @@ class LocalVaultViewModel @Inject constructor(
}
fun createStorage() {
tasksCount++
viewModelScope.launch {
taskOrchestrator.enqueue(
title = "Create storage",
requiresForeground = false,
work = PipelineWork { ctx ->
ctx.log(TaskLogLevel.Info, "Creating storage…")
manageLocalVaultUseCase.createStorage()
tasksCount--
}
ctx.log(TaskLogLevel.Info, "Storage created")
},
)
}
private val runningStorages = mutableSetOf<java.util.UUID>()
@@ -186,14 +194,23 @@ class LocalVaultViewModel @Inject constructor(
}
fun disableEncryption(storage: IStorageInfo) {
viewModelScope.launch {
taskOrchestrator.enqueue(
title = "Disable encryption",
requiresForeground = true,
work = PipelineWork { ctx ->
try {
manageStoragesEncryptionUseCase.disableEncryption(storage)
ctx.log(TaskLogLevel.Info, "Disabling encryption…")
manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p ->
ctx.reportProgress(p)
}
ctx.log(TaskLogLevel.Info, "Encryption disabled")
_messages.emit("Encryption disabled")
} 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 {
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 {

View File

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

View File

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

View File

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

View File

@@ -15,4 +15,23 @@
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
<string name="storage_lock_actions">Storage encryption actions</string>
<string name="task_pipeline_title">Task pipeline</string>
<string name="task_pipeline_jobs">Jobs</string>
<string name="task_pipeline_log">Log</string>
<string name="task_pipeline_cancel_current">Cancel current</string>
<string name="task_pipeline_cancel_all">Cancel all</string>
<string name="task_pipeline_open">Open task pipeline</string>
<string name="task_pipeline_run_test">Run test task</string>
<string name="task_pipeline_current_task">Current task</string>
<string name="task_pipeline_no_current_task">No running task</string>
<string name="task_pipeline_test_dialog_title">Test task setup</string>
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
<string name="task_pipeline_test_dialog_start">Start</string>
<string name="task_pipeline_test_dialog_cancel">Cancel</string>
<string name="task_state_queued">Queued</string>
<string name="task_state_running">Running</string>
<string name="task_state_completed">Completed</string>
<string name="task_state_cancelled">Cancelled</string>
<string name="task_state_failed">Failed: %1$s</string>
</resources>