From 7b3a4bece3f2cdffe146fc1bd2deff2b91f2ef86 Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Mon, 12 Oct 2026 19:27:44 +0200 Subject: [PATCH] Correct the unread badge after jumping away from the newest messages --- plugin.json | 2 +- src/hooks/badge_hook.py | 276 ++++++++++++++++++++++++++++++++++++++++ src/main.py | 7 + 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/hooks/badge_hook.py diff --git a/plugin.json b/plugin.json index b0c2e90..100174e 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "Chat Summaries", "description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.", "author": "@lyuksovannyy", - "version": "1.1.1", + "version": "1.1.2", "icon": "exteraGramPluginIcons/2", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", diff --git a/src/hooks/badge_hook.py b/src/hooks/badge_hook.py new file mode 100644 index 0000000..6790220 --- /dev/null +++ b/src/hooks/badge_hook.py @@ -0,0 +1,276 @@ +"""Keeps the page-down button's unread counter right away from the newest messages. + +ChatActivity shows `newUnreadMessageCount` on the page-down badge. It seeds it +from the dialog's unread count (everything after the read marker) and only +decrements it as messages are marked read. Away from the newest messages that +number is wrong: after a jump into history (a summary anchor, a reply, a +pinned message, search) messages between the read marker and the jump target +stay counted, so the badge can say 5k while only 3k messages lie below what is +on screen. + +What the badge should show is how many messages are newer than the newest +visible one. The server answers that exactly: messages.getHistory with +offset_id=, add_offset=-1, limit=1 returns `offset_id_offset`, the +number of messages newer than offset_id. The hook asks while the chat is not +showing the newest messages, at most once per REFRESH_SECONDS per chat and only +after the visible position moved, then writes the result through the same +setter the app uses. At the bottom of the chat it never interferes. +""" +import time +from typing import Any, Dict, List + +import diagnostics +from hooks.restore_hook import _find_methods_by_name + +# The badge only needs to be right, not instant: one tiny history request per +# chat every few seconds while scrolling history is negligible next to what +# scrolling itself loads. +REFRESH_SECONDS = 3.0 + + +def _get(obj: Any, name: str) -> Any: + try: + from hook_utils import get_private_field + + return get_private_field(obj, name) + except Exception: + return getattr(obj, name, None) + + +def _set_int(obj: Any, name: str, value: int) -> bool: + """Writes a Java int field through reflection. + + hook_utils.set_private_field returns without error here but leaves the + field unchanged (verified on device), so the field is written directly. + """ + cls = obj.getClass() + while cls is not None: + try: + field = cls.getDeclaredField(name) + field.setAccessible(True) + field.setInt(obj, int(value)) + return True + except Exception: + cls = cls.getSuperclass() + return False + + +def _first(value: Any) -> Any: + """First element of a Java array exposed through Chaquopy, or None.""" + try: + return value[0] + except Exception: + return None + + +def _newest_visible_id(chat_activity: Any) -> int: + """Largest real message id among the rows on screen, or 0.""" + try: + from services.rich_injector import get_chat_adapter, get_messages_list + + layout_manager = _get(chat_activity, "chatLayoutManager") + adapter = get_chat_adapter(chat_activity) + messages = get_messages_list(chat_activity) + if layout_manager is None or adapter is None or messages is None: + return 0 + first = int(layout_manager.findFirstVisibleItemPosition()) + last = int(layout_manager.findLastVisibleItemPosition()) + if first < 0 or last < 0: + return 0 + start = int(adapter.messagesStartRow) + size = int(messages.size()) + best = 0 + for position in range(min(first, last), max(first, last) + 1): + index = position - start + if 0 <= index < size: + owner = getattr(messages.get(index), "messageOwner", None) + msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0 + # Injected summary cards are negative, date rows are 0. + if msg_id > best: + best = msg_id + return best + except Exception as exc: + diagnostics.record_error("badge_hook.visible_id", exc) + return 0 + + +def _read_inbox_max_id(dialog_id: int) -> int: + try: + from client_utils import get_messages_controller + + controller = get_messages_controller() + dialog = controller.dialogs_dict.get(dialog_id) if controller else None + return int(getattr(dialog, "read_inbox_max_id", 0) or 0) if dialog is not None else 0 + except Exception: + return 0 + + +class BadgeHook: + def __init__(self, plugin): + self.plugin = plugin + self._unhooks: List[Any] = [] + # dialog_id -> (monotonic time, visible id) of the last server query. + self._asked_at: Dict[int, tuple] = {} + # dialog_id -> True while a query is in flight. + self._in_flight: Dict[int, bool] = {} + # Strong references to RequestCallback wrappers until they fire. + self._pending: List[Any] = [] + + def install(self) -> None: + try: + from base_plugin import MethodHook + from hook_utils import find_class + except Exception as exc: + diagnostics.record_error("badge_hook.install:imports", exc) + return + + chat_activity_class = find_class("org.telegram.ui.ChatActivity") + if not chat_activity_class: + diagnostics.note("badge_hook.install: ChatActivity not found") + return + methods = _find_methods_by_name(chat_activity_class, "updateMessagesVisiblePart") + diagnostics.note(f"badge_hook.install: updateMessagesVisiblePart methods found={len(methods)}") + + outer = self + + class _VisiblePartHook(MethodHook): + def after_hooked_method(self, param): + try: + outer._check(param.thisObject) + except Exception as exc: + diagnostics.record_error("badge_hook.check", exc) + + for method in methods: + try: + ref = self.plugin.hook_method(method, _VisiblePartHook()) + if ref: + self._unhooks.append(ref) + except Exception as exc: + diagnostics.record_error("badge_hook.install:hook_method", exc) + + def uninstall(self) -> None: + for ref in self._unhooks: + try: + self.plugin.unhook_method(ref) + except Exception as exc: + diagnostics.record_error("badge_hook.uninstall", exc) + self._unhooks = [] + self._pending = [] + + # ------------------------------------------------------------------ + + def _check(self, chat_activity: Any) -> None: + if chat_activity is None: + return + # Topics, threads, scheduled and saved views keep their own counters. + for flag in ("isTopic", "isThreadChat", "isComments"): + if bool(_get(chat_activity, flag) or False): + return + if int(_get(chat_activity, "chatMode") or 0) != 0: + return + if int(_get(chat_activity, "newUnreadMessageCount") or 0) <= 0: + return + # Showing the newest messages: the app's own count is accurate. + if bool(_first(_get(chat_activity, "forwardEndReached")) or False): + return + + dialog_id = int(chat_activity.getDialogId()) + if self._in_flight.get(dialog_id): + return + visible_id = _newest_visible_id(chat_activity) + if visible_id <= 0: + return + # Viewing a message marks everything up to it read, so the backlog + # starts after whichever is newer: the read marker or the screen. + visible_id = max(visible_id, _read_inbox_max_id(dialog_id)) + now = time.monotonic() + last_at, last_id = self._asked_at.get(dialog_id, (0.0, 0)) + if visible_id == last_id or now - last_at < REFRESH_SECONDS: + return + if len(self._asked_at) > 64: + self._asked_at.clear() + self._asked_at[dialog_id] = (now, visible_id) + self._query(chat_activity, dialog_id, visible_id) + + def _query(self, chat_activity: Any, dialog_id: int, visible_id: int) -> None: + try: + from client_utils import RequestCallback, get_messages_controller, send_request + from org.telegram.tgnet import TLRPC + from services.message_fetcher import MessageFetcher + + controller = get_messages_controller() + peer = MessageFetcher._resolve_input_peer(controller, TLRPC, dialog_id) if controller else None + if peer is None: + return + req = TLRPC.TL_messages_getHistory() + req.peer = peer + req.offset_id = visible_id + req.offset_date = 0 + # -1 returns the anchor itself; offset_id_offset then counts the + # messages strictly newer than it. + req.add_offset = -1 + req.limit = 1 + req.max_id = 0 + req.min_id = 0 + req.hash = 0 + + box: List[Any] = [] + + def on_complete(response, error): + try: + self._in_flight.pop(dialog_id, None) + for proxy in box: + if proxy in self._pending: + self._pending.remove(proxy) + if error is not None or response is None: + return + newer = getattr(response, "offset_id_offset", None) + if newer is None: + return + self._apply(chat_activity, dialog_id, int(newer), visible_id) + except Exception as exc: + diagnostics.record_error("badge_hook.on_complete", exc) + + proxy = RequestCallback(on_complete) + box.append(proxy) + self._pending.append(proxy) + self._in_flight[dialog_id] = True + send_request(req, proxy) + except Exception as exc: + self._in_flight.pop(dialog_id, None) + diagnostics.record_error("badge_hook.query", exc) + + def _apply(self, chat_activity: Any, dialog_id: int, newer: int, visible_id: int) -> None: + def run(): + try: + if int(chat_activity.getDialogId()) != dialog_id: + return + # Never raise the badge: when the user already read past what + # is on screen, the app's lower count is the right one. + current = int(_get(chat_activity, "newUnreadMessageCount") or 0) + if current <= 0: + return + value = max(0, min(newer, current)) + if value == current: + return + if not _set_int(chat_activity, "newUnreadMessageCount", value): + return + _set_int(chat_activity, "prevSetUnreadCount", value) + layout = _get(chat_activity, "sideControlsButtonsLayout") + if layout is not None: + # Button 1 is the page-down button; the app animates only + # when the button is already on screen. + layout.setButtonCount(1, value, True) + diagnostics.note( + f"badge_hook: dialog {dialog_id} badge {current} -> {value} " + f"({newer} messages newer than visible {visible_id})" + ) + except Exception as exc: + diagnostics.record_error("badge_hook.apply", exc) + + try: + from android_utils import run_on_ui_thread + + run_on_ui_thread(run) + except Exception: + run() diff --git a/src/main.py b/src/main.py index bf8d723..30d5fce 100644 --- a/src/main.py +++ b/src/main.py @@ -24,6 +24,7 @@ from config import ( SETTING_STORAGE_REVISION, STREAM_UPDATE_INTERVAL, ) +from hooks.badge_hook import BadgeHook from hooks.delete_hook import DeleteHook from hooks.restore_hook import RestoreHook from hooks.unread_hook import UnreadHook @@ -40,6 +41,7 @@ class ChatSummariesPlugin(BasePlugin): self.unread_hook = UnreadHook(self) self.restore_hook = RestoreHook(self) self.delete_hook = DeleteHook(self) + self.badge_hook = BadgeHook(self) self.summary_db = None def _get_summary_db(self): @@ -90,11 +92,16 @@ class ChatSummariesPlugin(BasePlugin): self.delete_hook.install() except Exception as exc: diagnostics.record_error("on_plugin_load:delete_hook.install", exc) + try: + self.badge_hook.install() + except Exception as exc: + diagnostics.record_error("on_plugin_load:badge_hook.install", exc) def on_plugin_unload(self) -> None: self.unread_hook.uninstall() self.restore_hook.uninstall() self.delete_hook.uninstall() + self.badge_hook.uninstall() log("chat_summaries unloaded") def create_settings(self):