commit 94d58c4f46175f422becd0f740a327eec1453cac
parent eb803df196293d07ac4477fca6c2b0af080dd305
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Wed,  1 Nov 2023 16:45:31 +0100

feat: in-chat snap preview

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt | 1+
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt | 23+++++++++++++++--------
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt | 4++++
6 files changed, 114 insertions(+), 8 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -232,6 +232,10 @@ } } }, + "snap_preview": { + "name": "Snap Preview", + "description": "Displays a small preview next to unseen Snaps in chat" + }, "bootstrap_override": { "name": "Bootstrap Override", "description": "Overrides user interface bootstrap settings", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -26,6 +26,7 @@ class UserInterfaceTweaks : ConfigContainer() { val friendFeedMenuPosition = integer("friend_feed_menu_position", defaultValue = 1) val amoledDarkMode = boolean("amoled_dark_mode") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() } + val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() } val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt @@ -0,0 +1,88 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.graphics.* +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import java.io.File + +class SnapPreview : Feature("SnapPreview", loadParams = FeatureLoadParams.INIT_SYNC or FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val mediaFileCache = mutableMapOf<String, File>() // mMediaId => mediaFile + private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap + + private val isEnabled get() = context.config.userInterface.snapPreview.get() + + override fun init() { + if (!isEnabled) return + context.mappings.getMappedClass("callbacks", "ContentCallback").hook("handleContentResult", HookStage.BEFORE) { param -> + val contentResult = param.arg<Any>(0) + val classMethods = contentResult::class.java.methods + + val contentKey = classMethods.find { it.name == "getContentKey" }?.invoke(contentResult) ?: return@hook + if (contentKey.getObjectField("mMediaContextType").toString() != "CHAT") return@hook + + val filePath = classMethods.find { it.name == "getFilePath" }?.invoke(contentResult) ?: return@hook + val mediaId = contentKey.getObjectField("mMediaId").toString() + + mediaFileCache[mediaId.substringAfter("-")] = File(filePath.toString()) + } + } + + @SuppressLint("DiscouragedApi") + override fun onActivityCreate() { + if (!isEnabled) return + val chatMediaCardHeight = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_height", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + val chatMediaCardSnapMargin = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_snap_margin", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + val chatMediaCardSnapMarginStartSdl = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_snap_margin_start_sdl", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + + fun decodeMedia(file: File) = runCatching { + bitmapCache.getOrPut(file.absolutePath) { + PreviewUtils.resizeBitmap( + PreviewUtils.createPreviewFromFile(file) ?: return@runCatching null, + chatMediaCardHeight - chatMediaCardSnapMargin, + chatMediaCardHeight - chatMediaCardSnapMargin + ) + } + }.getOrNull() + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { _, messageId -> + event.view.removeForegroundDrawable("snapPreview") + + val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage + val messageReader = ProtoReader(message.messageContent ?: return@chatMessage) + val contentType = ContentType.fromMessageContainer(messageReader.followPath(4, 4)) + + if (contentType != ContentType.SNAP) return@chatMessage + + val mediaIdKey = messageReader.getString(4, 5, 1, 3, 2, 2) ?: return@chatMessage + + event.view.addForegroundDrawable("snapPreview", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + if (canvas.height / context.resources.displayMetrics.density > 90) return + val bitmap = mediaFileCache[mediaIdKey]?.let { decodeMedia(it) } ?: return + + canvas.drawBitmap(bitmap, + canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(), + (canvas.height - bitmap.height) / 2f, + null + ) + } + })) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -102,6 +102,7 @@ class FeatureManager( HideFriendFeedEntry::class, HideQuickAddFriendFeed::class, CallStartConfirmation::class, + SnapPreview::class, ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt @@ -8,6 +8,7 @@ import android.media.MediaDataSource import android.media.MediaMetadataRetriever import me.rhunk.snapenhance.common.data.FileType import java.io.File +import kotlin.math.max object PreviewUtils { fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { @@ -52,14 +53,20 @@ object PreviewUtils { } } - private fun resizeBitmap(bitmap: Bitmap, outWidth: Int, outHeight: Int): Bitmap? { - val scaleWidth = outWidth.toFloat() / bitmap.width - val scaleHeight = outHeight.toFloat() / bitmap.height - val matrix = Matrix() - matrix.postScale(scaleWidth, scaleHeight) - val resizedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false) - bitmap.recycle() - return resizedBitmap + fun resizeBitmap(source: Bitmap, outWidth: Int, outHeight: Int): Bitmap { + val sourceWidth = source.getWidth() + val sourceHeight = source.getHeight() + val scale = max(outWidth.toFloat() / sourceWidth, outHeight.toFloat() / sourceHeight) + + val dx = (outWidth - (scale * sourceWidth)) / 2F + val dy = (outHeight - (scale * sourceHeight)) / 2F + val dest = Bitmap.createBitmap(outWidth, outHeight, source.getConfig()) + val canvas = Canvas(dest) + canvas.drawBitmap(source, Matrix().apply { + postScale(scale, scale) + postTranslate(dx, dy) + }, null) + return dest } fun mergeBitmapOverlay(originalMedia: Bitmap, overlayLayer: Bitmap): Bitmap { diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt @@ -17,6 +17,10 @@ class CallbackMapper : AbstractClassMapper() { if (clazz.getClassName().endsWith("\$CppProxy")) return@filter false + // ignore dummy ContentCallback class + if (superclassName.endsWith("ContentCallback") && !clazz.methods.first { it.name == "<init>" }.parameterTypes.contains("Z")) + return@filter false + val superClass = getClass(clazz.superclass) ?: return@filter false !superClass.isFinal() }.map {