generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 3s
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>
822 lines
33 KiB
Python
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)
|