feat: in-chat snap preview

This commit is contained in:
rhunk
2023-11-01 16:45:31 +01:00
parent eb803df196
commit 94d58c4f46
6 changed files with 113 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@ -102,6 +102,7 @@ class FeatureManager(
HideFriendFeedEntry::class,
HideQuickAddFriendFeed::class,
CallStartConfirmation::class,
SnapPreview::class,
)
initializeFeatures()

View File

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

View File

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