commit c501682a2fd185f41eb8d117e28ddb2c1fccf210
parent cc59f9d060b685eda27ccb013673dc016270a906
Author: auth <64337177+authorisation@users.noreply.github.com>
Date:   Thu, 22 Jun 2023 00:57:51 +0200

fix: unique hash (#81)

* fix: media hash reference

* fix: download manager receiver
longToast -> shortToast

* fix: media downloader
sanitize file name
fix playlistUrl bug
fix dash download duration

---------

Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com>
Diffstat:
Mapp/src/main/assets/lang/en_US.json | 3+++
Mapp/src/main/kotlin/me/rhunk/snapenhance/Logger.kt | 2+-
Mapp/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt | 10+++++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt | 10++++++----
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt | 14++++++++++++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt | 30++++++++++++++++++++++++++++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt | 13+++++++------
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt | 6++++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
9 files changed, 150 insertions(+), 41 deletions(-)

diff --git a/app/src/main/assets/lang/en_US.json b/app/src/main/assets/lang/en_US.json @@ -87,6 +87,7 @@ "conversation_info": "\uD83D\uDC64 Conversation Info" }, "download_options": { + "allow_duplicate": "Allow duplicate downloads", "format_user_folder": "Create folder for each user", "format_hash": "Add a unique hash to the file path", "format_username": "Add the username to the file path", @@ -232,6 +233,8 @@ } }, "download_manager_receiver": { + "already_queued_toast": "Media already in queue!", + "already_downloaded_toast": "Media already downloaded!", "saved_toast": "Saved to {path}", "download_toast": "Downloading {path}...", "processing_toast": "Processing {path}...", diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt @@ -16,7 +16,7 @@ object Logger { } fun error(throwable: Throwable) { - Log.e(TAG, "",throwable) + Log.e(TAG, "", throwable) } fun error(message: Any?) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt @@ -115,8 +115,16 @@ enum class ConfigProperty( "download_options", ConfigCategory.MEDIA_MANAGEMENT, ConfigStateListValue( - listOf("format_user_folder", "format_hash", "format_date_time", "format_username", "merge_overlay"), + listOf( + "allow_duplicate", + "format_user_folder", + "format_hash", + "format_date_time", + "format_username", + "merge_overlay" + ), mutableMapOf( + "allow_duplicate" to false, "format_user_folder" to true, "format_hash" to true, "format_date_time" to true, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt @@ -13,7 +13,8 @@ class DownloadManagerClient ( private val outputPath: String, private val mediaDisplaySource: String?, private val mediaDisplayType: String?, - private val iconUrl: String? + private val iconUrl: String?, + private val uniqueHash: String? ) { private fun sendToBroadcastReceiver(bundle: Bundle) { val intent = Intent() @@ -32,10 +33,11 @@ class DownloadManagerClient ( putString("mediaDisplaySource", mediaDisplaySource) putString("mediaDisplayType", mediaDisplayType) putString("iconUrl", iconUrl) + putString("uniqueHash", uniqueHash) }.apply(extras)) } - fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long) { + fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) { sendToBroadcastReceiver( DownloadRequest( inputMedias = arrayOf(playlistUrl), @@ -44,8 +46,8 @@ class DownloadManagerClient ( ) ) { putBundle("dashOptions", Bundle().apply { - putLong("offsetTime", offsetTime) - putLong("duration", duration) + putString("offsetTime", offsetTime.toString()) + duration?.let { putString("duration", it.toString()) } }) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt @@ -128,7 +128,7 @@ class DownloadManagerReceiver : BroadcastReceiver() { if (!it.endsWith("/")) "$it/" else it } - longToast( + shortToast( translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", "")) ) @@ -255,9 +255,19 @@ class DownloadManagerReceiver : BroadcastReceiver() { this.context = context SharedContext.ensureInitialized(context) - val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) + SharedContext.downloadTaskManager.canDownloadMedia(downloadRequest.getUniqueHash())?.let { downloadStage -> + shortToast( + translation[if (downloadStage.isFinalStage) { + "already_downloaded_toast" + } else { + "already_queued_toast" + }] + ) + return + } + GlobalScope.launch(Dispatchers.IO) { val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -20,6 +20,7 @@ class DownloadTaskManager { SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf( "tasks" to listOf( "id INTEGER PRIMARY KEY AUTOINCREMENT", + "hash VARCHAR UNIQUE", "outputPath TEXT", "outputFile TEXT", "mediaDisplayType TEXT", @@ -32,8 +33,9 @@ class DownloadTaskManager { } fun addTask(task: PendingDownload): Int { - taskDatabase.execSQL("INSERT INTO tasks (outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?)", + taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", arrayOf( + task.uniqueHash, task.outputPath, task.outputFile, task.mediaDisplayType, @@ -51,8 +53,9 @@ class DownloadTaskManager { } fun updateTask(task: PendingDownload) { - taskDatabase.execSQL("UPDATE tasks SET outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", + taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", arrayOf( + task.uniqueHash, task.outputPath, task.outputFile, task.mediaDisplayType, @@ -71,6 +74,28 @@ class DownloadTaskManager { } } + @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() } @@ -127,6 +152,7 @@ class DownloadTaskManager { outputPath = cursor.getString(cursor.getColumnIndex("outputPath")), mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")), mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")), + uniqueHash = cursor.getString(cursor.getColumnIndex("hash")), iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) ).apply { downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage"))) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt @@ -19,7 +19,8 @@ class DownloadRequest( private val flags: Int = 0, private val dashOptions: Map<String, String?>? = null, private val mediaDisplaySource: String? = null, - private val mediaDisplayType: String? = null + private val mediaDisplayType: String? = null, + private val uniqueHash: String? = null ) { companion object { fun fromBundle(bundle: Bundle): DownloadRequest { @@ -39,7 +40,8 @@ class DownloadRequest( options.getString(key) } }, - flags = bundle.getInt("flags", 0) + flags = bundle.getInt("flags", 0), + uniqueHash = bundle.getString("uniqueHash") ) } } @@ -62,6 +64,7 @@ class DownloadRequest( } }) putInt("flags", flags) + putString("uniqueHash", uniqueHash) } } @@ -85,10 +88,6 @@ class DownloadRequest( } } - fun getInputMedia(index: Int): String? { - return inputMedias.getOrNull(index) - } - fun getInputMedias(): List<InputMedia> { return inputMedias.mapIndexed { index, uri -> InputMedia( @@ -102,4 +101,6 @@ class DownloadRequest( fun getInputType(index: Int): DownloadMediaType? { return inputTypes.getOrNull(index)?.let { DownloadMediaType.valueOf(it) } } + + fun getUniqueHash() = uniqueHash } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt @@ -13,7 +13,8 @@ data class PendingDownload( val outputPath: String, val mediaDisplayType: String?, val mediaDisplaySource: String?, - val iconUrl: String? + val iconUrl: String?, + val uniqueHash: String? ) { companion object { fun fromBundle(bundle: Bundle): PendingDownload { @@ -21,7 +22,8 @@ data class PendingDownload( outputPath = bundle.getString("outputPath")!!, mediaDisplayType = bundle.getString("mediaDisplayType"), mediaDisplaySource = bundle.getString("mediaDisplaySource"), - iconUrl = bundle.getString("iconUrl") + iconUrl = bundle.getString("iconUrl"), + uniqueHash = bundle.getString("uniqueHash") ) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -30,13 +30,14 @@ import me.rhunk.snapenhance.hook.HookAdapter import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.ui.download.MediaFilter +import me.rhunk.snapenhance.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.util.getObjectField +import me.rhunk.snapenhance.util.protobuf.ProtoReader +import me.rhunk.snapenhance.util.snap.BitmojiSelfie import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaType import me.rhunk.snapenhance.util.snap.PreviewUtils -import me.rhunk.snapenhance.util.getObjectField -import me.rhunk.snapenhance.util.protobuf.ProtoReader -import me.rhunk.snapenhance.util.snap.BitmojiSelfie import java.io.File import java.nio.file.Paths import java.text.SimpleDateFormat @@ -56,10 +57,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam private fun provideClientDownloadManager( pathSuffix: String, + mediaIdentifier: String, mediaDisplaySource: String? = null, mediaDisplayType: String? = null, friendInfo: FriendInfo? = null ): DownloadManagerClient { + val generatedHash = mediaIdentifier.hashCode().toString(16) + val iconUrl = friendInfo?.takeIf { it.bitmojiAvatarId != null && it.bitmojiSelfieId != null }?.let { @@ -68,7 +72,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam val outputPath = File( context.config.string(ConfigProperty.SAVE_FOLDER), - createNewFilePath(pathSuffix.hashCode(), pathSuffix) + createNewFilePath(generatedHash, pathSuffix) ).absolutePath return DownloadManagerClient( @@ -76,6 +80,11 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam mediaDisplaySource = mediaDisplaySource, mediaDisplayType = mediaDisplayType, iconUrl = iconUrl, + uniqueHash = + // If duplicate is allowed, we don't need to pass the hash + if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["allow_duplicate"] == false) { + generatedHash + } else null, outputPath = outputPath ) } @@ -85,9 +94,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam return isFFmpegPresent } - private fun createNewFilePath(hash: Int, pathPrefix: String): String { - val hexHash = Integer.toHexString(hash) + private fun createNewFilePath(hexHash: String, pathPrefix: String): String { val downloadOptions = context.config.options(ConfigProperty.DOWNLOAD_OPTIONS) + val sanitizedPathPrefix = pathPrefix + .replace(" ", "_") + .replace(Regex("[\\\\/:*?\"<>|]"), "") + .ifEmpty { hexHash } val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis()) @@ -102,13 +114,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } if (downloadOptions["format_user_folder"] == true) { - finalPath.append(pathPrefix).append("/") + finalPath.append(sanitizedPathPrefix).append("/") } if (downloadOptions["format_hash"] == true) { appendFileName(hexHash) } if (downloadOptions["format_username"] == true) { - appendFileName(pathPrefix) + appendFileName(sanitizedPathPrefix) } if (downloadOptions["format_date_time"] == true) { appendFileName(currentDateTime) @@ -186,7 +198,10 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam //messages paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || canAutoDownload("friend_snaps") }?.let { id -> val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() - val senderId: String = context.database.getConversationMessageFromId(messageId)!!.sender_id!! + val conversationMessage = context.database.getConversationMessageFromId(messageId)!! + + val senderId = conversationMessage.sender_id!! + val conversationId = conversationMessage.client_conversation_id!! if (!forceDownload && context.feature(AntiAutoDownload::class).isUserIgnored(senderId)) { return @@ -194,7 +209,15 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam val author = context.database.getFriendInfo(senderId) ?: return val authorUsername = author.usernameForSorting!! - downloadOperaMedia(provideClientDownloadManager(authorUsername, authorUsername, MediaFilter.CHAT_MEDIA.mediaDisplayType, friendInfo = author), mediaInfoMap) + + downloadOperaMedia(provideClientDownloadManager( + pathSuffix = authorUsername, + mediaIdentifier = "$conversationId$senderId$messageId", + mediaDisplaySource = authorUsername, + mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, + friendInfo = author + ), mediaInfoMap) + return } @@ -202,15 +225,20 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam paramMap["PLAYLIST_V2_GROUP"]?.toString()?.takeIf { it.contains("storyUserId=") && (forceDownload || canAutoDownload("friend_stories")) }?.let { playlistGroup -> - val storyIdStartIndex = playlistGroup.indexOf("storyUserId=") + 12 - val storyUserId = playlistGroup.substring( - storyIdStartIndex, - playlistGroup.indexOf(",", storyIdStartIndex) - ) + val storyUserId = (playlistGroup.indexOf("storyUserId=") + 12).let { + playlistGroup.substring(it, playlistGroup.indexOf(",", it)) + } + val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) ?: return val authorName = author.usernameForSorting!! - downloadOperaMedia(provideClientDownloadManager(authorName, authorName, MediaFilter.STORY.mediaDisplayType, friendInfo = author), mediaInfoMap, ) + downloadOperaMedia(provideClientDownloadManager( + pathSuffix = authorName, + mediaIdentifier = paramMap["MEDIA_ID"].toString(), + mediaDisplaySource = authorName, + mediaDisplayType = MediaFilter.STORY.mediaDisplayType, + friendInfo = author + ), mediaInfoMap) return } @@ -222,13 +250,24 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace( "[^\\x00-\\x7F]".toRegex(), "") - downloadOperaMedia(provideClientDownloadManager("Public-Stories/$userDisplayName", userDisplayName, "Public Story"), mediaInfoMap) + + downloadOperaMedia(provideClientDownloadManager( + pathSuffix = "Public-Stories/$userDisplayName", + mediaIdentifier = paramMap["SNAP_ID"].toString(), + mediaDisplayType = userDisplayName, + mediaDisplaySource = "Public Story" + ), mediaInfoMap) return } //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { - downloadOperaMedia(provideClientDownloadManager("Spotlight", mediaDisplayType = MediaFilter.SPOTLIGHT.mediaDisplayType, mediaDisplaySource = paramMap["TIME_STAMP"].toString()), mediaInfoMap) + downloadOperaMedia(provideClientDownloadManager( + pathSuffix = "Spotlight", + mediaIdentifier = paramMap["SNAP_ID"].toString(), + mediaDisplayType = MediaFilter.SPOTLIGHT.mediaDisplayType, + mediaDisplaySource = paramMap["TIME_STAMP"].toString() + ), mediaInfoMap) return } @@ -256,12 +295,24 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam //add 100ms to the start time to prevent the video from starting too early val snapChapterTimestamp = snapChapter.startTimeMs.plus(100) - val duration = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) ?: 0 + val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) //get the mpd playlist and append the cdn url to baseurl nodes - val playlistUrl = paramMap["MEDIA_ID"].toString().let { it.substring(it.indexOf("https://cf-st.sc-cdn.net")) } - val clientDownloadManager = provideClientDownloadManager("Pro-Stories/${storyName}", storyName, "Pro Story") - clientDownloadManager.downloadDashMedia( + 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) + } + } + + provideClientDownloadManager( + pathSuffix = "Pro-Stories/${storyName}", + mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}", + mediaDisplaySource = storyName, + mediaDisplayType = "Pro Story" + ).downloadDashMedia( playlistUrl, snapChapterTimestamp, duration @@ -384,7 +435,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam runCatching { if (!isPreviewMode) { val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) - provideClientDownloadManager(authorName, authorName, MediaFilter.CHAT_MEDIA.mediaDisplayType, friendInfo = friendInfo).downloadMedia( + provideClientDownloadManager( + pathSuffix = authorName, + mediaIdentifier = "${message.client_conversation_id}${message.sender_id}${message.client_message_id}", + mediaDisplaySource = authorName, + mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, + friendInfo = friendInfo + ).downloadMedia( Base64.UrlSafe.encode(urlProto), DownloadMediaType.PROTO_MEDIA, encryption = encryptionKeys?.toKeyPair()