TasksRootSection.kt (23540B) - raw


      1 package me.rhunk.snapenhance.ui.manager.pages
      2 
      3 import android.content.Intent
      4 import android.graphics.drawable.ColorDrawable
      5 import androidx.compose.foundation.Image
      6 import androidx.compose.foundation.border
      7 import androidx.compose.foundation.clickable
      8 import androidx.compose.foundation.gestures.detectTapGestures
      9 import androidx.compose.foundation.layout.*
     10 import androidx.compose.foundation.lazy.LazyColumn
     11 import androidx.compose.foundation.lazy.items
     12 import androidx.compose.foundation.lazy.rememberLazyListState
     13 import androidx.compose.material.icons.Icons
     14 import androidx.compose.material.icons.filled.*
     15 import androidx.compose.material3.*
     16 import androidx.compose.runtime.*
     17 import androidx.compose.ui.Alignment
     18 import androidx.compose.ui.Modifier
     19 import androidx.compose.ui.draw.clip
     20 import androidx.compose.ui.draw.clipToBounds
     21 import androidx.compose.ui.graphics.StrokeCap
     22 import androidx.compose.ui.graphics.toArgb
     23 import androidx.compose.ui.input.pointer.pointerInput
     24 import androidx.compose.ui.layout.ContentScale
     25 import androidx.compose.ui.unit.dp
     26 import androidx.core.net.toUri
     27 import androidx.documentfile.provider.DocumentFile
     28 import androidx.lifecycle.Lifecycle
     29 import androidx.navigation.NavBackStackEntry
     30 import coil.compose.rememberAsyncImagePainter
     31 import coil.request.ImageRequest
     32 import kotlinx.coroutines.CoroutineScope
     33 import kotlinx.coroutines.Dispatchers
     34 import kotlinx.coroutines.launch
     35 import me.rhunk.snapenhance.bridge.DownloadCallback
     36 import me.rhunk.snapenhance.common.data.download.DownloadMetadata
     37 import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
     38 import me.rhunk.snapenhance.common.data.download.createNewFilePath
     39 import me.rhunk.snapenhance.common.ui.TopBarActionButton
     40 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
     41 import me.rhunk.snapenhance.common.util.ktx.longHashCode
     42 import me.rhunk.snapenhance.download.DownloadProcessor
     43 import me.rhunk.snapenhance.download.FFMpegProcessor
     44 import me.rhunk.snapenhance.task.*
     45 import me.rhunk.snapenhance.ui.manager.Routes
     46 import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
     47 import me.rhunk.snapenhance.ui.util.coil.cacheKey
     48 import java.io.File
     49 import java.util.UUID
     50 import kotlin.math.absoluteValue
     51 
     52 class TasksRootSection : Routes.Route() {
     53     private var activeTasks by mutableStateOf(listOf<PendingTask>())
     54     private lateinit var recentTasks: MutableList<Task>
     55     private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
     56 
     57     private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) {
     58         scope.launch(Dispatchers.IO) {
     59             activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList()
     60         }
     61     }
     62 
     63     private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) {
     64         val firstTask = selection.first().first
     65 
     66         val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
     67         val pendingTask = context.taskManager.createPendingTask(
     68             Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash)
     69         )
     70         pendingTask.status = TaskStatus.RUNNING
     71         fetchActiveTasks()
     72 
     73         context.coroutineScope.launch {
     74             val filesToMerge = mutableListOf<File>()
     75 
     76             selection.forEach { (task, documentFile) ->
     77                 val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also {
     78                     it.deleteOnExit()
     79                 }
     80 
     81                 runCatching {
     82                     pendingTask.updateProgress("Copying ${documentFile.name}")
     83                     context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
     84                         //copy with progress
     85                         val length = documentFile.length().toFloat()
     86                         tempFile.outputStream().use { outputStream ->
     87                             val buffer = ByteArray(16 * 1024)
     88                             var read: Int
     89                             while (inputStream.read(buffer).also { read = it } != -1) {
     90                                 outputStream.write(buffer, 0, read)
     91                                 pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt())
     92                             }
     93                             outputStream.flush()
     94                             filesToMerge.add(tempFile)
     95                         }
     96                     }
     97                 }.onFailure {
     98                     pendingTask.fail("Failed to copy file $documentFile to $tempFile")
     99                     filesToMerge.forEach { it.delete() }
    100                     return@launch
    101                 }
    102             }
    103 
    104             val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also {
    105                 it.deleteOnExit()
    106             }
    107 
    108             runCatching {
    109                 context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString()))
    110                 FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
    111                     FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge.map { it.absolutePath }, mergedFile)
    112                 )
    113                 DownloadProcessor(context, object: DownloadCallback.Default() {
    114                     override fun onSuccess(outputPath: String) {
    115                         context.log.verbose("Merged files to $outputPath")
    116                     }
    117                 }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata(
    118                     mediaIdentifier = taskHash,
    119                     outputPath = createNewFilePath(
    120                         context.config.root,
    121                         taskHash,
    122                         downloadSource = MediaDownloadSource.MERGED,
    123                         mediaAuthor = firstTask.author,
    124                         creationTimestamp = System.currentTimeMillis()
    125                     ),
    126                     mediaAuthor = firstTask.author,
    127                     downloadSource = MediaDownloadSource.MERGED.translate(context.translation),
    128                     iconUrl = null
    129                 ))
    130             }.onFailure {
    131                 context.log.error("Failed to merge files", it)
    132                 pendingTask.fail(it.message ?: "Failed to merge files")
    133             }.onSuccess {
    134                 pendingTask.success()
    135             }
    136             filesToMerge.forEach { it.delete() }
    137             mergedFile.delete()
    138         }.also {
    139             pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() }))
    140         }
    141     }
    142 
    143     override val topBarActions: @Composable (RowScope.() -> Unit) = {
    144         var showConfirmDialog by remember { mutableStateOf(false) }
    145         val coroutineScope = rememberCoroutineScope()
    146 
    147         if (taskSelection.size > 1) {
    148             val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
    149                 taskSelection.all { it.second?.type?.contains("video") == true }
    150             }
    151 
    152             if (canMergeSelection) {
    153                 TopBarActionButton(
    154                     onClick = {
    155                         mergeSelection(taskSelection.toList().also {
    156                             taskSelection.clear()
    157                         }.map { it.first to it.second!! })
    158                     },
    159                     icon = Icons.Filled.Merge,
    160                     text = translation["merge_button"]
    161                 )
    162             }
    163         }
    164 
    165         IconButton(onClick = {
    166             showConfirmDialog = true
    167         }) {
    168             Icon(Icons.Filled.Delete, contentDescription = "Clear tasks")
    169         }
    170 
    171         if (showConfirmDialog) {
    172             var alsoDeleteFiles by remember { mutableStateOf(false) }
    173 
    174             AlertDialog(
    175                 onDismissRequest = { showConfirmDialog = false },
    176                 title = {
    177                     if (taskSelection.isNotEmpty()) {
    178                         Text(translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()))
    179                     } else {
    180                         Text(translation["remove_all_tasks_confirm"])
    181                     }
    182                 },
    183                 text = {
    184                     Column {
    185                         if (taskSelection.isNotEmpty()) {
    186                             Text(translation["remove_selected_tasks_title"])
    187                             Row (
    188                                 modifier = Modifier
    189                                     .padding(top = 10.dp)
    190                                     .fillMaxWidth()
    191                                     .clickable {
    192                                         alsoDeleteFiles = !alsoDeleteFiles
    193                                     },
    194                                 horizontalArrangement = Arrangement.spacedBy(5.dp),
    195                                 verticalAlignment = Alignment.CenterVertically
    196                             ) {
    197                                 Checkbox(checked = alsoDeleteFiles, onCheckedChange = {
    198                                     alsoDeleteFiles = it
    199                                 })
    200                                 Text(translation["delete_files_option"])
    201                             }
    202                         } else {
    203                             Text(translation["remove_all_tasks_title"])
    204                         }
    205                     }
    206                 },
    207                 confirmButton = {
    208                     Button(
    209                         onClick = {
    210                             showConfirmDialog = false
    211                             if (taskSelection.isNotEmpty()) {
    212                                 taskSelection.forEach { (task, documentFile) ->
    213                                     coroutineScope.launch(Dispatchers.IO) {
    214                                         context.taskManager.removeTask(task)
    215                                         if (alsoDeleteFiles) {
    216                                             documentFile?.delete()
    217                                         }
    218                                     }
    219                                     recentTasks.remove(task)
    220                                 }
    221                                 activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
    222                                 taskSelection.clear()
    223                             } else {
    224                                 coroutineScope.launch(Dispatchers.IO) {
    225                                     context.taskManager.clearAllTasks()
    226                                 }
    227                                 recentTasks.clear()
    228                                 activeTasks.forEach {
    229                                     runCatching {
    230                                         it.cancel()
    231                                     }.onFailure { throwable ->
    232                                         context.log.error("Failed to cancel task $it", throwable)
    233                                     }
    234                                 }
    235                                 activeTasks = listOf()
    236                                 context.taskManager.getActiveTasks().clear()
    237                             }
    238                         }
    239                     ) {
    240                         Text(context.translation["button.positive"])
    241                     }
    242                 },
    243                 dismissButton = {
    244                     Button(
    245                         onClick = {
    246                             showConfirmDialog = false
    247                         }
    248                     ) {
    249                         Text(context.translation["button.negative"])
    250                     }
    251                 }
    252             )
    253         }
    254     }
    255 
    256     @Composable
    257     private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
    258         var taskStatus by remember { mutableStateOf(task.status) }
    259         var taskProgressLabel by remember { mutableStateOf<String?>(null) }
    260         var taskProgress by remember { mutableIntStateOf(-1) }
    261         val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
    262 
    263         var documentFileMimeType by remember { mutableStateOf("") }
    264         var isDocumentFileReadable by remember { mutableStateOf(true) }
    265         val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
    266             DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
    267                 documentFileMimeType = type ?: ""
    268                 isDocumentFileReadable = canRead()
    269             }
    270         }
    271 
    272 
    273         val listener = remember { PendingTaskListener(
    274             onStateChange = {
    275                 taskStatus = it
    276             },
    277             onProgress = { label, progress ->
    278                 taskProgressLabel = label
    279                 taskProgress = progress
    280             }
    281         ) }
    282 
    283         LaunchedEffect(Unit) {
    284             pendingTask?.addListener(listener)
    285         }
    286 
    287         DisposableEffect(Unit) {
    288             onDispose {
    289                 pendingTask?.removeListener(listener)
    290             }
    291         }
    292 
    293         fun toggleSelection() {
    294             if (isSelected) {
    295                 taskSelection.removeIf { it.first == task }
    296                 return
    297             }
    298             taskSelection.add(task to documentFile)
    299         }
    300 
    301         fun openFile() {
    302             if (!isDocumentFileReadable || documentFile == null) return
    303             runCatching {
    304                 context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
    305                     setDataAndType(documentFile!!.uri, documentFile!!.type)
    306                     flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
    307                 })
    308             }.onFailure {
    309                 context.log.error("Failed to open file ${documentFile?.uri}", it)
    310                 context.shortToast(translation["failed_to_open_file"])
    311             }
    312         }
    313 
    314         OutlinedCard(modifier = modifier
    315             .pointerInput(Unit) {
    316                 detectTapGestures(
    317                     onTap = {
    318                         if (taskSelection.isNotEmpty()) {
    319                             toggleSelection()
    320                             return@detectTapGestures
    321                         }
    322                         openFile()
    323                     },
    324                     onLongPress = {
    325                         if (taskSelection.isNotEmpty()) {
    326                             openFile()
    327                             return@detectTapGestures
    328                         }
    329                         toggleSelection()
    330                     }
    331                 )
    332             }
    333             .let {
    334                 if (isSelected) {
    335                     it
    336                         .border(2.dp, MaterialTheme.colorScheme.primary)
    337                         .clip(MaterialTheme.shapes.medium)
    338                 } else it
    339             }
    340         ) {
    341             Row(
    342                 modifier = Modifier.padding(12.dp),
    343                 verticalAlignment = Alignment.CenterVertically
    344             ) {
    345                 Box(
    346                     modifier = Modifier
    347                         .padding(end = 15.dp)
    348                         .size(50.dp)
    349                         .clipToBounds(),
    350                     contentAlignment = Alignment.Center
    351                 ) {
    352                     var loadFailed by remember { mutableStateOf(false) }
    353                     documentFile?.let {
    354                         if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
    355                             Image(
    356                                 painter = rememberAsyncImagePainter(
    357                                     model = ImageRequest.Builder(context.androidContext)
    358                                         .data(it.uri)
    359                                         .cacheKey(it.uri.toString())
    360                                         .placeholder(ColorDrawable(MaterialTheme.colorScheme.surfaceVariant.toArgb()))
    361                                         .build(),
    362                                     imageLoader = context.imageLoader,
    363                                     onError = { loadFailed = true }
    364                                 ),
    365                                 contentDescription = null,
    366                                 contentScale = ContentScale.FillWidth,
    367                                 modifier = Modifier
    368                                     .size(50.dp)
    369                                     .clip(MaterialTheme.shapes.medium)
    370                             )
    371                         } else {
    372                             when {
    373                                 !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
    374                                 documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
    375                                 documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
    376                                 documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
    377                                 else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
    378                             }
    379                         }
    380                     } ?: run {
    381                         when (task.type) {
    382                             TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download")
    383                             TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action")
    384                         }
    385                     }
    386                 }
    387                 Column(
    388                     modifier = Modifier.weight(1f),
    389                 ) {
    390                     Row(
    391                         modifier = Modifier.fillMaxWidth(),
    392                         verticalAlignment = Alignment.CenterVertically
    393                     ) {
    394                         Text(task.title, style = MaterialTheme.typography.bodyMedium)
    395                         task.author?.takeIf { it != "null" }?.let {
    396                             Spacer(modifier = Modifier.width(5.dp))
    397                             Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
    398                         }
    399                     }
    400                     Text(task.hash, style = MaterialTheme.typography.labelSmall)
    401                     Column(
    402                         modifier = Modifier.padding(top = 5.dp),
    403                         verticalArrangement = Arrangement.spacedBy(5.dp)
    404                     ) {
    405                         if (taskStatus.isFinalStage()) {
    406                             if (taskStatus != TaskStatus.SUCCESS) {
    407                                 Text("$taskStatus", style = MaterialTheme.typography.bodySmall)
    408                             }
    409                         } else {
    410                             taskProgressLabel?.let {
    411                                 Text(it, style = MaterialTheme.typography.bodySmall)
    412                             }
    413                             if (taskProgress != -1) {
    414                                 LinearProgressIndicator(
    415                                     progress = { taskProgress.toFloat() / 100f },
    416                                     strokeCap = StrokeCap.Round,
    417                                 )
    418                             } else {
    419                                 task.extra?.let {
    420                                     Text(it, style = MaterialTheme.typography.bodySmall)
    421                                 }
    422                             }
    423                         }
    424                     }
    425                 }
    426 
    427                 Column {
    428                     if (pendingTask != null && !taskStatus.isFinalStage()) {
    429                         FilledIconButton(onClick = {
    430                             runCatching {
    431                                 pendingTask.cancel()
    432                             }.onFailure { throwable ->
    433                                 context.log.error("Failed to cancel task $pendingTask", throwable)
    434                             }
    435                         }) {
    436                             Icon(Icons.Filled.Close, contentDescription = "Cancel")
    437                         }
    438                     } else {
    439                         when (taskStatus) {
    440                             TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary)
    441                             TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error)
    442                             TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error)
    443                             else -> {}
    444                         }
    445                     }
    446                 }
    447             }
    448         }
    449     }
    450 
    451     override val content: @Composable (NavBackStackEntry) -> Unit = {
    452         val scrollState = rememberLazyListState()
    453         val scope = rememberCoroutineScope()
    454         recentTasks = remember { mutableStateListOf() }
    455         var lastFetchedTaskId by remember { mutableStateOf(null as Long?) }
    456 
    457         fun fetchNewRecentTasks() {
    458             scope.launch(Dispatchers.IO) {
    459                 val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
    460                 if (tasks.isNotEmpty()) {
    461                     lastFetchedTaskId = tasks.keys.last()
    462                     val activeTaskIds = activeTasks.map { it.taskId }
    463                     recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
    464                 }
    465             }
    466         }
    467 
    468         LaunchedEffect(Unit) {
    469             fetchActiveTasks(this)
    470         }
    471 
    472         DisposableEffect(Unit) {
    473             onDispose {
    474                 taskSelection.clear()
    475             }
    476         }
    477 
    478         OnLifecycleEvent { _, event ->
    479             if (event == Lifecycle.Event.ON_RESUME) {
    480                 fetchActiveTasks(scope)
    481             }
    482         }
    483 
    484         LazyColumn(
    485             state = scrollState,
    486             modifier = Modifier.fillMaxSize()
    487         ) {
    488             item {
    489                 if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
    490                     Column(
    491                         modifier = Modifier.fillMaxSize(),
    492                         horizontalAlignment = Alignment.CenterHorizontally,
    493                         verticalArrangement = Arrangement.Center
    494                     ) {
    495                         translation["no_tasks"].let {
    496                             Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary)
    497                             Text(it, style = MaterialTheme.typography.bodyLarge)
    498                         }
    499                     }
    500                 }
    501             }
    502             items(activeTasks, key = { it.taskId }) {pendingTask ->
    503                 TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask)
    504             }
    505             items(recentTasks, key = { it.hash }) { task ->
    506                 TaskCard(modifier = Modifier.padding(8.dp), task)
    507             }
    508             item {
    509                 Spacer(modifier = Modifier.height(40.dp))
    510                 LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
    511                     fetchNewRecentTasks()
    512                 }
    513             }
    514         }
    515     }
    516 }