Добавлен foreground сервис
This commit is contained in:
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
data class PipelineState(
|
||||
val tasks: List<PipelineTask>,
|
||||
val currentTaskId: TaskId?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
fun interface PipelineWork {
|
||||
suspend fun run(ctx: TaskContext)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class TaskId(val uuid: UUID = UUID.randomUUID())
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
enum class TaskLogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.github.nullptroma.wallenc.domain.tasks
|
||||
|
||||
data class TaskLogLine(
|
||||
val timestampMs: Long,
|
||||
val level: TaskLogLevel,
|
||||
val message: String,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user