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:
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 {