commit 4e4f77a21323907b612979904495aa93cada7af3
parent 0f2edca45959d9b441f9b24ea186ed2a93d55505
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Fri, 4 Jul 2025 01:28:33 +0200
fix: prevent message list auto scroll
Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
Diffstat:
3 files changed, 67 insertions(+), 57 deletions(-)
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt
@@ -1,79 +1,52 @@
package me.rhunk.snapenhance.core.features.impl.tweaks
-import android.view.View
-import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
-import me.rhunk.snapenhance.core.event.events.impl.ConversationUpdateEvent
+import android.util.SparseIntArray
+import androidx.core.util.size
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
-import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
+import me.rhunk.snapenhance.core.util.ktx.getObjectField
+import me.rhunk.snapenhance.core.util.ktx.setObjectField
+import me.rhunk.snapenhance.mapper.impl.FoldingLayoutMapper
+
class PreventMessageListAutoScroll : Feature("PreventMessageListAutoScroll") {
- private var openedConversationId: String? = null
- private val focusedMessages = mutableMapOf<View, Long>()
- private var firstFocusedMessageId: Long? = null
- private val delayedMessageUpdates = mutableListOf<() -> Unit>()
+ companion object {
+ private const val MIN_SCROLL_ITEMS = 4
+ }
override fun init() {
if (!context.config.userInterface.preventMessageListAutoScroll.get()) return
- onNextActivityCreate {
- context.event.subscribe(ConversationUpdateEvent::class) { event ->
- val updatedMessage = event.messages.firstOrNull() ?: return@subscribe
- if (openedConversationId != updatedMessage.messageDescriptor?.conversationId.toString()) return@subscribe
+ val foldingLayoutManager = findClass("com.snap.messaging.chat.features.messagelist.FoldingLayoutManager")
+ val recyclerViewClass = findClass("androidx.recyclerview.widget.RecyclerView")
- // cancel if the message is already in focus
- if (focusedMessages.entries.any { entry -> entry.value == updatedMessage.messageDescriptor?.messageId && entry.key.isAttachedToWindow }) return@subscribe
+ val computeVerticalScrollOffsetMethod = recyclerViewClass.getMethod("computeVerticalScrollOffset")
+ val computeVerticalScrollExtentMethod = recyclerViewClass.getMethod("computeVerticalScrollExtent")
+ val computeVerticalScrollRangeMethod = recyclerViewClass.getMethod("computeVerticalScrollRange")
- val conversationLastMessages = context.database.getMessagesFromConversationId(
- openedConversationId.toString(),
- 4
- ) ?: return@subscribe
+ context.mappings.useMapper(FoldingLayoutMapper::class) {
+ foldingLayoutManager.hook(onLayoutCompletedMethod.getAsString() ?: throw NoSuchMethodError("onLayoutCompleted"), HookStage.BEFORE) { param ->
+ val instance = param.thisObject<Any>()
+ val shouldScrollToBottom = instance.getObjectField(shouldScrollToBottomField.getAsString()!!) as Boolean
- if (conversationLastMessages.none {
- focusedMessages.entries.any { entry -> entry.value == it.clientMessageId.toLong() && entry.key.isAttachedToWindow }
- }) {
- synchronized(delayedMessageUpdates) {
- if (firstFocusedMessageId == null) firstFocusedMessageId = conversationLastMessages.lastOrNull()?.clientMessageId?.toLong()
- delayedMessageUpdates.add {
- event.adapter.invokeOriginal()
- }
- }
- event.adapter.setResult(null)
- }
- }
+ if (shouldScrollToBottom) {
+ val sparseIntArray = param.thisObject<Any>().getObjectField(sizeSparseArrayField.getAsString()!!) as SparseIntArray
+ val recyclerView = param.thisObject<Any>().getObjectField(recyclerViewField.getAsString()!!)
- context.classCache.conversationManager.apply {
- hook("enterConversation", HookStage.BEFORE) { param ->
- openedConversationId = SnapUUID(param.arg(0)).toString()
- }
- hook("exitConversation", HookStage.BEFORE) {
- openedConversationId = null
- firstFocusedMessageId = null
- synchronized(focusedMessages) {
- focusedMessages.clear()
- }
- synchronized(delayedMessageUpdates) {
- delayedMessageUpdates.clear()
- }
- }
- }
+ val scrollOffset = computeVerticalScrollRangeMethod.invoke(recyclerView) as Int - (computeVerticalScrollOffsetMethod.invoke(recyclerView) as Int + computeVerticalScrollExtentMethod.invoke(recyclerView) as Int)
- context.event.subscribe(BindViewEvent::class) { event ->
- event.chatMessage { conversationId, messageId ->
- if (conversationId != openedConversationId) return@chatMessage
- synchronized(focusedMessages) {
- focusedMessages[event.view] = messageId.toLong()
- }
+ var layoutSizeSum = 0
- if (delayedMessageUpdates.isNotEmpty() && focusedMessages.entries.any { entry -> entry.value == firstFocusedMessageId && entry.key.isAttachedToWindow }) {
- delayedMessageUpdates.apply {
- synchronized(this) {
- removeIf { it(); true }
- firstFocusedMessageId = null
- }
+ for (i in 0 until sparseIntArray.size) {
+ if (sparseIntArray.keyAt(i) < MIN_SCROLL_ITEMS) {
+ layoutSizeSum += sparseIntArray.valueAt(i)
}
}
+
+ if (scrollOffset <= 0 || scrollOffset > layoutSizeSum) {
+ instance.setObjectField(shouldScrollToBottomField.getAsString()!!, false)
+ }
}
}
}
diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt
@@ -38,6 +38,7 @@ class ClassMapper(
MemoriesPresenterMapper(),
StreaksExpirationMapper(),
COFObservableMapper(),
+ FoldingLayoutMapper(),
)
}
diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FoldingLayoutMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FoldingLayoutMapper.kt
@@ -0,0 +1,35 @@
+package me.rhunk.snapenhance.mapper.impl
+
+import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c
+import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c
+import com.android.tools.smali.dexlib2.iface.reference.FieldReference
+import com.android.tools.smali.dexlib2.iface.reference.MethodReference
+import me.rhunk.snapenhance.mapper.AbstractClassMapper
+
+class FoldingLayoutMapper: AbstractClassMapper("FoldingLayoutMapper") {
+ val onLayoutCompletedMethod = string("onLayoutCompletedMethod")
+ val shouldScrollToBottomField = string("shouldScrollToBottomField")
+ val sizeSparseArrayField = string("sizeSparseArrayField")
+ val recyclerViewField = string("recyclerViewField")
+
+ init {
+ mapper {
+ val foldingLayoutManagerClass = getClass("Lcom/snap/messaging/chat/features/messagelist/FoldingLayoutManager;") ?: return@mapper
+
+ sizeSparseArrayField.set(foldingLayoutManagerClass.fields.firstOrNull { it.type == "Landroid/util/SparseIntArray;" }?.name ?: return@mapper)
+ recyclerViewField.set(foldingLayoutManagerClass.fields.firstOrNull { it.type == "Landroidx/recyclerview/widget/RecyclerView;" }?.name ?: return@mapper)
+
+ foldingLayoutManagerClass.methods.firstOrNull {
+ it.parameterTypes.size == 1 && it.returnType == "V" && it.implementation?.instructions?.any {
+ ((it as? Instruction35c)?.reference as? MethodReference)?.name == "invoke"
+ } == true
+ }?.let { method ->
+ onLayoutCompletedMethod.set(method.name)
+ for (instruction in method.implementation?.instructions ?: return@mapper) {
+ shouldScrollToBottomField.set(((instruction as? Instruction22c)?.reference as? FieldReference)?.takeIf { it.type == "Z" }?.name ?: continue)
+ break
+ }
+ }
+ }
+ }
+}+
\ No newline at end of file