Files
chatSummaries/src/services/rich_injector.py
T
lyuksovannyyandClaude Opus 5.5 af23d2e682
Publish plugin / publish (push) Successful in 3s
Keep the chat on a far-back summary card after its anchor loads
With an offset the newest analyzed message is usually outside the loaded
window, so placement asks the chat to load it first. That path could leave the
view away from the card while offset 0 always worked:

- Placement gave up after ~3 s (25 x 120 ms) and asked for the anchor only
  once. scrollToMessageId drops the request while waitingForGetDifference, and
  a load can be superseded. It now waits ~10 s and re-requests every ~2 s.
- Loading a far anchor makes ChatActivity run its own postponed scroll, and a
  cache-then-server reload can re-scroll or replace the list. Either can land
  after the jump. Settle checks at 0.25/0.7/1.5/3 s re-jump if the card row is
  outside the visible range, and re-place the card if a reload dropped it.
  They stop as soon as the user drags the list.

Also logs each run's count/offset, the fetched id range, pre-stream failures,
load requests, placement and jumps to the diagnostics file, since bulletin-only
errors previously left no trace.

Verified on device with count 1000, offset 1000: anchor loaded on the first
retry, card placed after it and the chat jumped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:52:24 +02:00

822 lines
33 KiB
Python

"""Client-side injection of summary messages rendered as RichMessage.
A summary is not a real Telegram message: it is a locally built
`TLRPC.TL_message` carrying `rich_message`, attributed to a mock sender that
exists only in this client's `MessagesController` cache. It is inserted into the
open `ChatActivity`'s message list right after the newest analyzed message.
Verified against exteraGram 12.5.x (dex inspection):
- `Message.rich_message` + `flags2 |= 1 << 12` makes `MessageObject.setType`
return 36, which is the gate for `RichMessageLayout`.
- `RichMessage.part` makes the layout fade its bottom edge and hide the
timestamp, i.e. the app's own "still streaming" presentation.
- The mock sender id MUST be positive: negative ids are chat/channel space, so
`getUser()` can never resolve one and the cell falls back to "DELETED".
"""
from typing import Any, Optional
import diagnostics
from config import (
JUMP_SETTLE_DELAYS_MS,
PLACEMENT_RELOAD_EVERY,
PLACEMENT_RETRIES,
PLACEMENT_RETRY_DELAY_MS,
SUMMARY_HASHTAG,
)
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
# Above Telegram's issued id range, so this user exists only locally.
MOCK_SENDER_ID = 8000000000000001
MOCK_SENDER_NAME = "AI Summary"
MOCK_SENDER_USERNAME = "ai_summary"
FLAGS2_RICH_MESSAGE = 1 << 12
# Telegram's animated robot custom emoji, used as the mock sender's status.
ROBOT_EMOJI_DOCUMENT_ID = 5397634513026043777
def _tlrpc():
from org.telegram.tgnet import TLRPC
return TLRPC
def virtual_id_for(summary_id: int, anchor_msg_id: int) -> int:
"""Deterministic negative id, so re-injection is idempotent and detectable."""
return -abs(int(anchor_msg_id) * 1000 + (int(summary_id) % 1000)) or -1
def register_mock_sender(account: int) -> Optional[Any]:
"""Puts the mock sender into the local user cache so the cell renders a name."""
try:
TLRPC = _tlrpc()
from org.telegram.messenger import MessagesController
user = TLRPC.TL_user()
user.id = MOCK_SENDER_ID
user.first_name = MOCK_SENDER_NAME
user.last_name = ""
user.username = MOCK_SENDER_USERNAME
user.bot = True
user.verified = True
user.deleted = False
user.access_hash = 0
_attach_robot_status(user, account, TLRPC)
MessagesController.getInstance(account).putUser(user, False)
return user
except Exception as exc:
diagnostics.record_error("injector.register_mock_sender", exc)
return None
def _attach_robot_status(user: Any, account: int, TLRPC: Any) -> None:
try:
from org.telegram.messenger import AnimatedEmojiDrawable
if AnimatedEmojiDrawable.findDocument(account, ROBOT_EMOJI_DOCUMENT_ID) is None:
return
status = TLRPC.TL_emojiStatus()
status.document_id = ROBOT_EMOJI_DOCUMENT_ID
user.emoji_status = status
except Exception:
# Purely cosmetic; a missing emoji pack must not break injection.
pass
def peer_for_dialog(dialog_id: int) -> Any:
"""Builds the Peer subtype matching the dialog kind.
The subtype matters: `MessageObject.isOutOwner` inspects it, and a
`TL_peerUser` in a group makes the summary render as an outgoing message.
"""
TLRPC = _tlrpc()
dialog_id = int(dialog_id)
if dialog_id > 0:
peer = TLRPC.TL_peerUser()
peer.user_id = dialog_id
return peer
if dialog_id <= -1000000000000:
peer = TLRPC.TL_peerChannel()
peer.channel_id = -dialog_id - 1000000000000
return peer
peer = TLRPC.TL_peerChat()
peer.chat_id = -dialog_id
return peer
def build_summary_message(
account: int,
dialog_id: int,
topic_id: int,
anchor_msg_id: int,
virtual_id: int,
answer_markdown: str,
thinking: str = "",
streaming: bool = False,
status: str = "",
tagged: bool = False,
) -> Optional[Any]:
"""Builds the local MessageObject for a summary, or None on failure.
`tagged` marks a finished summary: SUMMARY_HASHTAG is appended to the
card as a native hashtag node (TL_iv.textHashtag), and to the message's
plain-text body with a TL_messageEntityHashtag. The tag is added here rather than stored, so every
summary restored from the DB carries it too.
"""
try:
import time
TLRPC = _tlrpc()
from org.telegram.messenger import MessageObject
msg = TLRPC.TL_message()
msg.id = int(virtual_id)
msg.dialog_id = int(dialog_id)
msg.date = int(time.time())
msg.out = False
msg.unread = False
msg.post = False
msg.flags = 0
msg.peer_id = peer_for_dialog(dialog_id)
sender = TLRPC.TL_peerUser()
sender.user_id = MOCK_SENDER_ID
msg.from_id = sender
# Reply header points at the newest analyzed message, so the summary
# visually attaches to the range it covers and taps navigate there.
if anchor_msg_id:
try:
header = TLRPC.TL_messageReplyHeader()
header.reply_to_msg_id = int(anchor_msg_id)
if topic_id:
header.reply_to_top_id = int(topic_id)
msg.reply_to = header
except Exception as exc:
diagnostics.record_error("injector.reply_header", exc)
tagged = tagged and bool((answer_markdown or "").strip())
_set_plain_body(msg, answer_markdown, tagged, TLRPC)
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
if tagged:
msg.rich_message.blocks.add(hashtag_block(SUMMARY_HASHTAG))
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
message_object = MessageObject(account, msg, True, True)
# Pin attribution rather than let it be derived: in Saved Messages
# isOutOwner() returns true for anything without forward info.
message_object.isOutOwnerCached = False
return message_object
except Exception as exc:
diagnostics.record_error("injector.build_summary_message", exc)
return None
def _utf16_len(text: str) -> int:
"""Length in UTF-16 code units, which is what Telegram entity offsets count."""
return len(text.encode("utf-16-le")) // 2
def _set_plain_body(msg: Any, answer_markdown: str, tagged: bool, TLRPC: Any) -> None:
"""Fills `message` with the summary as plain text.
The card renders from rich_message, but copy, translate and any text
matching in the app read `message`. Left empty, a summary is invisible to
all of them.
"""
text = markdown_to_plain(answer_markdown)
if tagged:
text = (text + "\n\n" + SUMMARY_HASHTAG) if text else SUMMARY_HASHTAG
msg.message = text
if not tagged:
return
try:
entity = TLRPC.TL_messageEntityHashtag()
entity.offset = _utf16_len(text) - _utf16_len(SUMMARY_HASHTAG)
entity.length = _utf16_len(SUMMARY_HASHTAG)
msg.entities.add(entity)
except Exception as exc:
diagnostics.record_error("injector.hashtag_entity", exc)
def get_messages_list(chat_activity: Any) -> Optional[Any]:
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
try:
from hook_utils import get_private_field
candidate = getattr(chat_activity, "messages", None) or get_private_field(chat_activity, "messages")
if candidate is not None and hasattr(candidate, "size"):
return candidate
diagnostics.note(
f"injector.get_messages_list: unusable candidate={type(candidate).__name__ if candidate is not None else None}"
)
except Exception as exc:
diagnostics.record_error("injector.get_messages_list", exc)
return None
def get_chat_adapter(chat_activity: Any) -> Optional[Any]:
try:
from hook_utils import get_private_field
return getattr(chat_activity, "chatAdapter", None) or get_private_field(chat_activity, "chatAdapter")
except Exception:
return None
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int, require_anchor: bool = True) -> int:
"""Index at which to insert so the summary sits directly after the anchor.
ChatActivity.messages is newest-first, so "directly after the anchor"
chronologically means "immediately before it" in list order.
Returns -1 when the anchor message is not loaded in this view and
`require_anchor` is set. Positioning by id order alone is not safe: with a
non-zero offset, or a range that has since paged out, the nearest-by-id slot
can sit among messages the summary never analyzed, which is how cards ended
up attached to the wrong message. Callers skip the insert instead and try
again once the anchor is paged in.
"""
anchor_msg_id = int(anchor_msg_id)
# Anchors are real, positive message ids. A non-positive anchor must never
# match: date separators are local MessageObjects whose messageOwner.id is
# 0, so scanning for 0 would "find" the first separator and place the card
# against a divider instead of a message.
if anchor_msg_id <= 0:
return -1
try:
size = messages_list.size()
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
return i
if require_anchor:
return -1
# Anchor absent: insert before the first real message whose id is at
# most the anchor's, i.e. the newest message not newer than the
# analyzed range. Injected cards carry negative ids and are skipped so
# they cannot drag the position around.
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
if 0 < msg_id <= anchor_msg_id:
return i
# Every loaded message is newer than the anchor, so the analyzed range
# ends above everything here: the card belongs at the very top.
return size
except Exception as exc:
diagnostics.record_error("injector.find_index_for_anchor", exc)
return -1
def find_existing(messages_list: Any, virtual_id: int) -> int:
"""Index of an already-injected message with this virtual id, else -1."""
try:
size = messages_list.size()
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == int(virtual_id):
return i
except Exception:
pass
return -1
def notify_adapter(chat_activity: Any) -> None:
adapter = get_chat_adapter(chat_activity)
if adapter is None:
return
def run():
try:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.notifyDataSetChanged", exc)
try:
from android_utils import run_on_ui_thread
run_on_ui_thread(run)
except Exception:
run()
class SummaryInjection:
"""A single injected summary message, updatable in place while streaming.
Created only once the summary has started arriving, with its final anchor
and virtual id already known: nothing about it is provisional, so it is
placed exactly once, directly after the newest analyzed message.
"""
def __init__(self, chat_activity: Any, account: int, dialog_id: int, topic_id: int, anchor_msg_id: int, virtual_id: int):
self.chat_activity = chat_activity
self.account = int(account)
self.dialog_id = int(dialog_id)
self.topic_id = int(topic_id)
self.anchor_msg_id = int(anchor_msg_id)
self.virtual_id = int(virtual_id)
self.message_object: Optional[Any] = None
# True once the card is in the list. Until then renders only replace
# message_object, which placement reads when it finally lands.
self.placed = False
self._placing = False
def _build(self, answer_markdown: str, thinking: str, streaming: bool, status: str, tagged: bool) -> Optional[Any]:
return build_summary_message(
self.account,
self.dialog_id,
self.topic_id,
self.anchor_msg_id,
self.virtual_id,
answer_markdown,
thinking,
streaming,
status,
tagged,
)
def insert(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
tagged: bool = False,
) -> bool:
"""Places the card after its anchor and jumps the chat to it.
Returns False only when the chat cannot take an injected card at all.
An anchor that is not loaded yet is not a failure: the chat is asked to
load it and placement retries until it arrives.
"""
if get_messages_list(self.chat_activity) is None:
diagnostics.note("injector: ChatActivity messages list not found")
return False
register_mock_sender(self.account)
message_object = self._build(answer_markdown, thinking, streaming, status, tagged)
if message_object is None:
return False
self.message_object = message_object
self._placing = True
_on_ui(lambda: self._place(PLACEMENT_RETRIES))
return True
def _place(self, retries_left: int) -> None:
"""Runs on the UI thread. The index is computed immediately before the
mutation that uses it: computed earlier, paging or an incoming message
could shift the list and land the card after the wrong message.
"""
try:
messages_list = get_messages_list(self.chat_activity)
if messages_list is None:
self._placing = False
return
message_object = self.message_object
index = find_existing(messages_list, self.virtual_id)
if index >= 0:
# A card for this summary is already on screen -- the restore
# hook re-injected the saved row being re-summarized. Claim it.
messages_list.set(index, message_object)
else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index < 0:
attempt = PLACEMENT_RETRIES - retries_left
if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0:
# The analyzed range ends outside the loaded window
# (an offset, or history scrolled away). Have the chat
# load and show it, then place once it is in the list.
# Asked again periodically: scrollToMessageId drops the
# request outright while waitingForGetDifference, and
# a load can be superseded by another one.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} not loaded "
f"({_id_span(messages_list)}), requesting it (attempt {attempt})"
)
self._load_anchor()
if retries_left > 0:
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
else:
self._placing = False
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} never loaded, card not placed"
)
return
messages_list.add(index, message_object)
# A render may have landed while this was queued; show the newest.
if self.message_object is not message_object:
messages_list.set(index, self.message_object)
self.placed = True
self._placing = False
diagnostics.note(
f"injector: card placed after anchor {self.anchor_msg_id} at index {index} "
f"(retries left {retries_left})"
)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# On placement only -- scrolling on every streaming update would
# fight the user for control of the list. The settle checks right
# after only undo scrolls the app itself makes in that window.
if self.scroll_into_view():
self._schedule_settle(0)
except Exception as exc:
self._placing = False
diagnostics.record_error("injector.place", exc)
def _schedule_settle(self, step: int) -> None:
if step >= len(JUMP_SETTLE_DELAYS_MS):
return
previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0
_on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous)
def _settle(self, step: int) -> None:
"""Re-jumps if something scrolled the fresh card off screen.
Stops for good as soon as the user touches the list: from then on
where the chat is scrolled is theirs to decide.
"""
try:
list_view = _chat_list_view(self.chat_activity)
if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING
diagnostics.note("injector: user is scrolling, jump settled")
return
span = self._visible_span()
if span is not None:
position, first, last = span
if not first <= position <= last:
diagnostics.note(
f"injector: card row {position} scrolled away (visible {first}..{last}), "
f"jumping back (check {step})"
)
self._jump_to_card()
elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0:
# The list was reloaded under the card. Put it back.
messages_list = get_messages_list(self.chat_activity)
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index >= 0:
diagnostics.note(f"injector: list reloaded, re-placing card (check {step})")
messages_list.add(index, self.message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
self._jump_to_card()
except Exception as exc:
diagnostics.record_error("injector.settle", exc)
self._schedule_settle(step + 1)
def _visible_span(self) -> Optional[tuple]:
"""(card adapter position, first visible, last visible), or None."""
try:
from hook_utils import get_private_field
messages_list = get_messages_list(self.chat_activity)
adapter = get_chat_adapter(self.chat_activity)
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
self.chat_activity, "chatLayoutManager"
)
if messages_list is None or adapter is None or layout_manager is None:
return None
index = find_existing(messages_list, self.virtual_id)
if index < 0:
return None
first = int(layout_manager.findFirstVisibleItemPosition())
last = int(layout_manager.findLastVisibleItemPosition())
if first < 0 or last < 0:
return None
return int(adapter.messagesStartRow) + index, min(first, last), max(first, last)
except Exception as exc:
diagnostics.record_error("injector.visible_span", exc)
return None
def _load_anchor(self) -> None:
"""Asks the chat to load the anchor and scroll to it.
ChatActivity.scrollToMessageId(id, fromMessageId, select, loadIndex,
forceScroll, unreadCount) -- arity verified in the APK. For an
unloaded id it requests history around it and jumps there on arrival.
"""
try:
self.chat_activity.scrollToMessageId(int(self.anchor_msg_id), 0, False, 0, True, 0)
except Exception as exc:
diagnostics.record_error("injector.load_anchor", exc)
def scroll_into_view(self) -> bool:
"""Jumps the chat so the card sits a third of the way down the screen,
with its anchor directly above it.
A hard jump, not scrollToMessageId: for a message already in the list
that only does smoothScrollBy() from the current cell positions, which
are stale right after an insert (the layout pass has not run yet), so
it undershoots or does nothing. Setting the layout manager's pending
position is applied on the next layout, after the insert, and is what
ChatActivity itself does when it must land on a specific row.
"""
if self._jump_to_card():
return True
self._load_anchor()
return False
def _jump_to_card(self) -> bool:
try:
from hook_utils import get_private_field
messages_list = get_messages_list(self.chat_activity)
adapter = get_chat_adapter(self.chat_activity)
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
self.chat_activity, "chatLayoutManager"
)
list_view = _chat_list_view(self.chat_activity)
if messages_list is None or adapter is None or layout_manager is None:
return False
index = find_existing(messages_list, self.virtual_id)
if index < 0:
return False
# Public on ChatActivityAdapter; already current, because
# notifyDataSetChanged() runs updateRowsInternal() before returning.
position = int(adapter.messagesStartRow) + index
offset = 0
if list_view is not None:
try:
list_view.stopScroll()
except Exception:
pass
offset = int(list_view.getMeasuredHeight()) // 3
# bottom=False: offset counts from the top edge, as in
# ChatActivity.scrollToMessageId's own scrollToPosition call.
layout_manager.scrollToPositionWithOffset(position, offset, False)
diagnostics.note(f"injector: jump to row {position} offset {offset}")
return True
except Exception as exc:
diagnostics.record_error("injector.jump_to_card", exc)
return False
def update(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
tagged: bool = False,
) -> None:
"""Re-renders the card in place, without rebuilding the whole list.
Mutating rich_message alone is not enough: MessageObject caches
messageText, type, and its measured layout, and ChatMessageCell keeps
its own cached sizes, so a bare swap renders as an empty bubble.
The app's own live-update path (ChatActivity.applyFullRichMessageLocally)
does exactly three things: set rich_message, null richLayout, then call
chatMessageCellDelegate.forceUpdate(cell, true, true). That repaints the
single affected cell and is what keeps a streaming card visually stable.
notifyDataSetChanged() rebinds every row instead, which is what made the
card flicker and lose scroll position on each delta; it is only used as
a fallback when the live cell cannot be found.
"""
if self.message_object is None:
self.insert(answer_markdown, thinking, streaming, status, tagged)
return
replacement = self._build(answer_markdown, thinking, streaming, status, tagged)
if replacement is None:
return
self.message_object = replacement
if not self.placed:
# Placement is still waiting for the anchor and will show this
# render. If it gave up, give it one more look now: the user may
# have scrolled the anchor in since.
if not self._placing:
self._placing = True
_on_ui(lambda: self._place(0))
return
def run():
try:
messages_list = get_messages_list(self.chat_activity)
inserted = False
if messages_list is not None:
index = find_existing(messages_list, self.virtual_id)
if index >= 0:
messages_list.set(index, replacement)
else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index < 0:
# Anchor paged out mid-stream. Keep the streamed
# text; it is re-inserted once the anchor is back.
return
messages_list.add(index, replacement)
inserted = True
# A row was added, so the adapter's item count changed and only
# a full rebind is correct.
if inserted or not self._force_update_cell(replacement):
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.update", exc)
_on_ui(run)
def _force_update_cell(self, message_object: Any) -> bool:
"""Repaints just this card's cell. Returns False if it is not on screen.
Mirrors ChatActivity.applyFullRichMessageLocally: locate the live
ChatMessageCell bound to this message and hand it to
chatMessageCellDelegate.forceUpdate, which remeasures and redraws that
one row instead of rebinding the whole list.
"""
try:
from hook_utils import get_private_field
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
self.chat_activity, "chatMessageCellDelegate"
)
list_view = _chat_list_view(self.chat_activity)
if delegate is None or list_view is None:
return False
for i in range(list_view.getChildCount()):
child = list_view.getChildAt(i)
if child is None or not hasattr(child, "getMessageObject"):
continue
bound = child.getMessageObject()
owner = getattr(bound, "messageOwner", None) if bound is not None else None
if owner is None or int(getattr(owner, "id", 0) or 0) != self.virtual_id:
continue
# setMessageObject(messageObject, groupedMessages, pinnedBottom,
# pinnedTop, firstInChat) -- verified arity against the APK.
child.setMessageObject(message_object, None, False, False, False)
delegate.forceUpdate(child, False, True)
child.invalidate()
return True
except Exception as exc:
diagnostics.record_error("injector._force_update_cell", exc)
return False
def _chat_list_view(chat_activity: Any) -> Optional[Any]:
try:
from hook_utils import get_private_field
return getattr(chat_activity, "chatListView", None) or get_private_field(chat_activity, "chatListView")
except Exception:
return None
def _id_span(messages_list: Any) -> str:
"""'loaded ids A..B, N rows' for diagnostics."""
try:
ids = [
int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0)
for i in range(messages_list.size())
]
real = [i for i in ids if i > 0]
if not real:
return f"no messages, {len(ids)} rows"
return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows"
except Exception:
return "loaded ids unknown"
def remove_cards(chat_activity: Any, virtual_ids) -> None:
"""Drops injected cards from an open chat's list.
ChatActivity removes deleted messages by looking them up in messagesDict,
which injected cards are never added to, so a deleted card would otherwise
stay on screen.
"""
wanted = {int(v) for v in virtual_ids}
if chat_activity is None or not wanted:
return
def run():
try:
messages_list = get_messages_list(chat_activity)
if messages_list is None:
return
removed = 0
for i in range(messages_list.size() - 1, -1, -1):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) in wanted:
messages_list.remove(i)
removed += 1
if removed:
adapter = get_chat_adapter(chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.remove_cards", exc)
_on_ui(run)
def _on_ui(fn, delay_ms: int = 0) -> None:
try:
from android_utils import run_on_ui_thread
if delay_ms:
run_on_ui_thread(fn, delay_ms)
else:
run_on_ui_thread(fn)
except Exception:
fn()
def loaded_message_ids(messages_list: Any) -> set:
"""Ids currently loaded in the view, in one pass.
Restoring used to scan the whole list once per saved summary; a single set
keeps it linear in the list length no matter how many summaries exist.
"""
ids = set()
try:
for i in range(messages_list.size()):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is None:
continue
msg_id = int(getattr(owner, "id", 0) or 0)
# Skip date separators (id 0). Keeping them would make a summary
# whose anchor id is 0 look restorable.
if msg_id:
ids.add(msg_id)
except Exception as exc:
diagnostics.record_error("injector.loaded_message_ids", exc)
return ids
def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int:
"""Re-injects persisted summaries into a freshly opened chat.
Only summaries whose anchor is currently loaded are injected: a card whose
anchor is outside the loaded window has no correct position, and guessing
one by id order is what stacked every saved card together at the bottom of
the chat. The rest are picked up on a later call, once paging has brought
their anchor in. Returns how many were dispatched for insertion.
"""
messages_list = get_messages_list(chat_activity)
if messages_list is None or not summaries:
return 0
loaded = loaded_message_ids(messages_list)
pending = []
for summary in summaries:
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
if virtual_id in loaded:
continue
if int(summary.anchor_msg_id) not in loaded:
continue
message_object = build_summary_message(
account,
dialog_id,
topic_id,
summary.anchor_msg_id,
virtual_id,
summary.text,
summary.thinking,
False,
tagged=True,
)
if message_object is not None:
pending.append((summary.anchor_msg_id, virtual_id, message_object))
if not pending:
return 0
register_mock_sender(account)
# Newest anchor first: each insert shifts everything below it, so placing
# the newest card first keeps the remaining lookups pointing at unmoved
# rows. The index is recomputed per card against the live list anyway.
pending.sort(key=lambda item: item[0], reverse=True)
def run():
try:
# Re-resolve on the UI thread: the list this was queued against may
# have paged or been replaced in between.
live = get_messages_list(chat_activity)
if live is None:
return
added = 0
for anchor_msg_id, virtual_id, message_object in pending:
if find_existing(live, virtual_id) >= 0:
continue
index = find_index_for_anchor(live, anchor_msg_id)
if index < 0:
continue
live.add(index, message_object)
added += 1
if added:
adapter = get_chat_adapter(chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.restore", exc)
_on_ui(run)
return len(pending)