Fix scroll lag and misplaced summary cards

Three defects in the restore/injection path.

Scrolling stuttered in any chat that had been summarized. The restore gate
keyed on id(chat_activity), but every Xposed callback hands Python a fresh
Chaquopy wrapper around the same Java object, so the key never matched and
each scroll frame opened SQLite and rescanned the message list.
updateMessagesVisiblePart runs per frame, so that ran continuously.
BaseFragment.classGuid is assigned once per fragment instance, so it
identifies an open chat across calls; measured 1 query per 300 frames
instead of 300. Saved rows are cached per scope, invalidated on save.

The card could attach to the wrong message when many messages were
included. insert() computed the index on the calling thread and applied it
later on the UI thread; a large range takes long enough for paging or an
incoming message to shift the list in between, so a stale index was used.
The index is now computed on the UI thread immediately before the
mutation that consumes it.

Every saved summary appeared stacked together in a row. find_index_for_anchor
fell back to nearest-by-id whenever the anchor was not loaded, and a chat
opens with a short window, so summaries anchored far up all collapsed to
adjacent slots at the bottom. Absence is now reported as -1 and those cards
are skipped until paging brings their anchor in; the id-order fallback stays
opt-in for reanchor, which must place a card it has already removed.
Verified against the 6 real saved summaries for one chat: previously all 6
were injected consecutively on open, now none are, and each appears in its
correct place once its range is scrolled in.

Also makes restore linear in list length rather than rescanning per summary.
This commit is contained in:
lyuksovannyy committed 2026-09-19 16:51:12 +02:00
1 parent 7f0fcfe72f
commit 0619c5fe68
4 files changed
+297 -50

No files matched your search

+119 -8
View File
@@ -1582,28 +1582,35 @@ def main(argv=None) -> int:
# Anchor not loaded in this view: falling back to index 0 would put the
# card below messages the summary never saw, which is the bug this
# ordering fallback exists to prevent.
# These exercise the id-ordering fallback itself, which is now opt-in:
# callers that must place a card somewhere (reanchor) ask for it, while
# the default refuses to guess a position for an unloaded anchor.
check(
"an unloaded anchor is ordered by id instead of dropping to the bottom",
injector.find_index_for_anchor(offset_activity.messages, 750) == 2,
f"index={injector.find_index_for_anchor(offset_activity.messages, 750)}",
"requested id ordering places an unloaded anchor by id",
injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False) == 2,
f"index={injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False)}",
)
check(
"an anchor older than everything loaded lands at the top of the list",
injector.find_index_for_anchor(offset_activity.messages, 100)
injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)
== offset_activity.messages.size(),
f"index={injector.find_index_for_anchor(offset_activity.messages, 100)}",
f"index={injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)}",
)
check(
"an anchor newer than everything loaded lands at the bottom",
injector.find_index_for_anchor(offset_activity.messages, 1000) == 0,
f"index={injector.find_index_for_anchor(offset_activity.messages, 1000)}",
injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False) == 0,
f"index={injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False)}",
)
# The real invariant is positional, not a fixed index: every message
# newer than the anchor stays below the card, every older one above,
# regardless of injected cards already sitting in the list.
def neighbours(activity_obj, anchor):
index = injector.find_index_for_anchor(activity_obj.messages, anchor)
# require_anchor=False so the invariant is checked for unloaded
# anchors too: when a position is chosen at all, it must be correct.
index = injector.find_index_for_anchor(
activity_obj.messages, anchor, require_anchor=False
)
ids = [m.messageOwner.id for m in activity_obj.messages]
real = [i for i in ids if i > 0]
below = [i for i in ids[:index] if i > 0]
@@ -1816,6 +1823,110 @@ def main(argv=None) -> int:
f"ids={ids}",
)
# Bug: every saved summary appeared stacked in a row at the bottom of
# the chat. Cards whose anchor is outside the loaded window have no
# correct position, and placing them by id order alone collapsed them
# together. They must be skipped until paging brings their anchor in.
narrow = _FakeChatActivity([600, 590, 580])
stranded = injector.restore_saved_summaries(
narrow,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
],
)
narrow_ids = [m.messageOwner.id for m in narrow.messages]
injected = [i for i in narrow_ids if i < 0]
check(
"summaries whose anchor is not loaded are not injected at all",
stranded == 1 and injected == [injector.virtual_id_for(23, 590)],
f"dispatched={stranded} injected={injected} ids={narrow_ids}",
)
check(
"the one restorable summary still lands directly above its anchor",
narrow_ids.index(injector.virtual_id_for(23, 590)) == narrow_ids.index(590) - 1,
f"ids={narrow_ids}",
)
# Once paging widens the window, the previously skipped summary has a
# real anchor and must be restored on the next attempt.
for older in (130, 120):
owner_obj = _TLObj("TL_message")
owner_obj.id = older
narrow.messages.add(_FakeMessageObject(0, owner_obj))
after_paging = injector.restore_saved_summaries(
narrow,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
],
)
paged_ids = [m.messageOwner.id for m in narrow.messages]
check(
"summaries are restored once paging loads their anchor",
after_paging == 2
and paged_ids.index(injector.virtual_id_for(21, 120)) == paged_ids.index(120) - 1
and paged_ids.index(injector.virtual_id_for(22, 130)) == paged_ids.index(130) - 1,
f"dispatched={after_paging} ids={paged_ids}",
)
check(
"re-restoring does not duplicate the summary already on screen",
paged_ids.count(injector.virtual_id_for(23, 590)) == 1,
f"ids={paged_ids}",
)
# Bug: the card appeared after the wrong message when many messages
# were included. The insert index was computed on the calling thread and
# applied later on the UI thread; anything arriving in between shifted
# the list and invalidated it. A fake dispatcher that mutates the list
# between computing and applying reproduces that window.
racing = _FakeChatActivity([700, 690, 680])
real_on_ui = injector._on_ui
def _racing_on_ui(fn):
owner_obj = _TLObj("TL_message")
owner_obj.id = 701
racing.messages.add(0, _FakeMessageObject(0, owner_obj))
fn()
injector._on_ui = _racing_on_ui
try:
racing_injection = injector.SummaryInjection(
racing, 0, -4242, 0, 690, injector.virtual_id_for(31, 690)
)
racing_injection.insert("summary body", "", streaming=False)
finally:
injector._on_ui = real_on_ui
racing_ids = [m.messageOwner.id for m in racing.messages]
card = injector.virtual_id_for(31, 690)
check(
"the card lands above its anchor even if the list shifts first",
racing_ids.index(card) == racing_ids.index(690) - 1,
f"ids={racing_ids}",
)
# find_index_for_anchor must report an absent anchor rather than
# guessing index 0 (chat bottom) or a nearest-by-id slot.
absent = _FakeChatActivity([900, 800])
check(
"an unloaded anchor is reported as absent",
injector.find_index_for_anchor(absent.messages, 123) == -1,
f"index={injector.find_index_for_anchor(absent.messages, 123)}",
)
check(
"id-ordered placement is still available when explicitly requested",
injector.find_index_for_anchor(absent.messages, 123, require_anchor=False) == 2,
f"index={injector.find_index_for_anchor(absent.messages, 123, require_anchor=False)}",
)
check(
"sys.modules gained no entry for bundled plugin module names",
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),