From 46503f24a00c073147b2693291bbea1ad37da9ba Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Wed, 7 Oct 2026 00:51:58 +0200 Subject: [PATCH] Rename to Extended Media Forwards and forward rich message media Rename the plugin (id extended_media_forwards) and add the Forward button to media opened from rich messages, both single media and carousels. Rich message media opens PhotoViewer through ChatActivity's ChatArticlePageBlocksAdapter, whose parent object is the MessageObject rather than a WebPage, so the article-only check hid the button. The re-sent media links back to the message when it lives in a channel or supergroup; elsewhere it is sent without a caption. FileLoader now gets the parent object so expired file references can be refreshed. Instant View carousels already worked: slideshow items are flattened into the article's photo list. Findings are in docs/media-viewer-notes.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/media-viewer-notes.md | 100 ++++++++++++++++++++++++++++++++++++ plugin.json | 8 +-- src/icons.py | 4 +- src/ivmedia.py | 52 ++++++++++++++++++- src/main.py | 36 ++++++++++--- src/picker.py | 2 +- src/sender.py | 48 ++++++++++------- tools/verify_bundle.py | 102 ++++++++++++++++++++++++++++++++++++- 9 files changed, 317 insertions(+), 36 deletions(-) create mode 100644 docs/media-viewer-notes.md diff --git a/README.md b/README.md index da27d39..3475603 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ tools/ .gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases scripts/ ADB debug-loop automation and the git hooks (see below) docs/sdk-notes.md condensed SDK API surface gathered from the official docs +docs/media-viewer-notes.md app internals behind the Forward button (page-block adapters, rich messages) ``` ## The three commands that matter diff --git a/docs/media-viewer-notes.md b/docs/media-viewer-notes.md new file mode 100644 index 0000000..ba15006 --- /dev/null +++ b/docs/media-viewer-notes.md @@ -0,0 +1,100 @@ +# Media viewer internals — findings + +Notes on how exteraGram opens page-block media, gathered by decompiling the +12.10.6 APK (versionCode 71129) with jadx and testing on a device. Class and +field names are from that build. They are app internals, not SDK API, so check +them again after app updates. + +## Two adapters, one PhotoViewer + +Page-block media never opens with a `MessageObject` list. PhotoViewer runs on a +`PhotoViewer.PageBlocksAdapter` instead, and `onPhotoShow` fires the same way +for both callers below, so one hook covers them. + +| Opened from | Adapter | `getParentObject()` | +|---|---|---| +| Instant View article (`ArticleViewer.openPhoto`) | `ArticleViewer.RealPageBlocksAdapter` | `TLRPC.WebPage` | +| Rich message in a chat (`ChatActivity.openArticlePhoto`) | `ChatActivity.ChatArticlePageBlocksAdapter` | the message's `MessageObject`, or the bare `TL_iv.RichMessage` when constructed without one | + +Both adapters expose the same `getMedia(i)` / `getFile(i)` / `isVideo(i)`. Those +methods resolve through `ArticleViewer.WebPageUtils`, which has overloads for +`WebPage`, `TL_iv.Page` and `TL_iv.RichMessage`. That is why `ivmedia.media_at` +works unchanged for both. Only the parent object tells them apart. + +A rich message is recognised by `MessageObject.messageOwner.rich_message != null`. + +## Carousels (slideshows) + +- **Articles:** `WebpageAdapter.addAllMediaFromBlock` flattens every + `pageBlockSlideshow` / `pageBlockCollage` item into `photoBlocks`. Tapping a + slideshow item therefore goes through `RealPageBlocksAdapter` like any other + article photo, and needed no special handling. +- **Rich messages:** `RichMessageLayout.collectMediaBlocks` flattens single + media and slideshow items into one list for `ChatArticlePageBlocksAdapter`. + Swiping in the viewer walks through all of them, and + `updateSlideshowCell` keeps the in-chat carousel in sync. + +## telegra.ph cannot produce a carousel + +Telegraph has no gallery/slideshow node. The editor has no such block, and +`createPage` keeps only the first `` of a `
` holding several, so +consecutive images render as separate `pageBlockPhoto`s. Carousels only reach +the viewer from other Instant View templates (sites other than telegra.ph, +covered by the `any_article` setting) and from rich messages. + +`telegra.ph/upload` no longer accepts files ("Unknown error"). Test pages have +to reference external image URLs, e.g. on `upload.wikimedia.org`. + +## Rich messages need Premium to send media + +The rich composer is the attach menu's "Article" tab (`ChatAttachAlertRichLayout`). +Sending is locked unless `MessagesController.richEditorAllowed()` or +`UserConfig.isPremium()`, whenever the content is "lossy" +(`RichMessageConvert.isLossy`). Lossy means anything a plain message can't +represent, and every media block is lossy. A non-Premium account therefore +cannot send a test rich message with media. + +To test without one, build the message locally and open it the same way +`ChatActivity.openArticlePhoto` does: + +```python +rm = TL_iv.RichMessage() # fill rm.photos and rm.blocks +msg = TLRPC.TL_message() # peer_id/id/dialog_id of any chat +msg.rich_message = rm +mo = MessageObject(account, msg, False, False) +adapter = ChatActivity.ChatArticlePageBlocksAdapter(rm, flat_blocks, mo) +viewer = PhotoViewer.getInstance() +viewer.setParentActivity(chat_fragment) +viewer.openPhoto(index, adapter, PhotoViewer.EmptyPhotoViewerProvider()) +``` + +Real `TLRPC.Photo` objects with server locations can be borrowed from the +`cached_page.photos` of any loaded article. Pointing `peer_id` at a channel +exercises the message-link caption. + +`ArticleViewer.open(TL_webPage, anchor)` re-requests `messages.getWebPage` and +replaces the page with the server copy. Copy the original `hash` onto the +injected `TL_webPage` to keep a locally modified `cached_page`. + +## Message links + +Only channels and supergroups (`ChatObject.isChannel`) have message links: +`https://t.me//` when public, `https://t.me/c//` +otherwise. On Android the dialog id of a channel is `-chat.id`, with no `-100` +prefix. DMs, basic groups and Saved Messages have no link, so media forwarded +from rich messages there is sent without a caption. + +## File references + +Pass the adapter's parent object (WebPage or MessageObject) as `parentObject` +to `FileLoader.loadFile`. `FileRefController` uses it to refresh an expired +file reference, and it handles rich messages explicitly. + +## Device debugging + +Logcat can be completely silent when the device's log buffer is disabled in +developer options, and `scripts/logs.sh` then shows nothing. A plugin can +still write diagnostics to +`ApplicationLoader.applicationContext.getExternalFilesDir(None)`, which maps +to `/sdcard/Android/data/com.exteragram.messenger/files/` and is readable with +`adb shell cat` without `run-as`. diff --git a/plugin.json b/plugin.json index cfa5be4..d4fddae 100644 --- a/plugin.json +++ b/plugin.json @@ -1,9 +1,9 @@ { - "id": "telegraph_media_forward", - "name": "Telegraph Media Forward", - "description": "Adds a Forward action to media opened from telegra.ph pages: downloads the media and re-sends it from your account with a link back to the article.", + "id": "extended_media_forwards", + "name": "Extended Media Forwards", + "description": "Adds a Forward action to media opened from telegra.ph pages and rich messages (single media and carousels): downloads the media and re-sends it from your account with a link back to its source.", "author": "@lyuksovannyy", - "version": "1.0.0", + "version": "1.1.1", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", diff --git a/src/icons.py b/src/icons.py index 0cd2081..ccf3eab 100644 --- a/src/icons.py +++ b/src/icons.py @@ -49,11 +49,11 @@ def drawable(context, res_id): if packed is not None: return packed except Exception as exc: - log("[telegraph_media_forward] icon pack lookup failed: %s" % exc) + log("[extended_media_forwards] icon pack lookup failed: %s" % exc) try: res_id = manager.getIcon(res_id) except Exception as exc: - log("[telegraph_media_forward] icon remap failed: %s" % exc) + log("[extended_media_forwards] icon remap failed: %s" % exc) resources = context.getResources() if density: return resources.getDrawableForDensity(res_id, density, theme) diff --git a/src/ivmedia.py b/src/ivmedia.py index e9afcf4..e429fad 100644 --- a/src/ivmedia.py +++ b/src/ivmedia.py @@ -1,9 +1,13 @@ -"""Reads media out of the PhotoViewer instance that Instant View opened. +"""Reads media out of a PhotoViewer that runs on a `PageBlocksAdapter`. When ArticleViewer opens a telegra.ph photo/video, PhotoViewer runs with `currentMessageObject == null` and a `PageBlocksAdapter` instead. That adapter is the only handle to the underlying `TLRPC.Photo` / `TLRPC.Document` and to the `TLRPC.WebPage` the article came from, so everything here goes through it. + +Rich messages (page blocks sent as a chat message) open their single media and +carousels the same way, through ChatActivity's `ChatArticlePageBlocksAdapter`, +whose parent object is the message's `MessageObject` instead of a WebPage. """ from hook_utils import get_private_field @@ -81,6 +85,52 @@ def is_telegraph(adapter): return any(host == h or host.endswith("." + h) for h in TELEGRAPH_HOSTS) +def rich_parent(adapter): + """For media opened from a rich message: the `MessageObject` carrying it, + or the bare `TL_iv.RichMessage` when the adapter has no message. None for + anything else, articles included.""" + from org.telegram.messenger import MessageObject + from org.telegram.tgnet.tl import TL_iv + + parent = adapter.getParentObject() + if isinstance(parent, MessageObject): + owner = parent.messageOwner + if owner is not None and owner.rich_message is not None: + return parent + return None + if isinstance(parent, TL_iv.RichMessage): + return parent + return None + + +def is_rich_message(adapter): + return rich_parent(adapter) is not None + + +def message_link(account, message): + """t.me link to `message`, or None when it has none. + + Only channels and supergroups have message links: public ones by username, + private ones through t.me/c/. DMs, basic groups and unsent local messages + cannot be linked to. + """ + from org.telegram.messenger import ChatObject, MessageObject, MessagesController + + if not isinstance(message, MessageObject): + return None + msg_id = message.getId() + dialog_id = message.getDialogId() + if msg_id <= 0 or dialog_id >= 0: + return None + chat = MessagesController.getInstance(account).getChat(-dialog_id) + if chat is None or not ChatObject.isChannel(chat): + return None + username = ChatObject.getPublicUsername(chat) + if username: + return "https://t.me/%s/%d" % (username, msg_id) + return "https://t.me/c/%d/%d" % (chat.id, msg_id) + + def media_at(adapter, index): """`(media, existing_file_path_or_None, is_video)` for the shown block.""" media = adapter.getMedia(index) diff --git a/src/main.py b/src/main.py index 7d11a0b..a1aa4e4 100644 --- a/src/main.py +++ b/src/main.py @@ -17,7 +17,7 @@ _BUTTON_SIZE_DP = 48.0 _LOAD_TOKEN = "tmf-%d" % id(object()) -class TelegraphMediaForwardPlugin(BasePlugin): +class ExtendedMediaForwardsPlugin(BasePlugin): def on_plugin_load(self): viewer = find_class("org.telegram.ui.PhotoViewer") if viewer is None: @@ -31,7 +31,7 @@ class TelegraphMediaForwardPlugin(BasePlugin): def create_settings(self): return [ - Header(text="Telegraph Media Forward"), + Header(text="Extended Media Forwards"), Switch( key="any_article", text="Allow on any Instant View article", @@ -39,18 +39,27 @@ class TelegraphMediaForwardPlugin(BasePlugin): subtext="Off: only telegra.ph and graph.org pages.", icon="msg_link", ), + Switch( + key="rich_messages", + text="Allow on rich messages", + default=True, + subtext="Single media and carousels inside rich (article) messages.", + icon="msg_media", + ), Text( text="How it works", subtext=( - "Open a photo or video from an article, tap the ⋮ menu and choose " + "Open a photo or video from an article or a rich message and tap " "Forward. The file is downloaded and sent from your account with " - "an Original link to the page." + "a link back to its source when it has one." ), icon="msg_info", ), ] def allows(self, adapter): + if ivmedia.is_rich_message(adapter): + return self.get_setting("rich_messages", True) if self.get_setting("any_article", False): return ivmedia.page_url(adapter) is not None return ivmedia.is_telegraph(adapter) @@ -60,7 +69,6 @@ class TelegraphMediaForwardPlugin(BasePlugin): adapter = ivmedia.adapter_of(viewer) if adapter is None: return - url = ivmedia.page_url(adapter) index = ivmedia.current_index(viewer) try: media, path, is_video = ivmedia.media_at(adapter, index) @@ -68,6 +76,8 @@ class TelegraphMediaForwardPlugin(BasePlugin): sender.notify_error(str(exc)) return account = get_selected_account() + caption = _caption_for(account, adapter) + parent = adapter.getParentObject() context = ivmedia.parent_activity(viewer) if context is None: return @@ -78,17 +88,27 @@ class TelegraphMediaForwardPlugin(BasePlugin): # The sheet sits above the viewer's window; drop it so the sent # message is what the user lands on. viewer.closePhoto(False, False) - sender.send_media(account, media, path, is_video, url, peers, comment) + sender.send_media(account, media, path, is_video, caption, peers, comment, parent) try: # ShareAlert owns its own window, so it shows over PhotoViewer just # like the stock share button does - no need to close the viewer. picker.pick_chats(context, picked, ivmedia.resources_provider(viewer)) except Exception as exc: - log("[telegraph_media_forward] picker failed: %s" % exc) + log("[extended_media_forwards] picker failed: %s" % exc) sender.notify_error(str(exc)) +def _caption_for(account, adapter): + """`(text, url)` linking back to where the media came from, or None.""" + rich = ivmedia.rich_parent(adapter) + if rich is not None: + url = ivmedia.message_link(account, rich) + return (sender.RICH_CAPTION_TEXT, url) if url else None + url = ivmedia.page_url(adapter) + return (sender.CAPTION_TEXT, url) if url else None + + class _OnPhotoShow(MethodHook): """Adds (or re-shows) the Forward item every time the viewer opens media.""" @@ -122,7 +142,7 @@ def _sync_menu(plugin, viewer): button = _add_button(plugin, menu, viewer) button.setVisibility(View.VISIBLE if visible else View.GONE) except Exception as exc: - log("[telegraph_media_forward] menu sync failed: %s" % exc) + log("[extended_media_forwards] menu sync failed: %s" % exc) def _find_button(menu): diff --git a/src/picker.py b/src/picker.py index 8b6f95e..e58f272 100644 --- a/src/picker.py +++ b/src/picker.py @@ -89,4 +89,4 @@ def _enable_focus(alert): if alert.getWindow() is not None: alert.setFocusable(True) except Exception as exc: - log("[telegraph_media_forward] focus handoff failed: %s" % exc) + log("[extended_media_forwards] focus handoff failed: %s" % exc) diff --git a/src/sender.py b/src/sender.py index 813689f..3df7e06 100644 --- a/src/sender.py +++ b/src/sender.py @@ -1,9 +1,11 @@ -"""Downloads telegra.ph media and re-sends it from the user's own account. +"""Downloads page-block media and re-sends it from the user's own account. Instant View media lives on the article's WebPage, not in any message, so it -cannot be forwarded. Instead the file is fetched through FileLoader (or reused -if already cached) and uploaded as a fresh photo/document, exactly like picking -the file from the gallery. Caption is a single markdown link to the article. +cannot be forwarded. Rich-message media is part of a message, but only as one +block of it, so forwarding would carry the whole message along. Instead the +file is fetched through FileLoader (or reused if already cached) and uploaded +as a fresh photo/document, exactly like picking the file from the gallery. The +caption, when there is a source to link to, is a single link back to it. """ import threading @@ -33,25 +35,31 @@ def notify_success(text): CAPTION_TEXT = "telegra.ph source" +RICH_CAPTION_TEXT = "message source" -def send_media(account, media, cached_path, is_video, page_url, peers, comment): - """Starts the download+send in the background. Safe to call on the UI thread.""" +def send_media(account, media, cached_path, is_video, caption, peers, comment, parent=None): + """Starts the download+send in the background. Safe to call on the UI thread. + + `caption` is `(text, url)` for a linked caption or None for none. `parent` + is the object the media belongs to (WebPage or MessageObject); FileLoader + uses it to refresh an expired file reference. + """ def worker(): try: - path = cached_path or _download(account, media, is_video) + path = cached_path or _download(account, media, is_video, parent) for peer in peers: if comment: _send(account, _text_params(comment, peer)) - _send(account, _media_params(account, media, path, is_video, page_url, peer)) + _send(account, _media_params(account, media, path, is_video, caption, peer)) except Exception as exc: - log("[telegraph_media_forward] send failed: %s" % exc) + log("[extended_media_forwards] send failed: %s" % exc) notify_error(str(exc)) return notify_success("Sent") - threading.Thread(target=worker, name="telegraph-media-forward", daemon=True).start() + threading.Thread(target=worker, name="extended-media-forwards", daemon=True).start() def _text_params(text, peer): @@ -60,12 +68,14 @@ def _text_params(text, peer): return SendMessagesHelper.SendMessageParams.of(text, peer) -def _media_params(account, media, path, is_video, page_url, peer): +def _media_params(account, media, path, is_video, caption, peer): from org.telegram.messenger import SendMessagesHelper params = SendMessagesHelper.SendMessageParams.of(None, peer) - params.caption = CAPTION_TEXT - params.entities = _link_entities(page_url) + if caption is not None: + text, url = caption + params.caption = text + params.entities = _link_entities(text, url) params.path = path if is_video: params.document = _fresh_document(account, media, path) @@ -77,14 +87,14 @@ def _media_params(account, media, path, is_video, page_url, peer): return params -def _link_entities(url): +def _link_entities(text, url): """Whole caption as one text_url entity, so it renders as a link.""" from java.util import ArrayList from org.telegram.tgnet import TLRPC entity = TLRPC.TL_messageEntityTextUrl() entity.offset = 0 - entity.length = len(CAPTION_TEXT) + entity.length = len(text) entity.url = url entities = ArrayList() entities.add(entity) @@ -119,7 +129,7 @@ def _send(account, params): try: get_send_messages_helper(account).sendMessage(params) except Exception as exc: - log("[telegraph_media_forward] sendMessage failed: %s" % exc) + log("[extended_media_forwards] sendMessage failed: %s" % exc) finally: done.set() @@ -128,7 +138,7 @@ def _send(account, params): done.wait(30) -def _download(account, media, is_video): +def _download(account, media, is_video, parent=None): """Loads the file through FileLoader and returns its on-disk path.""" from org.telegram.messenger import FileLoader, ImageLocation from org.telegram.tgnet import TLRPC @@ -136,11 +146,11 @@ def _download(account, media, is_video): loader = FileLoader.getInstance(account) if is_video or isinstance(media, TLRPC.Document): target = media - loader.loadFile(media, None, FileLoader.PRIORITY_HIGH, 0) + loader.loadFile(media, parent, FileLoader.PRIORITY_HIGH, 0) else: target = _best_size(media) loader.loadFile( - ImageLocation.getForPhoto(target, media), None, "jpg", FileLoader.PRIORITY_HIGH, 0 + ImageLocation.getForPhoto(target, media), parent, "jpg", FileLoader.PRIORITY_HIGH, 0 ) candidates = [loader.getPathToAttach(target, True), loader.getPathToAttach(target, False)] return _wait_for_file(candidates) diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index 069174a..caed0dc 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -116,6 +116,42 @@ class FakeWebPage: self.display_url = display_url +class FakeRichMessage: + """Stand-in for TL_iv.RichMessage.""" + + +class FakeMessage: + def __init__(self, rich_message=None): + self.rich_message = rich_message + + +class FakeMessageObject: + def __init__(self, msg_id, dialog_id, rich_message=None): + self.messageOwner = FakeMessage(rich_message) + self._id = msg_id + self._dialog_id = dialog_id + + def getId(self): + return self._id + + def getDialogId(self): + return self._dialog_id + + +class FakeChat: + def __init__(self, chat_id, channel, username=None): + self.id = chat_id + self.channel = channel + self.username = username + + +FAKE_CHATS = { + 100: FakeChat(100, channel=True, username="publicchan"), + 200: FakeChat(200, channel=True), + 300: FakeChat(300, channel=False), +} + + def install_fakes() -> None: base_plugin_mod = types.ModuleType("base_plugin") base_plugin_mod.BasePlugin = BasePlugin @@ -158,6 +194,22 @@ def install_fakes() -> None: ), ) tgnet_mod.TLRPC = tlrpc + tgnet_mod.__path__ = [] + tgnet_tl_mod = types.ModuleType("org.telegram.tgnet.tl") + tgnet_tl_mod.TL_iv = types.SimpleNamespace(RichMessage=FakeRichMessage) + tgnet_mod.tl = tgnet_tl_mod + sys.modules["org.telegram.tgnet.tl"] = tgnet_tl_mod + + messenger_mod = types.ModuleType("org.telegram.messenger") + messenger_mod.MessageObject = FakeMessageObject + messenger_mod.ChatObject = types.SimpleNamespace( + isChannel=lambda chat: chat.channel, + getPublicUsername=lambda chat: chat.username, + ) + messenger_mod.MessagesController = types.SimpleNamespace( + getInstance=lambda account: types.SimpleNamespace(getChat=FAKE_CHATS.get) + ) + sys.modules["org.telegram.messenger"] = messenger_mod # `java.util.ArrayList` only ever gets add()ed to and iterated. java_mod = types.ModuleType("java") @@ -346,11 +398,32 @@ def main(argv=None) -> int: plugin.allows(other) and not plugin.allows(no_page), ) + rich_msg = FakeAdapter(FakeMessageObject(5, -1000000000200, FakeRichMessage())) + rich_bare = FakeAdapter(FakeRichMessage()) + plain_msg = FakeAdapter(FakeMessageObject(5, -1000000000200, None)) + check( + "settings expose the rich_messages switch", + any(getattr(row, "key", None) == "rich_messages" for row in rows), + ) + check( + "rich message media is accepted by default (with or without a message)", + plugin.allows(rich_msg) and plugin.allows(rich_bare), + ) + check( + "message parent without a rich message is rejected", + not plugin.allows(plain_msg), + ) + plugin.set_setting("rich_messages", False) + check( + "rich_messages setting off rejects rich message media", + not plugin.allows(rich_msg) and not plugin.allows(rich_bare), + ) + ivmedia = ns.get("ivmedia") sender = ns.get("sender") caption = getattr(sender, "CAPTION_TEXT", None) link_entities = getattr(sender, "_link_entities", None) - entities = link_entities("https://telegra.ph/x") if link_entities else [] + entities = link_entities(caption, "https://telegra.ph/x") if link_entities and caption else [] entity = entities[0] if entities else None check( "caption is a single text_url entity covering the whole caption", @@ -363,6 +436,33 @@ def main(argv=None) -> int: f"got caption={caption!r} entities={entities!r}", ) + message_link = getattr(ivmedia, "message_link", None) + if message_link is not None: + # Channel dialog ids are -(chat id) in this fake, like getDialogId(). + links = ( + message_link(0, FakeMessageObject(7, -100)), + message_link(0, FakeMessageObject(8, -200)), + message_link(0, FakeMessageObject(9, -300)), + message_link(0, FakeMessageObject(9, 12345)), + message_link(0, FakeMessageObject(-1, -100)), + message_link(0, FakeRichMessage()), + ) + else: + links = None + check( + "message_link: public/private channels linked; groups, DMs, local and bare rich messages not", + links + == ( + "https://t.me/publicchan/7", + "https://t.me/c/200/8", + None, + None, + None, + None, + ), + f"got {links!r}", + ) + media_at = getattr(ivmedia, "media_at", None) no_media = getattr(ivmedia, "NoMedia", None) raised = False