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:
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt | 87+++++++++++++++++++++++++++----------------------------------------------------
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt | 1+
Amapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FoldingLayoutMapper.kt | 36++++++++++++++++++++++++++++++++++++
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