Добавлен 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

@@ -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 ->
navState.push(TextEditRoute(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 {
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<java.util.UUID>()
@@ -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 {

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>