commit 5776d4411103691e0f09a2e902cb6e0591a8ccb5 parent ea6260463c8339800f252c7744f6bccc19c8ab55 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 11:50:42 +0200 fix(media_downloader): story voice note reply - refactor media author and download source - optimize download section Diffstat:
17 files changed, 361 insertions(+), 330 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -180,7 +180,7 @@ class LogManager( fun error(message: Any?, throwable: Throwable, tag: String = TAG) { internalLog(tag, LogLevel.ERROR, message) - internalLog(tag, LogLevel.ERROR, throwable) + internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) } fun info(message: Any?, tag: String = TAG) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -20,7 +20,7 @@ import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.config.ModConfig -import me.rhunk.snapenhance.core.download.DownloadTaskManager +import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.ui.manager.MainActivity diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.download + +import kotlinx.coroutines.Job +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.DownloadStage + +data class DownloadObject( + var downloadId: Int = 0, + var outputFile: String? = null, + val metadata : DownloadMetadata +) { + var job: Job? = null + + var changeListener = { _: DownloadStage, _: DownloadStage -> } + lateinit var updateTaskCallback: (DownloadObject) -> Unit + + private var _stage: DownloadStage = DownloadStage.PENDING + var downloadStage: DownloadStage + get() = synchronized(this) { + _stage + } + set(value) = synchronized(this) { + changeListener(_stage, value) + _stage = value + updateTaskCallback(this) + } + + fun isJobActive() = job?.isActive == true + + fun cancel() { + downloadStage = DownloadStage.CANCELLED + job?.cancel() + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -16,14 +16,12 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.core.download.data.DownloadMediaType import me.rhunk.snapenhance.core.download.data.DownloadMetadata -import me.rhunk.snapenhance.core.download.data.DownloadObject import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.InputMedia @@ -320,7 +318,11 @@ class DownloadProcessor ( val downloadObjectObject = DownloadObject( metadata = downloadMetadata - ).apply { downloadTaskManager = remoteSideContext.downloadTaskManager } + ).apply { + updateTaskCallback = { + remoteSideContext.downloadTaskManager.updateTask(it) + } + } downloadObjectObject.also { remoteSideContext.downloadTaskManager.addTask(it) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -0,0 +1,181 @@ +package me.rhunk.snapenhance.download + +import android.annotation.SuppressLint +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.DownloadStage +import me.rhunk.snapenhance.core.download.data.MediaDownloadSource +import me.rhunk.snapenhance.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.util.ktx.getIntOrNull +import me.rhunk.snapenhance.util.ktx.getStringOrNull + +class DownloadTaskManager { + private lateinit var taskDatabase: SQLiteDatabase + private val pendingTasks = mutableMapOf<Int, DownloadObject>() + private val cachedTasks = mutableMapOf<Int, DownloadObject>() + + @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): Int { + 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 + return task.downloadId + } + + fun updateTask(task: DownloadObject) { + 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) { + 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() { + taskDatabase.execSQL("DELETE FROM tasks") + cachedTasks.clear() + pendingTasks.clear() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -42,6 +42,7 @@ 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 @@ -49,27 +50,42 @@ 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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch +import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.core.download.data.DownloadObject -import me.rhunk.snapenhance.core.download.data.MediaFilter +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(MediaFilter.NONE) + private var currentFilter = mutableStateOf(MediaDownloadSource.NONE) + private val coroutineScope = CoroutineScope(Dispatchers.IO) override fun onResumed() { super.onResumed() - loadByFilter(currentFilter.value) + coroutineScope.launch { + loadByFilter(currentFilter.value) + } } - private fun loadByFilter(filter: MediaFilter) { + private fun loadByFilter(filter: MediaDownloadSource) { this.currentFilter.value = filter synchronized(loadedDownloads) { - loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter) + 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) } } @@ -87,7 +103,6 @@ class DownloadsSection : Section() { @Composable private fun FilterList() { - val coroutineScope = rememberCoroutineScope() var showMenu by remember { mutableStateOf(false) } IconButton(onClick = { showMenu = !showMenu}) { Icon( @@ -97,7 +112,7 @@ class DownloadsSection : Section() { } DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - MediaFilter.values().toList().forEach { filter -> + MediaDownloadSource.values().toList().forEach { filter -> DropdownMenuItem( text = { Row( @@ -110,7 +125,7 @@ class DownloadsSection : Section() { selected = (currentFilter.value == filter), onClick = null ) - Text(filter.name, modifier = Modifier.weight(1f)) + Text(filter.displayName, modifier = Modifier.weight(1f)) } }, onClick = { @@ -144,11 +159,12 @@ class DownloadsSection : Section() { context.androidContext, download.outputFile ), - imageLoader = context.imageLoader + imageLoader = context.imageLoader, + filterQuality = FilterQuality.None, ), modifier = Modifier .matchParentSize() - .blur(12.dp), + .blur(5.dp), contentDescription = null, contentScale = ContentScale.FillWidth ) @@ -156,9 +172,9 @@ class DownloadsSection : Section() { Row( modifier = Modifier .padding(start = 10.dp, end = 10.dp) - .fillMaxWidth() .fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically + + verticalAlignment = Alignment.CenterVertically, ){ //info card Row( @@ -177,13 +193,13 @@ class DownloadsSection : Section() { verticalArrangement = Arrangement.SpaceBetween ) { Text( - text = download.metadata.mediaDisplayType ?: "", + text = MediaDownloadSource.fromKey(download.metadata.downloadSource).displayName, overflow = TextOverflow.Ellipsis, fontSize = 16.sp, fontWeight = FontWeight.Bold ) Text( - text = download.metadata.mediaDisplaySource ?: "", + text = download.metadata.mediaAuthor ?: "", overflow = TextOverflow.Ellipsis, fontSize = 12.sp, fontWeight = FontWeight.Light @@ -191,16 +207,17 @@ class DownloadsSection : Section() { } } - Spacer(modifier = Modifier.weight(1f)) - //action buttons Row( modifier = Modifier - .padding(5.dp), + .padding(5.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { FilledIconButton( onClick = { + removeTask(download) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.error, @@ -240,6 +257,7 @@ class DownloadsSection : Section() { @Composable override fun Content() { val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() LazyColumn( state = scrollState, @@ -252,14 +270,19 @@ class DownloadsSection : Section() { item { Spacer(Modifier.height(20.dp)) if (loadedDownloads.value.isEmpty()) { - Text(text = "No downloads", fontSize = 20.sp, modifier = Modifier + Text(text = "(empty)", fontSize = 20.sp, modifier = Modifier .fillMaxWidth() .padding(10.dp), textAlign = TextAlign.Center) } - LaunchedEffect(true) { + LaunchedEffect(Unit) { val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect - lazyLoadFromIndex(lastItemIndex) - scrollState.animateScrollToItem(lastItemIndex) + scope.launch(Dispatchers.IO) { + lazyLoadFromIndex(lastItemIndex) + }.asCompletableFuture().thenAccept { + scope.launch { + scrollState.animateScrollToItem(lastItemIndex) + } + } } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt @@ -53,6 +53,8 @@ object ImageRequestHelper { fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context) .data(filePath) .cacheKey(filePath) + .memoryCacheKey(filePath) .crossfade(true) + .crossfade(200) .build() } \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -388,11 +388,12 @@ "conversation_info": "\uD83D\uDC64 Conversation Info" }, "path_format": { - "create_user_folder": "Create folder for each user", + "create_author_folder": "Create folder for each author", + "create_source_folder": "Create folder for each media source type", "append_hash": "Add a unique hash to the file name", + "append_source": "Add the media source to the file name", "append_username": "Add the username to the file name", - "append_date_time": "Add the date and time to the file name", - "append_type": "Add the media type to the file name" + "append_date_time": "Add the date and time to the file name" }, "auto_download_sources": { "friend_snaps": "Friend Snaps", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -14,11 +14,12 @@ class DownloaderConfig : ConfigContainer() { ) val preventSelfAutoDownload = boolean("prevent_self_auto_download") val pathFormat = multiple("path_format", - "create_user_folder", + "create_author_folder", + "create_source_folder", "append_hash", + "append_source", + "append_username", "append_date_time", - "append_type", - "append_username" ).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) } val allowDuplicate = boolean("allow_duplicate") val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt @@ -1,182 +0,0 @@ -package me.rhunk.snapenhance.core.download - -import android.annotation.SuppressLint -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.core.download.data.DownloadMetadata -import me.rhunk.snapenhance.core.download.data.DownloadObject -import me.rhunk.snapenhance.core.download.data.DownloadStage -import me.rhunk.snapenhance.core.download.data.MediaFilter -import me.rhunk.snapenhance.util.SQLiteDatabaseHelper -import me.rhunk.snapenhance.util.ktx.getIntOrNull -import me.rhunk.snapenhance.util.ktx.getStringOrNull - -class DownloadTaskManager { - private lateinit var taskDatabase: SQLiteDatabase - private val pendingTasks = mutableMapOf<Int, DownloadObject>() - private val cachedTasks = mutableMapOf<Int, DownloadObject>() - - @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", - "mediaDisplayType TEXT", - "mediaDisplaySource TEXT", - "iconUrl TEXT", - "downloadStage TEXT" - ) - )) - } - } - - fun addTask(task: DownloadObject): Int { - taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", - arrayOf( - task.metadata.mediaIdentifier, - task.metadata.outputPath, - task.outputFile, - task.metadata.mediaDisplayType, - task.metadata.mediaDisplaySource, - 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 - return task.downloadId - } - - fun updateTask(task: DownloadObject) { - taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", - arrayOf( - task.metadata.mediaIdentifier, - task.metadata.outputPath, - task.outputFile, - task.metadata.mediaDisplayType, - task.metadata.mediaDisplaySource, - 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) { - 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: MediaFilter): Map<Int, DownloadObject> { - val isPendingFilter = filter == MediaFilter.PENDING - val tasks = mutableMapOf<Int, DownloadObject>() - - tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) }) - 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: MediaFilter = MediaFilter.NONE): Map<Int, DownloadObject> { - if (filter == MediaFilter.PENDING) { - return emptyMap() - } - - val cursor = taskDatabase.rawQuery( - "SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?", - arrayOf( - from.toString(), - if (filter.shouldIgnoreFilter) "%" 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"), - mediaDisplayType = cursor.getStringOrNull("mediaDisplayType"), - mediaDisplaySource = cursor.getStringOrNull("mediaDisplaySource"), - iconUrl = cursor.getStringOrNull("iconUrl") - ) - ).apply { - downloadTaskManager = this@DownloadTaskManager - 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() { - taskDatabase.execSQL("DELETE FROM tasks") - cachedTasks.clear() - pendingTasks.clear() - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.download.data data class DownloadMetadata( val mediaIdentifier: String?, val outputPath: String, - val mediaDisplaySource: String?, - val mediaDisplayType: String?, + val mediaAuthor: String?, + val downloadSource: String, val iconUrl: String? ) \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt @@ -1,32 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -import kotlinx.coroutines.Job -import me.rhunk.snapenhance.core.download.DownloadTaskManager - -data class DownloadObject( - var downloadId: Int = 0, - var outputFile: String? = null, - val metadata : DownloadMetadata -) { - lateinit var downloadTaskManager: DownloadTaskManager - var job: Job? = null - - var changeListener = { _: DownloadStage, _: DownloadStage -> } - private var _stage: DownloadStage = DownloadStage.PENDING - var downloadStage: DownloadStage - get() = synchronized(this) { - _stage - } - set(value) = synchronized(this) { - changeListener(_stage, value) - _stage = value - downloadTaskManager.updateTask(this) - } - - fun isJobActive() = job?.isActive == true - - fun cancel() { - downloadStage = DownloadStage.CANCELLED - job?.cancel() - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaDownloadSource.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaDownloadSource.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.core.download.data + +enum class MediaDownloadSource( + val key: String, + val displayName: String = key, + val pathName: String = key, + val ignoreFilter: Boolean = false +) { + NONE("none", "None", ignoreFilter = true), + PENDING("pending", "Pending", ignoreFilter = true), + CHAT_MEDIA("chat_media", "Chat Media", "chat_media"), + STORY("story", "Story", "story"), + PUBLIC_STORY("public_story", "Public Story", "public_story"), + SPOTLIGHT("spotlight", "Spotlight", "spotlight"), + PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"); + + fun matches(source: String?): Boolean { + if (source == null) return false + return source.contains(key, ignoreCase = true) + } + + companion object { + fun fromKey(key: String?): MediaDownloadSource { + if (key == null) return NONE + return values().find { it.key == key } ?: NONE + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -enum class MediaFilter( - val key: String, - val shouldIgnoreFilter: Boolean = false -) { - NONE("none", true), - PENDING("pending", true), - CHAT_MEDIA("chat_media"), - STORY("story"), - SPOTLIGHT("spotlight"), - PROFILE_PICTURE("profile_picture"); - - fun matches(source: String?): Boolean { - if (source == null) return false - return source.contains(key, ignoreCase = true) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -12,7 +12,7 @@ import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.core.download.data.DownloadMediaType import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.InputMedia -import me.rhunk.snapenhance.core.download.data.MediaFilter +import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.download.data.toKeyPair import me.rhunk.snapenhance.core.messaging.MessagingRuleType @@ -45,16 +45,20 @@ import kotlin.coroutines.suspendCoroutine import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +private fun String.sanitizeForPath(): String { + return this.replace(" ", "_") + .replace(Regex("\\p{Cntrl}"), "") +} + @OptIn(ExperimentalEncodingApi::class) class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null private var lastSeenMapParams: ParamMap? = null private fun provideDownloadManagerClient( - pathSuffix: String, mediaIdentifier: String, - mediaDisplaySource: String? = null, - mediaDisplayType: String? = null, + mediaAuthor: String, + downloadSource: MediaDownloadSource, friendInfo: FriendInfo? = null ): DownloadManagerClient { val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "") @@ -66,7 +70,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp context.shortToast(context.translation["download_processor.download_started_toast"]) } - val outputPath = createNewFilePath(generatedHash, mediaDisplayType, pathSuffix) + val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor) return DownloadManagerClient( context = context, @@ -74,8 +78,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) { generatedHash } else null, - mediaDisplaySource = mediaDisplaySource, - mediaDisplayType = mediaDisplayType, + mediaAuthor = mediaAuthor, + downloadSource = downloadSource.key, iconUrl = iconUrl, outputPath = outputPath ), @@ -106,13 +110,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } - //TODO: implement subfolder argument - private fun createNewFilePath(hexHash: String, mediaDisplayType: String?, pathPrefix: String): String { + private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): String { val pathFormat by context.config.downloader.pathFormat - val sanitizedPathPrefix = pathPrefix - .replace(" ", "_") - .replace(Regex("[\\p{Cntrl}]"), "") - .ifEmpty { hexHash } + val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash } val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis()) @@ -126,19 +126,20 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } - if (pathFormat.contains("create_user_folder")) { - finalPath.append(sanitizedPathPrefix).append("/") + if (pathFormat.contains("create_author_folder")) { + finalPath.append(sanitizedMediaAuthor).append("/") + } + if (pathFormat.contains("create_source_folder")) { + finalPath.append(downloadSource.pathName).append("/") } if (pathFormat.contains("append_hash")) { appendFileName(hexHash) } - mediaDisplayType?.let { - if (pathFormat.contains("append_type")) { - appendFileName(it.lowercase().replace(" ", "-")) - } + if (pathFormat.contains("append_source")) { + appendFileName(downloadSource.pathName) } if (pathFormat.contains("append_username")) { - appendFileName(sanitizedPathPrefix) + appendFileName(sanitizedMediaAuthor) } if (pathFormat.contains("append_date_time")) { appendFileName(currentDateTime) @@ -235,10 +236,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val authorUsername = author.usernameForSorting!! downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = authorUsername, mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}", - mediaDisplaySource = authorUsername, - mediaDisplayType = MediaFilter.CHAT_MEDIA.key, + mediaAuthor = authorUsername, + downloadSource = MediaDownloadSource.CHAT_MEDIA, friendInfo = author ), mediaInfoMap) @@ -278,10 +278,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp if (!forceDownload && !canUseRule(author.userId!!)) return downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = authorName, mediaIdentifier = paramMap["MEDIA_ID"].toString(), - mediaDisplaySource = authorName, - mediaDisplayType = MediaFilter.STORY.key, + mediaAuthor = authorName, + downloadSource = MediaDownloadSource.STORY, friendInfo = author ), mediaInfoMap) return @@ -292,15 +291,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //public stories if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") && (forceDownload || canAutoDownload("public_stories"))) { - val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace( - "[\\p{Cntrl}]".toRegex(), - "") + val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").sanitizeForPath() downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = "Public-Stories/$userDisplayName", mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaDisplayType = userDisplayName, - mediaDisplaySource = "Public Story" + mediaAuthor = userDisplayName, + downloadSource = MediaDownloadSource.PUBLIC_STORY, ), mediaInfoMap) return } @@ -308,10 +304,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = "Spotlight", mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaDisplayType = MediaFilter.SPOTLIGHT.key, - mediaDisplaySource = paramMap["TIME_STAMP"].toString() + downloadSource = MediaDownloadSource.SPOTLIGHT, + mediaAuthor = paramMap["TIME_STAMP"].toString() ), mediaInfoMap) return } @@ -319,9 +314,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //stories with mpeg dash media //TODO: option to download multiple chapters if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { - val storyName = paramMap["STORY_NAME"].toString().replace( - "[\\p{Cntrl}]".toRegex(), - "") + val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath() //get the position of the media in the playlist and the duration val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) @@ -338,20 +331,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) //get the mpd playlist and append the cdn url to baseurl nodes + context.log.verbose("Downloading dash media ${paramMap["MEDIA_ID"].toString()}", featureKey) val playlistUrl = paramMap["MEDIA_ID"].toString().let { - val urlIndex = it.indexOf("https://cf-st.sc-cdn.net") - if (urlIndex == -1) { - "${RemoteMediaResolver.CF_ST_CDN_D}$it" - } else { - it.substring(urlIndex) - } + val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) + + urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> + it.substring(validIndex) + } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" } provideDownloadManagerClient( - pathSuffix = "Pro-Stories/${storyName}", mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}", - mediaDisplaySource = storyName, - mediaDisplayType = "Pro Story" + downloadSource = MediaDownloadSource.PUBLIC_STORY, + mediaAuthor = storyName ).downloadDashMedia( playlistUrl, snapChapterTimestamp, @@ -476,10 +468,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp if (!isPreview) { val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) provideDownloadManagerClient( - pathSuffix = authorName, mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}", - mediaDisplaySource = authorName, - mediaDisplayType = MediaFilter.CHAT_MEDIA.key, + downloadSource = MediaDownloadSource.CHAT_MEDIA, + mediaAuthor = authorName, friendInfo = friendInfo ).downloadSingleMedia( Base64.UrlSafe.encode(urlProto), @@ -532,10 +523,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp fun downloadProfilePicture(url: String, author: String) { provideDownloadManagerClient( - pathSuffix = "Profile Pictures", mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), - mediaDisplaySource = author, - mediaDisplayType = MediaFilter.PROFILE_PICTURE.key + mediaAuthor = author, + downloadSource = MediaDownloadSource.PROFILE_PICTURE ).downloadSingleMedia( url, DownloadMediaType.REMOTE_MEDIA diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt @@ -12,13 +12,13 @@ import javax.crypto.spec.SecretKeySpec object EncryptionHelper { fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? { - val messageMediaInfo = MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo) ?: return null - val encryptionProtoIndex = if (messageMediaInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) { + val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo(messageProto, contentType, isArroyo) ?: return null + val encryptionProtoIndex = if (mediaEncryptionInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) { Constants.ENCRYPTION_PROTO_INDEX_V2 } else { Constants.ENCRYPTION_PROTO_INDEX } - val encryptionProto = messageMediaInfo.followPath(encryptionProtoIndex) ?: return null + val encryptionProto = mediaEncryptionInfo.followPath(encryptionProtoIndex) ?: return null var key: ByteArray = encryptionProto.getByteArray(1)!! var iv: ByteArray = encryptionProto.getByteArray(2)!! diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -18,7 +18,7 @@ import java.util.zip.ZipInputStream object MediaDownloaderHelper { - fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { + fun getMessageMediaEncryptionInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { val messageContainerPath = if (isArroyo) protoReader.followPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1) @@ -27,12 +27,13 @@ object MediaDownloaderHelper { ContentType.SNAP -> messageContainerPath.followPath(*(intArrayOf(11) + mediaContainerPath)) ContentType.EXTERNAL_MEDIA -> { val externalMediaTypes = arrayOf( - intArrayOf(3, 3), //normal external media - intArrayOf(7, 12, 3), //attached story reply - intArrayOf(7, 3) //original story reply + intArrayOf(3, 3, *mediaContainerPath), //normal external media + intArrayOf(7, 15, 1, 1), //attached audio note + intArrayOf(7, 12, 3, *mediaContainerPath), //attached story reply + intArrayOf(7, 3, *mediaContainerPath), //original story reply ) externalMediaTypes.forEach { path -> - messageContainerPath.followPath(*(path + mediaContainerPath))?.also { return it } + messageContainerPath.followPath(*path)?.also { return it } } null }