feat: task section

This commit is contained in:
rhunk
2023-10-29 02:17:58 +02:00
parent 31aa7151ec
commit bbcaab4e75
14 changed files with 643 additions and 588 deletions

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}
}

View File

@@ -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) {

View 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() }
}
}
}

View 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
}
}

View File

@@ -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",

View File

@@ -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()
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -23,7 +23,7 @@
"manager": {
"routes": {
"downloads": "Downloads",
"tasks": "Tasks",
"features": "Features",
"home": "Home",
"home_settings": "Settings",

View File

@@ -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()
)
}
}

View File

@@ -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)