Correct the unread badge after jumping away from the newest messages
Publish plugin / publish (push) Successful in 3s

This commit is contained in:
lyuksovannyy committed 2026-10-12 19:27:44 +02:00
1 parent f58e157b70
commit 7b3a4bece3
3 files changed
+284 -1

No files matched your search

+1 -1
View File
@@ -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",
+276
View File
@@ -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=<that 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()
+7
View File
@@ -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):