commit bbcaab4e75b4aac792907802532aacdb4a188cf4 parent 31aa7151ec20757aedfac28d1dbefbdfbae39c2a Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 29 Oct 2023 02:17:58 +0200 feat: task section Diffstat:
14 files changed, 647 insertions(+), 590 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -24,11 +24,11 @@ import me.rhunk.snapenhance.common.BuildConfig import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.common.config.ModConfig -import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.e2ee.E2EEImplementation import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.scripting.RemoteScriptManager +import me.rhunk.snapenhance.task.TaskManager import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.ModInfo @@ -60,7 +60,7 @@ class RemoteSideContext( val config = ModConfig(androidContext) val translation = LocaleWrapper() val mappings = MappingsWrapper() - val downloadTaskManager = DownloadTaskManager() + val taskManager = TaskManager(this) val modDatabase = ModDatabase(this) val streaksReminder = StreaksReminder(this) val log = LogManager(this) @@ -100,7 +100,7 @@ class RemoteSideContext( loadFromContext(androidContext) init(androidContext) } - downloadTaskManager.init(androidContext) + taskManager.init() modDatabase.init() streaksReminder.init() scriptManager.init() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt @@ -1,34 +0,0 @@ -package me.rhunk.snapenhance.download - -import kotlinx.coroutines.Job -import me.rhunk.snapenhance.common.data.download.DownloadMetadata -import me.rhunk.snapenhance.common.data.download.DownloadStage - -data class DownloadObject( - var downloadId: Int = 0, - var outputFile: String? = null, - val metadata : DownloadMetadata -) { - var job: Job? = null - - var changeListener = { _: DownloadStage, _: DownloadStage -> } - lateinit var updateTaskCallback: (DownloadObject) -> Unit - - private var _stage: DownloadStage = DownloadStage.PENDING - var downloadStage: DownloadStage - get() = synchronized(this) { - _stage - } - set(value) = synchronized(this) { - changeListener(_stage, value) - _stage = value - updateTaskCallback(this) - } - - fun isJobActive() = job?.isActive == true - - fun cancel() { - downloadStage = DownloadStage.CANCELLED - job?.cancel() - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -18,14 +18,24 @@ import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.ReceiversConfig import me.rhunk.snapenhance.common.data.FileType -import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.data.download.DownloadMediaType +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.DownloadRequest +import me.rhunk.snapenhance.common.data.download.InputMedia +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.task.PendingTask +import me.rhunk.snapenhance.task.PendingTaskListener +import me.rhunk.snapenhance.task.Task +import me.rhunk.snapenhance.task.TaskStatus +import me.rhunk.snapenhance.task.TaskType import java.io.File import java.io.InputStream import java.net.HttpURLConnection import java.net.URL -import java.util.zip.ZipInputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import javax.xml.parsers.DocumentBuilderFactory import javax.xml.transform.TransformerFactory import javax.xml.transform.dom.DOMSource @@ -33,6 +43,7 @@ import javax.xml.transform.stream.StreamResult import kotlin.coroutines.coroutineContext import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.absoluteValue data class DownloadedFile( val file: File, @@ -52,12 +63,6 @@ class DownloadProcessor ( remoteSideContext.translation.getCategory("download_processor") } - private val ffmpegProcessor by lazy { - FFMpegProcessor( - remoteSideContext.log, - remoteSideContext.config.root.downloader.ffmpegOptions - ) - } private val gson by lazy { GsonBuilder().setPrettyPrinting().create() } private fun fallbackToast(message: Any) { @@ -84,26 +89,16 @@ class DownloadProcessor ( fallbackToast(it) } - private fun extractZip(inputStream: InputStream): List<File> { - val files = mutableListOf<File>() - val zipInputStream = ZipInputStream(inputStream) - var entry = zipInputStream.nextEntry - - while (entry != null) { - createMediaTempFile().also { file -> - file.outputStream().use { outputStream -> - zipInputStream.copyTo(outputStream) - } - files += file - } - entry = zipInputStream.nextEntry + private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor( + logManager = remoteSideContext.log, + ffmpegOptions = remoteSideContext.config.root.downloader.ffmpegOptions, + onStatistics = { + pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})") } - - return files - } + ) @SuppressLint("UnspecifiedRegisterReceiverFlag") - private suspend fun saveMediaToGallery(inputFile: File, downloadObject: DownloadObject) { + private suspend fun saveMediaToGallery(pendingTask: PendingTask, inputFile: File, metadata: DownloadMetadata) { if (coroutineContext.job.isCancelled) return runCatching { @@ -111,6 +106,7 @@ class DownloadProcessor ( if (fileType == FileType.UNKNOWN) { callbackOnFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null) + pendingTask.fail("Unknown media type") return } @@ -124,6 +120,7 @@ class DownloadProcessor ( else -> throw Exception("Invalid image format") } + pendingTask.updateProgress("Converting image to $format") val outputStream = inputFile.outputStream() bitmap.compress(compressFormat, 100, outputStream) outputStream.close() @@ -132,12 +129,12 @@ class DownloadProcessor ( } } - val fileName = downloadObject.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension + val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get())) ?: throw Exception("Failed to open output folder") - val outputFileFolder = downloadObject.metadata.outputPath.let { + val outputFileFolder = metadata.outputPath.let { if (it.contains("/")) { it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> folder.findFile(name) ?: folder.createDirectory(name)!! @@ -150,12 +147,13 @@ class DownloadProcessor ( val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!! + pendingTask.updateProgress("Saving media to gallery") inputFile.inputStream().use { inputStream -> inputStream.copyTo(outputStream) } - downloadObject.outputFile = outputFile.uri.toString() - downloadObject.downloadStage = DownloadStage.SAVED + pendingTask.task.extra = outputFile.uri.toString() + pendingTask.success() runCatching { val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") @@ -171,7 +169,7 @@ class DownloadProcessor ( }.onFailure { exception -> remoteSideContext.log.error("Failed to save media to gallery", exception) callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message) - downloadObject.downloadStage = DownloadStage.FAILED + pendingTask.fail("Failed to save media to gallery") } } @@ -179,38 +177,65 @@ class DownloadProcessor ( return File.createTempFile("media", ".tmp") } - private fun downloadInputMedias(downloadRequest: DownloadRequest) = runBlocking { + private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking { val jobs = mutableListOf<Job>() val downloadedMedias = mutableMapOf<InputMedia, File>() + val inputMediaProgress = ConcurrentHashMap<InputMedia, String>() + + fun updateDownloadProgress() { + pendingTask.updateProgress( + inputMediaProgress.values.joinToString("\n"), + progress = (jobs.filter { it.isActive }.size.toDouble() / jobs.size.toDouble() * 100.0).toInt() + ) + } downloadRequest.inputMedias.forEach { inputMedia -> - fun handleInputStream(inputStream: InputStream) { + fun setProgress(progress: String) { + inputMediaProgress[inputMedia] = progress + updateDownloadProgress() + } + + fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) { createMediaTempFile().apply { - (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).copyTo(outputStream()) + val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered() + val outputStream = outputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var totalRead = 0L + var lastTotalRead = 0L + + while (decryptedInputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + totalRead += read + if (totalRead - lastTotalRead > 1024 * 1024) { + setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB") + lastTotalRead = totalRead + } + } }.also { downloadedMedias[inputMedia] = it } } launch { when (inputMedia.type) { DownloadMediaType.PROTO_MEDIA -> { - RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { - handleInputStream(it) + RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length -> + handleInputStream(inputStream, estimatedSize = length) }) } - DownloadMediaType.DIRECT_MEDIA -> { - val decoded = Base64.UrlSafe.decode(inputMedia.content) - createMediaTempFile().apply { - writeBytes(decoded) - }.also { downloadedMedias[inputMedia] = it } - } DownloadMediaType.REMOTE_MEDIA -> { with(URL(inputMedia.content).openConnection() as HttpURLConnection) { requestMethod = "GET" setRequestProperty("User-Agent", Constants.USER_AGENT) connect() - handleInputStream(inputStream) + handleInputStream(inputStream, estimatedSize = contentLength.toLong()) } } + DownloadMediaType.DIRECT_MEDIA -> { + val decoded = Base64.UrlSafe.decode(inputMedia.content) + createMediaTempFile().apply { + writeBytes(decoded) + }.also { downloadedMedias[inputMedia] = it } + } else -> { downloadedMedias[inputMedia] = File(inputMedia.content) } @@ -222,7 +247,7 @@ class DownloadProcessor ( downloadedMedias } - private suspend fun downloadRemoteMedia(downloadObjectObject: DownloadObject, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { + private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { downloadRequest.inputMedias.first().let { inputMedia -> val mediaType = inputMedia.type val media = downloadedMedias[inputMedia]!! @@ -231,19 +256,19 @@ class DownloadProcessor ( if (inputMedia.attachmentType == "NOTE") { remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format -> val outputFile = File.createTempFile("voice_note", ".$format") - ffmpegProcessor.execute(FFMpegProcessor.Request( + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( action = FFMpegProcessor.Action.AUDIO_CONVERSION, input = media.file, output = outputFile )) media.file.delete() - saveMediaToGallery(outputFile, downloadObjectObject) + saveMediaToGallery(pendingTask, outputFile, metadata) outputFile.delete() return } } - saveMediaToGallery(media.file, downloadObjectObject) + saveMediaToGallery(pendingTask, media.file, metadata) media.file.delete() return } @@ -267,19 +292,19 @@ class DownloadProcessor ( callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension)) val outputFile = File.createTempFile("dash", ".mp4") runCatching { - ffmpegProcessor.execute(FFMpegProcessor.Request( + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( action = FFMpegProcessor.Action.DOWNLOAD_DASH, input = dashPlaylistFile, output = outputFile, startTime = dashOptions.offsetTime, duration = dashOptions.duration )) - saveMediaToGallery(outputFile, downloadObjectObject) + saveMediaToGallery(pendingTask, outputFile, metadata) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure remoteSideContext.log.error("Failed to download dash media", exception) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) - downloadObjectObject.downloadStage = DownloadStage.FAILED + pendingTask.fail("Failed to download dash media") } dashPlaylistFile.delete() @@ -299,35 +324,35 @@ class DownloadProcessor ( val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java) val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java) - remoteSideContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage -> - translation[if (downloadStage.isFinalStage) { - "already_downloaded_toast" + remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task -> + remoteSideContext.log.debug("already queued or downloaded") + + if (task.status.isFinalStage()) { + callbackOnFailure(translation["already_downloaded_toast"], null) } else { - "already_queued_toast" - }].let { - callbackOnFailure(it, null) + callbackOnFailure(translation["already_queued_toast"], null) } return@launch } - val downloadObjectObject = DownloadObject( - metadata = downloadMetadata + remoteSideContext.log.debug("downloading media") + val pendingTask = remoteSideContext.taskManager.createPendingTask( + Task( + type = TaskType.DOWNLOAD, + title = downloadMetadata.downloadSource + " (" + downloadMetadata.mediaAuthor + ")", + hash = (downloadMetadata.mediaIdentifier ?: UUID.randomUUID().toString()).hashCode().absoluteValue.toString(16) + ) ).apply { - updateTaskCallback = { - remoteSideContext.downloadTaskManager.updateTask(it) - } - } - - downloadObjectObject.also { - remoteSideContext.downloadTaskManager.addTask(it) - }.apply { - job = coroutineContext.job - downloadStage = DownloadStage.DOWNLOADING + status = TaskStatus.RUNNING + addListener(PendingTaskListener(onCancel = { + coroutineContext.job.cancel() + })) + updateProgress("Downloading...") } runCatching { //first download all input medias into cache - val downloadedMedias = downloadInputMedias(downloadRequest).map { + val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map { it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) }.toMap().toMutableMap() remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias") @@ -369,21 +394,20 @@ class DownloadProcessor ( val mergedOverlay: File = File.createTempFile("merged", ".mp4") runCatching { callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension)) - downloadObjectObject.downloadStage = DownloadStage.MERGING - ffmpegProcessor.execute(FFMpegProcessor.Request( + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( action = FFMpegProcessor.Action.MERGE_OVERLAY, input = renamedMedia, output = mergedOverlay, overlay = renamedOverlayMedia )) - saveMediaToGallery(mergedOverlay, downloadObjectObject) + saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure remoteSideContext.log.error("Failed to merge overlay", exception) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) - downloadObjectObject.downloadStage = DownloadStage.MERGE_FAILED + pendingTask.fail("Failed to merge overlay") } mergedOverlay.delete() @@ -392,9 +416,9 @@ class DownloadProcessor ( return@launch } - downloadRemoteMedia(downloadObjectObject, downloadedMedias, downloadRequest) + downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest) }.onFailure { exception -> - downloadObjectObject.downloadStage = DownloadStage.FAILED + pendingTask.fail("Failed to download media") remoteSideContext.log.error("Failed to download media", exception) callbackOnFailure(translation["failed_generic_toast"], exception.message) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -1,192 +0,0 @@ -package me.rhunk.snapenhance.download - -import android.annotation.SuppressLint -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.common.data.download.DownloadMetadata -import me.rhunk.snapenhance.common.data.download.DownloadStage -import me.rhunk.snapenhance.common.data.download.MediaDownloadSource -import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper -import me.rhunk.snapenhance.common.util.ktx.getIntOrNull -import me.rhunk.snapenhance.common.util.ktx.getStringOrNull -import java.util.concurrent.Executors - -class DownloadTaskManager { - private lateinit var taskDatabase: SQLiteDatabase - private val pendingTasks = mutableMapOf<Int, DownloadObject>() - private val cachedTasks = mutableMapOf<Int, DownloadObject>() - private val executor = Executors.newSingleThreadExecutor() - - @SuppressLint("Range") - fun init(context: Context) { - if (this::taskDatabase.isInitialized) return - taskDatabase = context.openOrCreateDatabase("download_tasks", Context.MODE_PRIVATE, null).apply { - SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf( - "tasks" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "hash VARCHAR UNIQUE", - "outputPath TEXT", - "outputFile TEXT", - "mediaAuthor TEXT", - "downloadSource TEXT", - "iconUrl TEXT", - "downloadStage TEXT" - ) - )) - } - } - - fun addTask(task: DownloadObject) { - executor.execute { - taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, downloadSource, mediaAuthor, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", - arrayOf( - task.metadata.mediaIdentifier, - task.metadata.outputPath, - task.outputFile, - task.metadata.downloadSource, - task.metadata.mediaAuthor, - task.metadata.iconUrl, - task.downloadStage.name - ) - ) - task.downloadId = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use { - it.moveToFirst() - it.getInt(0) - } - pendingTasks[task.downloadId] = task - } - } - - fun updateTask(task: DownloadObject) { - executor.execute { - taskDatabase.execSQL( - "UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, downloadSource = ?, mediaAuthor = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", - arrayOf( - task.metadata.mediaIdentifier, - task.metadata.outputPath, - task.outputFile, - task.metadata.downloadSource, - task.metadata.mediaAuthor, - task.metadata.iconUrl, - task.downloadStage.name, - task.downloadId - ) - ) - } - //if the task is no longer active, move it to the cached tasks - if (task.isJobActive()) { - pendingTasks[task.downloadId] = task - } else { - pendingTasks.remove(task.downloadId) - cachedTasks[task.downloadId] = task - } - } - - @SuppressLint("Range") - fun canDownloadMedia(mediaIdentifier: String?): DownloadStage? { - if (mediaIdentifier == null) return null - - val cursor = taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(mediaIdentifier)) - if (cursor.count > 0) { - cursor.moveToFirst() - val downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage"))) - cursor.close() - - //if the stage has reached a final stage and is not in a saved state, remove the task - if (downloadStage.isFinalStage && downloadStage != DownloadStage.SAVED) { - taskDatabase.execSQL("DELETE FROM tasks WHERE hash = ?", arrayOf(mediaIdentifier)) - return null - } - - return downloadStage - } - cursor.close() - return null - } - - fun isEmpty(): Boolean { - return cachedTasks.isEmpty() && pendingTasks.isEmpty() - } - - private fun removeTask(id: Int) { - executor.execute { - taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(id)) - cachedTasks.remove(id) - pendingTasks.remove(id) - } - } - - fun removeTask(task: DownloadObject) { - removeTask(task.downloadId) - } - - fun queryFirstTasks(filter: MediaDownloadSource): Map<Int, DownloadObject> { - val isPendingFilter = filter == MediaDownloadSource.PENDING - val tasks = mutableMapOf<Int, DownloadObject>() - - tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.downloadSource) }) - if (isPendingFilter) { - return tasks.toSortedMap(reverseOrder()) - } - - tasks.putAll(queryTasks( - from = tasks.values.lastOrNull()?.downloadId ?: Int.MAX_VALUE, - amount = 30, - filter = filter - )) - - return tasks.toSortedMap(reverseOrder()) - } - - @SuppressLint("Range") - fun queryTasks(from: Int, amount: Int = 30, filter: MediaDownloadSource = MediaDownloadSource.NONE): Map<Int, DownloadObject> { - if (filter == MediaDownloadSource.PENDING) { - return emptyMap() - } - - val cursor = taskDatabase.rawQuery( - "SELECT * FROM tasks WHERE id < ? AND downloadSource LIKE ? ORDER BY id DESC LIMIT ?", - arrayOf( - from.toString(), - if (filter.ignoreFilter) "%" else "%${filter.key}", - amount.toString() - ) - ) - - val result = sortedMapOf<Int, DownloadObject>() - - while (cursor.moveToNext()) { - val task = DownloadObject( - downloadId = cursor.getIntOrNull("id")!!, - outputFile = cursor.getStringOrNull("outputFile"), - metadata = DownloadMetadata( - outputPath = cursor.getStringOrNull("outputPath")!!, - mediaIdentifier = cursor.getStringOrNull("hash"), - downloadSource = cursor.getStringOrNull("downloadSource") - ?: MediaDownloadSource.NONE.key, - mediaAuthor = cursor.getStringOrNull("mediaAuthor"), - iconUrl = cursor.getStringOrNull("iconUrl") - ) - ).apply { - updateTaskCallback = { updateTask(it) } - downloadStage = DownloadStage.valueOf(cursor.getStringOrNull("downloadStage")!!) - //if downloadStage is not saved, it means the app was killed before the download was finished - if (downloadStage != DownloadStage.SAVED) { - downloadStage = DownloadStage.FAILED - } - } - result[task.downloadId] = task - } - cursor.close() - - return result.toSortedMap(reverseOrder()) - } - - fun removeAllTasks() { - executor.execute { - taskDatabase.execSQL("DELETE FROM tasks") - cachedTasks.clear() - pendingTasks.clear() - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.download import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegSession import com.arthenica.ffmpegkit.Level +import com.arthenica.ffmpegkit.Statistics import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.LogManager import me.rhunk.snapenhance.common.config.impl.DownloaderConfig @@ -35,7 +36,8 @@ class ArgumentList : LinkedHashMap<String, MutableList<String>>() { class FFMpegProcessor( private val logManager: LogManager, - private val ffmpegOptions: DownloaderConfig.FFMpegOptions + private val ffmpegOptions: DownloaderConfig.FFMpegOptions, + private val onStatistics: (Statistics) -> Unit = {} ) { companion object { private const val TAG = "ffmpeg-processor" @@ -88,7 +90,7 @@ class FFMpegProcessor( Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE else -> return@logFunction }, log.message) - }, { logManager.verbose(it.toString(), "ffmpeg-stats") }, Executors.newSingleThreadExecutor()) + }, { onStatistics(it) }, Executors.newSingleThreadExecutor()) } suspend fun execute(args: Request) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt @@ -0,0 +1,133 @@ +package me.rhunk.snapenhance.task + + +enum class TaskType( + val key: String +) { + DOWNLOAD("download"), + CHAT_ACTION("chat_action"); + + companion object { + fun fromKey(key: String): TaskType { + return entries.find { it.key == key } ?: throw IllegalArgumentException("Invalid key $key") + } + } +} + +enum class TaskStatus( + val key: String +) { + PENDING("pending"), + RUNNING("running"), + SUCCESS("success"), + FAILURE("failure"), + CANCELLED("cancelled"); + + fun isFinalStage(): Boolean { + return this == SUCCESS || this == FAILURE || this == CANCELLED + } + + companion object { + fun fromKey(key: String): TaskStatus { + return entries.find { it.key == key } ?: throw IllegalArgumentException("Invalid key $key") + } + } +} + +data class PendingTaskListener( + val onSuccess: () -> Unit = {}, + val onCancel: () -> Unit = {}, + val onProgress: (label: String?, progress: Int) -> Unit = { _, _ -> }, + val onStateChange: (status: TaskStatus) -> Unit = {}, +) + +data class Task( + val type: TaskType, + val title: String, + val hash: String +) { + var changeListener: () -> Unit = {} + + var extra: String? = null + set(value) { + field = value + changeListener() + } + var status: TaskStatus = TaskStatus.PENDING + set(value) { + field = value + changeListener() + } +} + +class PendingTask( + val task: Task +) { + private val listeners = mutableListOf<PendingTaskListener>() + + fun addListener(listener: PendingTaskListener) { + synchronized(listeners) { listeners.add(listener) } + } + + fun removeListener(listener: PendingTaskListener) { + synchronized(listeners) { listeners.remove(listener) } + } + + var status + get() = task.status; + set(value) { + task.status = value; + synchronized(listeners) { + listeners.forEach { it.onStateChange(value) } + } + } + + var progressLabel: String? = null + set(value) { + field = value + synchronized(listeners) { + listeners.forEach { it.onProgress(value, progress) } + } + } + + private var _progress = 0 + set(value) { + assert(value in 0..100 || value == -1) + field = value + } + + var progress get() = _progress + set(value) { + _progress = value + synchronized(listeners) { + listeners.forEach { it.onProgress(progressLabel, value) } + } + } + + fun updateProgress(label: String, progress: Int = -1) { + _progress = progress + progressLabel = label + } + + fun fail(reason: String) { + status = TaskStatus.FAILURE + synchronized(listeners) { + listeners.forEach { it.onCancel() } + } + updateProgress(reason) + } + + fun success() { + status = TaskStatus.SUCCESS + synchronized(listeners) { + listeners.forEach { it.onSuccess() } + } + } + + fun cancel() { + status = TaskStatus.CANCELLED + synchronized(listeners) { + listeners.forEach { it.onCancel() } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt @@ -0,0 +1,134 @@ +package me.rhunk.snapenhance.task + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import java.util.concurrent.Executors +import kotlin.coroutines.suspendCoroutine + +class TaskManager( + private val remoteSideContext: RemoteSideContext +) { + private lateinit var taskDatabase: SQLiteDatabase + private val queueExecutor = Executors.newSingleThreadExecutor() + + fun init() { + taskDatabase = remoteSideContext.androidContext.openOrCreateDatabase("tasks", Context.MODE_PRIVATE, null).apply { + SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf( + "tasks" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "hash VARCHAR UNIQUE", + "title VARCHAR(255) NOT NULL", + "type VARCHAR(255) NOT NULL", + "status VARCHAR(255) NOT NULL", + "extra TEXT" + ) + )) + } + } + + private val activeTasks = mutableMapOf<Long, PendingTask>() + + private fun readTaskFromCursor(cursor: android.database.Cursor): Task { + val task = Task(TaskType.fromKey(cursor.getStringOrNull("type")!!), cursor.getStringOrNull("title")!!, cursor.getStringOrNull("hash")!!) + task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!) + task.extra = cursor.getStringOrNull("extra") + task.changeListener = { + updateTask(cursor.getLong("id"), task) + } + return task + } + + private fun putNewTask(task: Task): Long { + return runBlocking { + suspendCoroutine { + queueExecutor.execute { + val result = taskDatabase.insert("tasks", null, ContentValues().apply { + put("type", task.type.key) + put("hash", task.hash) + put("title", task.title) + put("status", task.status.key) + put("extra", task.extra) + }) + it.resumeWith(Result.success(result)) + } + } + } + } + + private fun updateTask(id: Long, task: Task) { + queueExecutor.execute { + taskDatabase.execSQL("UPDATE tasks SET status = ?, extra = ? WHERE id = ?", + arrayOf( + task.status.key, + task.extra, + id.toString() + ) + ) + } + } + + fun clearAllTasks() { + runBlocking { + launch(queueExecutor.asCoroutineDispatcher()) { + taskDatabase.execSQL("DELETE FROM tasks") + } + } + } + + fun createPendingTask(task: Task): PendingTask { + val taskId = putNewTask(task) + task.changeListener = { + updateTask(taskId, task) + } + + val pendingTask = PendingTask(task) + activeTasks[taskId] = pendingTask + return pendingTask + } + + fun getTaskByHash(hash: String?): Task? { + if (hash == null) return null + taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor -> + if (cursor.moveToNext()) { + return readTaskFromCursor(cursor) + } + } + return null + } + + fun getActiveTasks() = activeTasks + + fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> { + val tasks = mutableMapOf<Long, Task>() + val invalidTasks = mutableListOf<Long>() + + taskDatabase.rawQuery("SELECT * FROM tasks WHERE id < ? ORDER BY id DESC LIMIT ?", arrayOf(lastId.toString(), limit.toString())).use { cursor -> + while (cursor.moveToNext()) { + runCatching { + val task = readTaskFromCursor(cursor) + if (!task.status.isFinalStage()) { task.status = TaskStatus.FAILURE } + tasks[cursor.getLong("id")] = task + }.onFailure { + invalidTasks.add(cursor.getLong("id")) + remoteSideContext.log.warn("Failed to read task ${cursor.getLong("id")}") + } + } + } + + invalidTasks.forEach { + queueExecutor.execute { + taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(it.toString())) + } + } + + return tasks + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt @@ -2,11 +2,7 @@ package me.rhunk.snapenhance.ui.manager import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DataObject -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Stars +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavController @@ -14,7 +10,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.ui.manager.sections.NotImplemented -import me.rhunk.snapenhance.ui.manager.sections.downloads.DownloadsSection +import me.rhunk.snapenhance.ui.manager.sections.TasksSection import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection import me.rhunk.snapenhance.ui.manager.sections.home.HomeSection import me.rhunk.snapenhance.ui.manager.sections.scripting.ScriptsSection @@ -26,10 +22,10 @@ enum class EnumSection( val icon: ImageVector, val section: KClass<out Section> = NotImplemented::class ) { - DOWNLOADS( - route = "downloads", - icon = Icons.Filled.Download, - section = DownloadsSection::class + TASKS( + route = "tasks", + icon = Icons.Filled.TaskAlt, + section = TasksSection::class ), FEATURES( route = "features", diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt @@ -0,0 +1,231 @@ +package me.rhunk.snapenhance.ui.manager.sections + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.task.PendingTask +import me.rhunk.snapenhance.task.PendingTaskListener +import me.rhunk.snapenhance.task.Task +import me.rhunk.snapenhance.task.TaskStatus +import me.rhunk.snapenhance.task.TaskType +import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.util.OnLifecycleEvent + +class TasksSection : Section() { + private lateinit var activeTasks: MutableMap<Long, PendingTask> + private lateinit var recentTasks: MutableList<Task> + + @Composable + override fun TopBarActions(rowScope: RowScope) { + var showConfirmDialog by remember { mutableStateOf(false) } + + IconButton(onClick = { + showConfirmDialog = true + }) { + Icon(Icons.Filled.Delete, contentDescription = "Clear all tasks") + } + + if (showConfirmDialog) { + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + title = { Text("Clear all tasks") }, + text = { Text("Are you sure you want to clear all tasks?") }, + confirmButton = { + Button( + onClick = { + context.taskManager.clearAllTasks() + recentTasks.clear() + activeTasks.toList().forEach { + runCatching { + it.second.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task ${it.first}", throwable) + } + activeTasks.remove(it.first) + } + context.taskManager.getActiveTasks().clear() + showConfirmDialog = false + } + ) { + Text("Yes") + } + }, + dismissButton = { + Button( + onClick = { + showConfirmDialog = false + } + ) { + Text("No") + } + } + ) + } + } + + @Composable + private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { + var taskStatus by remember { mutableStateOf(task.status) } + var taskProgressLabel by remember { mutableStateOf<String?>(null) } + var taskProgress by remember { mutableIntStateOf(-1) } + + val listener = remember { PendingTaskListener( + onStateChange = { + taskStatus = it + }, + onProgress = { label, progress -> + taskProgressLabel = label + taskProgress = progress + } + ).also { pendingTask?.addListener(it) }} + + DisposableEffect(Unit) { + onDispose { + pendingTask?.removeListener(listener) + } + } + + OutlinedCard(modifier = modifier) { + Row( + modifier = Modifier.padding(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(end = 15.dp) + ) { + when (task.type) { + TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") + TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") + } + } + Column( + modifier = Modifier.weight(1f), + ) { + Text(task.title, style = MaterialTheme.typography.bodyLarge) + Text(task.hash, style = MaterialTheme.typography.labelSmall) + Column( + modifier = Modifier.padding(top = 5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + if (taskStatus.isFinalStage()) { + if (taskStatus != TaskStatus.SUCCESS) { + Text("$taskStatus", style = MaterialTheme.typography.bodySmall) + } + } else { + taskProgressLabel?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + if (taskProgress != -1) { + LinearProgressIndicator( + progress = taskProgress.toFloat() / 100f, + strokeCap = StrokeCap.Round + ) + } else { + task.extra?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + Column { + if (pendingTask != null && !taskStatus.isFinalStage()) { + CircularProgressIndicator(modifier = Modifier.size(30.dp)) + } else { + when (taskStatus) { + TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary) + TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error) + TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error) + else -> {} + } + } + } + } + } + } + + @Preview + @Composable + override fun Content() { + val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() + activeTasks = remember { mutableStateMapOf() } + recentTasks = remember { mutableStateListOf() } + var lastFetchedTaskId: Long? by remember { mutableStateOf(null) } + + fun fetchNewRecentTasks() { + scope.launch(Dispatchers.IO) { + val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE) + if (tasks.isNotEmpty()) { + lastFetchedTaskId = tasks.keys.last() + recentTasks.addAll(tasks.filter { !activeTasks.containsKey(it.key) }.values) + } + } + } + + fun fetchActiveTasks() { + scope.launch { + activeTasks.clear() + activeTasks.putAll(context.taskManager.getActiveTasks()) + } + } + + SideEffect { + fetchActiveTasks() + } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + fetchActiveTasks() + } + else -> {} + } + } + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize() + ) { + item { + if (activeTasks.isEmpty() && recentTasks.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(Icons.Filled.CheckCircle, contentDescription = "No tasks", tint = MaterialTheme.colorScheme.primary) + Text("No tasks", style = MaterialTheme.typography.bodyLarge) + } + } + } + items(activeTasks.size) { index -> + val pendingTask = activeTasks.values.elementAt(index) + TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask) + } + items(recentTasks) { task -> + TaskCard(modifier = Modifier.padding(8.dp), task) + } + item { + Spacer(modifier = Modifier.height(20.dp)) + SideEffect { + fetchNewRecentTasks() + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -1,268 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.downloads - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.rememberAsyncImagePainter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.future.asCompletableFuture -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.common.data.FileType -import me.rhunk.snapenhance.common.data.download.MediaDownloadSource -import me.rhunk.snapenhance.download.DownloadObject -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.util.BitmojiImage -import me.rhunk.snapenhance.ui.util.ImageRequestHelper - -class DownloadsSection : Section() { - private val loadedDownloads = mutableStateOf(mapOf<Int, DownloadObject>()) - private var currentFilter = mutableStateOf(MediaDownloadSource.NONE) - - override fun onResumed() { - super.onResumed() - context.coroutineScope.launch { - loadByFilter(currentFilter.value) - } - } - - private fun loadByFilter(filter: MediaDownloadSource) { - this.currentFilter.value = filter - synchronized(loadedDownloads) { - loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter).toMutableMap() - } - } - - private fun removeTask(download: DownloadObject) { - synchronized(loadedDownloads) { - loadedDownloads.value = loadedDownloads.value.toMutableMap().also { - it.remove(download.downloadId) - } - context.downloadTaskManager.removeTask(download) - } - } - - private fun lazyLoadFromIndex(lastIndex: Int) { - synchronized(loadedDownloads) { - loadedDownloads.value = loadedDownloads.value.toMutableMap().also { - val lastVisible = loadedDownloads.value.values.elementAt(lastIndex) - it += context.downloadTaskManager.queryTasks( - from = lastVisible.downloadId, - filter = currentFilter.value - ) - } - } - } - - @Composable - private fun FilterList() { - var showMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showMenu = !showMenu}) { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null - ) - } - - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - MediaDownloadSource.entries.forEach { filter -> - DropdownMenuItem( - text = { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - modifier = Modifier.padding(end = 16.dp), - selected = (currentFilter.value == filter), - onClick = null - ) - Text(filter.displayName, modifier = Modifier.weight(1f)) - } - }, - onClick = { - context.coroutineScope.launch { - loadByFilter(filter) - showMenu = false - } - } - ) - } - } - } - - @Composable - override fun TopBarActions(rowScope: RowScope) { - FilterList() - } - - @Composable - private fun DownloadItem(download: DownloadObject) { - Card( - modifier = Modifier - .padding(6.dp) - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - ) { - Box(modifier = Modifier.height(100.dp)) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequestHelper.newDownloadPreviewImageRequest( - context.androidContext, - download.outputFile - ), - imageLoader = context.imageLoader, - filterQuality = FilterQuality.None, - ), - modifier = Modifier - .matchParentSize() - .blur(5.dp), - contentDescription = null, - contentScale = ContentScale.FillWidth - ) - - Row( - modifier = Modifier - .padding(start = 10.dp, end = 10.dp) - .fillMaxHeight(), - - verticalAlignment = Alignment.CenterVertically, - ){ - //info card - Row( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.background, - shape = MaterialTheme.shapes.medium - ) - .padding(15.dp), - verticalAlignment = Alignment.CenterVertically - ) { - BitmojiImage(context = context, url = download.metadata.iconUrl, size = 48) - Column( - modifier = Modifier - .padding(start = 10.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = MediaDownloadSource.fromKey(download.metadata.downloadSource).displayName, - overflow = TextOverflow.Ellipsis, - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - Text( - text = download.metadata.mediaAuthor ?: "", - overflow = TextOverflow.Ellipsis, - fontSize = 12.sp, - fontWeight = FontWeight.Light - ) - } - } - - //action buttons - Row( - modifier = Modifier - .padding(5.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - FilledIconButton( - onClick = { - removeTask(download) - }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError - ) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null - ) - } - //open - FilledIconButton(onClick = { - if (download.outputFile == null) return@FilledIconButton - val fileType = runCatching { - context.androidContext.contentResolver.openInputStream(Uri.parse(download.outputFile))?.use { input -> - FileType.fromInputStream(input) - } - }.getOrNull() ?: FileType.UNKNOWN - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(Uri.parse(download.outputFile), fileType.mimeType) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION - } - context.androidContext.startActivity(intent) - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null - ) - } - } - } - } - } - } - - @Composable - override fun Content() { - val scrollState = rememberLazyListState() - val scope = rememberCoroutineScope() - - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxSize() - ) { - items(loadedDownloads.value.size) { index -> - DownloadItem(loadedDownloads.value.values.elementAt(index)) - } - - item { - Spacer(Modifier.height(20.dp)) - if (loadedDownloads.value.isEmpty()) { - Text( - text = context.translation["manager.sections.downloads.empty_download_list"], - fontSize = 20.sp, - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), textAlign = TextAlign.Center - ) - } - LaunchedEffect(Unit) { - val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect - scope.launch(Dispatchers.IO) { - lazyLoadFromIndex(lastItemIndex) - }.asCompletableFuture().thenAccept { - scope.launch { - scrollState.animateScrollToItem(lastItemIndex) - } - } - } - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/LifecycleHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/LifecycleHelper.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner + +//https://stackoverflow.com/questions/66546962/jetpack-compose-how-do-i-refresh-a-screen-when-app-returns-to-foreground +@Composable +fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +}+ \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -23,7 +23,7 @@ "manager": { "routes": { - "downloads": "Downloads", + "tasks": "Tasks", "features": "Features", "home": "Home", "home_settings": "Settings", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt @@ -53,7 +53,7 @@ object RemoteMediaResolver { } } - fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }, resultCallback: (InputStream) -> Unit) { + fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }, resultCallback: (stream: InputStream, length: Long) -> Unit) { okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response -> if (!response.isSuccessful) { throw Throwable("invalid response ${response.code}") @@ -61,7 +61,8 @@ object RemoteMediaResolver { resultCallback( decryptionCallback( response.body.byteStream() - ) + ), + response.body.contentLength() ) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt @@ -122,8 +122,8 @@ class MessageExporter( runCatching { RemoteMediaResolver.downloadBoltMedia(protoMediaReference, decryptionCallback = { (attachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it) - }) { - it.use { inputStream -> + }) { downloadedInputStream, _ -> + downloadedInputStream.use { inputStream -> MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> val fileName = "${type}_${Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}" val bufferedInputStream = BufferedInputStream(splitInputStream)