feat: task section
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
133
app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt
Normal file
133
app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
134
app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt
Normal file
134
app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
"manager": {
|
||||
"routes": {
|
||||
"downloads": "Downloads",
|
||||
"tasks": "Tasks",
|
||||
"features": "Features",
|
||||
"home": "Home",
|
||||
"home_settings": "Settings",
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user