generated from exteraGramPlugins/pluginBase
[nobump] Unlocked Restrictions: screenshots, copy-forwarding, protected-chat lock
Publish plugin / publish (push) Successful in 4s
Publish plugin / publish (push) Successful in 4s
Replaces the pluginBase sample with the plugin: - remove FLAG_SECURE (FlagSecureReason + Window.setFlags) for screenshots - unlock forward/copy/save UI in protected chats and re-send protected messages as copies from the user's account (media re-uploaded, albums regrouped, bot-only parts dropped) - red lock before protected chats' names in the chat list, header and info - settings toggles for all three - verify_bundle.py rewritten for the plugin - bundle.py enables the git hooks on first run; README documents them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e0586a3423
commit
5405adf485
16 files changed
+1296
-127
No files matched your search
@@ -1,7 +1,56 @@
|
|||||||
# exteraGram Plugin Base
|
# Unlocked Restrictions
|
||||||
|
|
||||||
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
|
An [exteraGram](https://exteragram.app) plugin that lifts the "restrict saving content"
|
||||||
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
|
protection on your own device: screenshots work everywhere, messages from protected chats can
|
||||||
|
be forwarded (as copies), and protected chats are marked with a red lock.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
Each feature has its own switch in the plugin settings, all on by default, and toggles take
|
||||||
|
effect without reloading the plugin.
|
||||||
|
|
||||||
|
- **Allow screenshots.** Removes `FLAG_SECURE`, so screenshots and screen recording work in
|
||||||
|
protected chats, secret chats and on the passcode screen.
|
||||||
|
- **Allow forwarding.** The forward, copy and save options appear in protected chats. Forwarding
|
||||||
|
a protected message, from the long-press menu or the side share button, sends it to the chosen
|
||||||
|
chat as a new message from your account, with no "Forwarded from" header. Unprotected
|
||||||
|
messages in the same selection are still forwarded normally.
|
||||||
|
- **Mark protected chats.** A red lock before the chat's name in the chat list, the chat header
|
||||||
|
and the chat info screen.
|
||||||
|
|
||||||
|
### How copying works
|
||||||
|
|
||||||
|
Telegram refuses to forward protected content, and also to re-send its media by file id, so a
|
||||||
|
copy is sent the same way as a message you wrote:
|
||||||
|
|
||||||
|
- text keeps its formatting; photos, videos, files, voice messages and GIFs are downloaded
|
||||||
|
(or taken from the local cache) and uploaded again; stickers, locations and contacts are sent
|
||||||
|
directly;
|
||||||
|
- albums are sent as media groups again (at most 10 items per group);
|
||||||
|
- bot-only parts are dropped: reply and inline keyboards, games, invoices; content that can't
|
||||||
|
be reproduced (polls, dice, stories, giveaways, paid media) is skipped, and only its text is
|
||||||
|
sent if there is any;
|
||||||
|
- the selection is sent once every item is ready, so one large download delays the batch;
|
||||||
|
- items that can't be copied are reported in a bulletin.
|
||||||
|
|
||||||
|
Copied videos have no preview thumbnail. The lock covers groups and channels, not private chats
|
||||||
|
where the other person enabled content protection.
|
||||||
|
|
||||||
|
## Runtime notes
|
||||||
|
|
||||||
|
Things the on-device runtime does differently from the SDK docs, all handled in `src/`:
|
||||||
|
|
||||||
|
- Java lists are not iterable from Python; use `hooking.java_list()`.
|
||||||
|
- `param.setResult()` in a before-hook does not skip the original method, so the forward hook
|
||||||
|
empties the message list instead.
|
||||||
|
- `threading.local` state is not shared between a before-hook and its after-hook.
|
||||||
|
- Java `String` values arrive as Python `str`, other `CharSequence`s as Java objects.
|
||||||
|
- Fields named like `java.lang.Object` methods (`SendMessageParams.notify`) must be set with
|
||||||
|
`hook_utils.set_private_field`.
|
||||||
|
- Exceptions escaping a Python thread vanish silently, so the copy worker catches everything.
|
||||||
|
- Shared TL objects (`TLRPC.Chat` and friends) are never modified for display purposes.
|
||||||
|
|
||||||
|
The rest of this README documents the build toolchain inherited from pluginBase.
|
||||||
|
|
||||||
## How exteraGram plugins actually work
|
## How exteraGram plugins actually work
|
||||||
|
|
||||||
@@ -34,15 +83,19 @@ This repo does not use the official Elyx/ElyxBuilder archive format — see
|
|||||||
plugin.json single source of truth for plugin metadata
|
plugin.json single source of truth for plugin metadata
|
||||||
src/ the plugin, written as an ordinary Python package
|
src/ the plugin, written as an ordinary Python package
|
||||||
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
||||||
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
screenshots.py FLAG_SECURE removal (FlagSecureReason + Window.setFlags hooks)
|
||||||
textutil.py sample top-level sibling module
|
forwarding.py unlocks forward UI and intercepts forwards of protected messages
|
||||||
|
copier.py re-sends protected messages as copies (downloads + re-uploads media, albums)
|
||||||
|
badge.py red lock before protected chats' names (chat list, header, chat info)
|
||||||
|
protection.py shared "does this chat restrict saving content" check
|
||||||
|
hooking.py shared Xposed hook helpers
|
||||||
tools/
|
tools/
|
||||||
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
||||||
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
||||||
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
|
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
|
||||||
release.py publishes a built bundle to a Gitea release (used by CI)
|
release.py publishes a built bundle to a Gitea release (used by CI)
|
||||||
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
||||||
scripts/ bash automation for the ADB debug loop (see below)
|
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/sdk-notes.md condensed SDK API surface gathered from the official docs
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,6 +114,8 @@ a private import hook (scoped to a per-module `__builtins__` dict, never touchin
|
|||||||
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
|
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
|
||||||
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
|
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
|
||||||
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
|
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
|
||||||
|
Its first run in a clone also enables the repo's git hooks (see
|
||||||
|
[Git hooks and versioning](#git-hooks-and-versioning)).
|
||||||
|
|
||||||
## Device prerequisites
|
## Device prerequisites
|
||||||
|
|
||||||
@@ -93,6 +148,43 @@ standard `ANDROID_SERIAL` env var for device selection.
|
|||||||
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
|
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
|
||||||
for editor autocomplete.
|
for editor autocomplete.
|
||||||
|
|
||||||
|
## Git hooks and versioning
|
||||||
|
|
||||||
|
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
|
||||||
|
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
|
||||||
|
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
|
||||||
|
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
|
||||||
|
|
||||||
|
**The hooks enable themselves.** Git never activates hooks from repository content, so a
|
||||||
|
fresh clone, or a repo created from this template, starts without them. The first run of
|
||||||
|
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
|
||||||
|
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
|
||||||
|
includes `--check`, so after cloning run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/bundle.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
once, before your first commit. A commit made before any build runs without the hooks.
|
||||||
|
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
|
||||||
|
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
|
||||||
|
never fails a build over it. To turn the hooks off for a clone, run
|
||||||
|
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
|
||||||
|
until the next build.
|
||||||
|
|
||||||
|
The bump is skipped when:
|
||||||
|
|
||||||
|
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
|
||||||
|
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
|
||||||
|
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
|
||||||
|
for commits written in an editor, since their message doesn't exist yet when the hook runs,
|
||||||
|
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
|
||||||
|
minor or major release),
|
||||||
|
- a merge, rebase, cherry-pick or revert is in progress.
|
||||||
|
|
||||||
|
Because every version change publishes a release (see below), a normal commit + push to
|
||||||
|
`main` ships a new patch release. Use a stop word for commits that should not.
|
||||||
|
|
||||||
## Publishing a release
|
## Publishing a release
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
||||||
@@ -108,7 +200,8 @@ The `version` field in `plugin.json` is the single source of truth:
|
|||||||
| the same `version` | rebuilds the existing release, replacing its attachment |
|
| the same `version` | rebuilds the existing release, replacing its attachment |
|
||||||
|
|
||||||
So every push leaves a downloadable build attached to the current version, and bumping
|
So every push leaves a downloadable build attached to the current version, and bumping
|
||||||
`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that
|
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
|
||||||
|
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
|
||||||
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
|
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
|
||||||
|
|
||||||
Every path runs `--check`, builds with `--release`, and gates the upload on
|
Every path runs `--check`, builds with `--release`, and gates the upload on
|
||||||
@@ -140,9 +233,11 @@ you want when publishing needs to reach beyond this repo.
|
|||||||
|
|
||||||
## Writing your own plugin
|
## Writing your own plugin
|
||||||
|
|
||||||
1. Edit `plugin.json` (id, name, description, etc.).
|
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
|
||||||
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
|
version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
|
||||||
|
2. Edit `plugin.json` (id, name, description, etc.).
|
||||||
|
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
|
||||||
only — it's executed at plugin-module top level, not inside a package).
|
only — it's executed at plugin-module top level, not inside a package).
|
||||||
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
||||||
(substitute your `id` from `plugin.json`).
|
(substitute your `id` from `plugin.json`).
|
||||||
4. `bash scripts/deploy.sh --enable` with a device connected.
|
5. `bash scripts/deploy.sh --enable` with a device connected.
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "hello_bundle",
|
"id": "unlocked_restrictions",
|
||||||
"name": "Hello Bundle",
|
"name": "Unlocked Restrictions",
|
||||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
"description": "Allows screenshots and forwarding (as a copy) in chats with protected content",
|
||||||
"author": "@yourhandle",
|
"author": "lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraPlugins/1",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Companion to scripts/pre-commit.
|
# Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py
|
||||||
|
# (it sets `core.hooksPath scripts` on its first run).
|
||||||
#
|
#
|
||||||
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
|
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
|
||||||
# the commit from a temporary index. The pre-commit bump therefore lands in the
|
# the commit from a temporary index. The pre-commit bump therefore lands in the
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Increment the patch component of plugin.json's version as part of the commit.
|
"""Increment the patch component of plugin.json's version as part of the commit.
|
||||||
|
|
||||||
|
Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its
|
||||||
|
first run (git never enables hooks from repository content by itself).
|
||||||
|
|
||||||
Runs as a pre-commit hook, which is the only commit hook whose `git add` is
|
Runs as a pre-commit hook, which is the only commit hook whose `git add` is
|
||||||
picked up by the commit currently being created:
|
picked up by the commit currently being created:
|
||||||
|
|
||||||
|
|||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
"""Red lock in front of the names of chats that restrict saving content.
|
||||||
|
|
||||||
|
Shown in the chat list (DialogCell), the chat header (ChatAvatarContainer)
|
||||||
|
and the chat info screen (ProfileActivity). Chat objects themselves are never
|
||||||
|
modified: they are shared app-wide and may be written to the database at any
|
||||||
|
moment, so only the text handed to the views is changed.
|
||||||
|
|
||||||
|
Hook state must not live in threading.local: in this runtime a before-hook
|
||||||
|
and its after-hook do not see the same thread-local storage.
|
||||||
|
"""
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
from hooking import hook_all
|
||||||
|
from protection import chat_is_protected
|
||||||
|
|
||||||
|
# An em space (replaced by the lock) plus a normal space as the gap before the
|
||||||
|
# name. The lock span is exactly as wide as the em space, so text measured
|
||||||
|
# with the placeholder keeps its width once the lock is swapped in.
|
||||||
|
PLACEHOLDER = " "
|
||||||
|
|
||||||
|
# Title of the protected chat whose DialogCell is being laid out right now,
|
||||||
|
# or None. DialogCell.buildLayout() runs on the UI thread and does not nest.
|
||||||
|
_building_title = None
|
||||||
|
|
||||||
|
|
||||||
|
def lock_span(paint):
|
||||||
|
"""An ImageSpan drawing a red lock exactly one em space wide for `paint`."""
|
||||||
|
from android.graphics import PorterDuff, PorterDuffColorFilter
|
||||||
|
from android.text.style import ImageSpan
|
||||||
|
from org.telegram.messenger import ApplicationLoader, R
|
||||||
|
from org.telegram.ui.ActionBar import Theme
|
||||||
|
|
||||||
|
drawable = ApplicationLoader.applicationContext.getResources().getDrawable(R.drawable.list_secret).mutate()
|
||||||
|
drawable.setColorFilter(
|
||||||
|
PorterDuffColorFilter(Theme.getColor(Theme.key_text_RedBold), PorterDuff.Mode.SRC_IN)
|
||||||
|
)
|
||||||
|
box = int(round(paint.measureText(PLACEHOLDER[0])))
|
||||||
|
height = int(round(paint.getTextSize() * 0.75))
|
||||||
|
ratio = drawable.getIntrinsicWidth() / max(1, drawable.getIntrinsicHeight())
|
||||||
|
width = min(box, max(1, round(height * ratio)))
|
||||||
|
# ImageSpan reports its width as bounds.right, so right-aligning the lock
|
||||||
|
# in the box keeps the span exactly `box` wide.
|
||||||
|
drawable.setBounds(box - width, 0, box, height)
|
||||||
|
return ImageSpan(drawable, ImageSpan.ALIGN_BASELINE)
|
||||||
|
|
||||||
|
|
||||||
|
def with_lock(text, paint):
|
||||||
|
"""`text` with the lock in front; a leftover bare placeholder is replaced."""
|
||||||
|
from android.text import SpannableStringBuilder, Spanned
|
||||||
|
|
||||||
|
# Java Strings arrive as Python str, other CharSequences as Java objects.
|
||||||
|
if str(text).startswith(PLACEHOLDER):
|
||||||
|
if isinstance(text, str):
|
||||||
|
text = text[len(PLACEHOLDER):]
|
||||||
|
else:
|
||||||
|
text = text.subSequence(len(PLACEHOLDER), text.length())
|
||||||
|
builder = SpannableStringBuilder(PLACEHOLDER)
|
||||||
|
builder.append(text)
|
||||||
|
builder.setSpan(lock_span(paint), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
def has_lock(text):
|
||||||
|
"""True when `text` already starts with the lock span."""
|
||||||
|
from android.text import Spanned
|
||||||
|
from android.text.style import ImageSpan
|
||||||
|
|
||||||
|
if text is None or not str(text).startswith(PLACEHOLDER) or not isinstance(text, Spanned):
|
||||||
|
return False
|
||||||
|
return len(text.getSpans(0, 1, ImageSpan)) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _protected_chat(account, dialog_id):
|
||||||
|
from org.telegram.messenger import MessagesController
|
||||||
|
|
||||||
|
if account is None or dialog_id is None or dialog_id >= 0:
|
||||||
|
return None
|
||||||
|
controller = MessagesController.getInstance(account)
|
||||||
|
chat = controller.getChat(-dialog_id)
|
||||||
|
return chat if chat_is_protected(controller, chat) else None
|
||||||
|
|
||||||
|
|
||||||
|
class DialogCellNameHook(MethodHook):
|
||||||
|
"""DialogCell.buildLayout(): lay the name out with room for the lock.
|
||||||
|
|
||||||
|
Everything positioned after the name (verified badge, mute icon) is
|
||||||
|
computed inside buildLayout(), so the space has to exist while it runs:
|
||||||
|
EscapeHook prefixes the chat's title with PLACEHOLDER when buildLayout()
|
||||||
|
passes it through AndroidUtilities.escape(). Afterwards the placeholder in
|
||||||
|
the finished single-line layout is swapped for the lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugin, enabled):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
global _building_title
|
||||||
|
_building_title = None
|
||||||
|
if not self.enabled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cell = param.thisObject
|
||||||
|
chat = _protected_chat(
|
||||||
|
get_private_field(cell, "currentAccount"),
|
||||||
|
get_private_field(cell, "currentDialogId"),
|
||||||
|
)
|
||||||
|
if chat is not None and chat.title:
|
||||||
|
_building_title = chat.title
|
||||||
|
except Exception as exc:
|
||||||
|
self.plugin.log(f"lock badge: {exc}")
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
global _building_title
|
||||||
|
if _building_title is None:
|
||||||
|
return
|
||||||
|
_building_title = None
|
||||||
|
try:
|
||||||
|
self._swap_in_lock(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
self.plugin.log(f"lock badge: {exc}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _swap_in_lock(cell):
|
||||||
|
from android.text import Layout, StaticLayout
|
||||||
|
|
||||||
|
layout = cell.nameLayout
|
||||||
|
if layout is None or layout.getLineCount() != 1:
|
||||||
|
return
|
||||||
|
text = layout.getText()
|
||||||
|
if not str(text).startswith(PLACEHOLDER):
|
||||||
|
return
|
||||||
|
paint = layout.getPaint()
|
||||||
|
cell.nameLayout = StaticLayout(
|
||||||
|
with_lock(text, paint),
|
||||||
|
paint,
|
||||||
|
layout.getWidth(),
|
||||||
|
Layout.Alignment.ALIGN_NORMAL,
|
||||||
|
1.0,
|
||||||
|
0.0,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EscapeHook(MethodHook):
|
||||||
|
"""AndroidUtilities.escape(String): see DialogCellNameHook."""
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
title = _building_title
|
||||||
|
if title is None or param.args[0] != title:
|
||||||
|
return
|
||||||
|
result = param.getResult()
|
||||||
|
if result is not None and not str(result).startswith(PLACEHOLDER):
|
||||||
|
param.setResult(PLACEHOLDER + str(result))
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHeaderTitleHook(MethodHook):
|
||||||
|
"""ChatAvatarContainer.setTitle(CharSequence, boolean scam, boolean fake,
|
||||||
|
boolean verified, boolean premium, EmojiStatus, boolean animated). The
|
||||||
|
one-argument overload delegates here, so only the long one is handled."""
|
||||||
|
|
||||||
|
def __init__(self, plugin, enabled):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
args = param.args
|
||||||
|
if not self.enabled() or len(args) < 7 or args[0] is None or has_lock(args[0]):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
container = param.thisObject
|
||||||
|
fragment = get_private_field(container, "parentFragment")
|
||||||
|
if fragment is None:
|
||||||
|
return
|
||||||
|
if _protected_chat(fragment.getCurrentAccount(), fragment.getDialogId()) is None:
|
||||||
|
return
|
||||||
|
paint = get_private_field(container, "titleTextView").getPaint()
|
||||||
|
args[0] = with_lock(args[0], paint)
|
||||||
|
except Exception as exc:
|
||||||
|
self.plugin.log(f"lock badge: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileNameHook(MethodHook):
|
||||||
|
"""ProfileActivity.updateProfileData(boolean): prefix the chat info name."""
|
||||||
|
|
||||||
|
def __init__(self, plugin, enabled):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
if not self.enabled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
profile = param.thisObject
|
||||||
|
chat_id = get_private_field(profile, "chatId")
|
||||||
|
if not chat_id or _protected_chat(profile.getCurrentAccount(), -chat_id) is None:
|
||||||
|
return
|
||||||
|
views = get_private_field(profile, "nameTextView")
|
||||||
|
for i in range(len(views) if views is not None else 0):
|
||||||
|
view = views[i]
|
||||||
|
if view is None:
|
||||||
|
continue
|
||||||
|
text = view.getText()
|
||||||
|
if text is not None and len(str(text)) > 0 and not has_lock(text):
|
||||||
|
view.setText(with_lock(text, view.getPaint()))
|
||||||
|
except Exception as exc:
|
||||||
|
self.plugin.log(f"lock badge: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def install(plugin, enabled):
|
||||||
|
hooked = hook_all(plugin, "org.telegram.ui.Cells.DialogCell", "buildLayout", DialogCellNameHook(plugin, enabled))
|
||||||
|
hooked += hook_all(plugin, "org.telegram.messenger.AndroidUtilities", "escape", EscapeHook())
|
||||||
|
hooked += hook_all(
|
||||||
|
plugin, "org.telegram.ui.Components.ChatAvatarContainer", "setTitle", ChatHeaderTitleHook(plugin, enabled)
|
||||||
|
)
|
||||||
|
hooked += hook_all(plugin, "org.telegram.ui.ProfileActivity", "updateProfileData", ProfileNameHook(plugin, enabled))
|
||||||
|
return hooked
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_dialogs():
|
||||||
|
"""Asks the open chat list to rebuild its rows so a toggle shows at once."""
|
||||||
|
from client_utils import get_notification_center
|
||||||
|
from org.telegram.messenger import MessagesController, NotificationCenter
|
||||||
|
|
||||||
|
get_notification_center().postNotificationName(
|
||||||
|
NotificationCenter.updateInterfaces, MessagesController.UPDATE_MASK_NAME
|
||||||
|
)
|
||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
"""Re-sends messages from protected chats as fresh copies.
|
||||||
|
|
||||||
|
Telegram's server refuses to forward protected content, and also refuses to
|
||||||
|
re-send its media by file id, so every file is downloaded (or taken from the
|
||||||
|
local cache) and uploaded again as a new file.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from android_utils import log, run_on_ui_thread
|
||||||
|
from hook_utils import set_private_field
|
||||||
|
from hooking import java_list
|
||||||
|
from protection import chat_is_protected
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT_S = 600
|
||||||
|
DOWNLOAD_POLL_S = 0.5
|
||||||
|
MAX_ALBUM_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedMessage(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def notify_error(text):
|
||||||
|
from ui.bulletin import BulletinHelper
|
||||||
|
|
||||||
|
BulletinHelper.show_error(text)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_info(text):
|
||||||
|
from ui.bulletin import BulletinHelper
|
||||||
|
|
||||||
|
BulletinHelper.show_info(text)
|
||||||
|
|
||||||
|
|
||||||
|
def is_protected(message):
|
||||||
|
"""True when Telegram would refuse to forward `message`.
|
||||||
|
|
||||||
|
Reads the raw `noforwards` fields instead of calling
|
||||||
|
MessagesController.isChatNoForwards() and friends, since the plugin hooks
|
||||||
|
those to return false.
|
||||||
|
"""
|
||||||
|
from org.telegram.messenger import MessagesController
|
||||||
|
|
||||||
|
owner = message.messageOwner
|
||||||
|
if owner is None:
|
||||||
|
return False
|
||||||
|
if owner.noforwards:
|
||||||
|
return True
|
||||||
|
controller = MessagesController.getInstance(message.currentAccount)
|
||||||
|
dialog_id = message.getDialogId()
|
||||||
|
if dialog_id < 0:
|
||||||
|
return chat_is_protected(controller, controller.getChat(-dialog_id))
|
||||||
|
user_full = controller.getUserFull(dialog_id)
|
||||||
|
if user_full is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
getattr(user_full, "noforwards_peer_enabled", False)
|
||||||
|
or getattr(user_full, "noforwards_my_enabled", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_messages(plugin, messages, peer, hide_caption, notify, schedule_date, top_msg):
|
||||||
|
"""Starts copying `messages` to `peer` in the background. Call on the UI thread."""
|
||||||
|
count = len(messages)
|
||||||
|
notify_info(f"Copying {count} protected message{'s' if count != 1 else ''}…")
|
||||||
|
|
||||||
|
def copy_all():
|
||||||
|
failed = 0
|
||||||
|
built = [] # (message, params, has_media), in chat order
|
||||||
|
for message in sorted(messages, key=lambda m: m.getId()):
|
||||||
|
try:
|
||||||
|
params, has_media = _build_params(message, peer, hide_caption)
|
||||||
|
except UnsupportedMessage as exc:
|
||||||
|
plugin.log(f"skipping message {message.getId()}: {exc}")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
plugin.log(f"failed to prepare message {message.getId()}: {exc}")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
# `notify` clashes with Object.notify() in Chaquopy, so plain
|
||||||
|
# attribute assignment fails; set the field through reflection.
|
||||||
|
set_private_field(params, "notify", notify)
|
||||||
|
params.scheduleDate = schedule_date
|
||||||
|
if top_msg is not None:
|
||||||
|
params.replyToMsg = top_msg
|
||||||
|
params.replyToTopMsg = top_msg
|
||||||
|
built.append((message, params, has_media))
|
||||||
|
|
||||||
|
_group_albums(built)
|
||||||
|
for message, params, _ in built:
|
||||||
|
_send_on_ui_thread(message.currentAccount, params)
|
||||||
|
if failed:
|
||||||
|
notify_error(f"{failed} of {count} message{'s' if count != 1 else ''} could not be copied")
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
# An exception escaping a Python thread is silently lost on device.
|
||||||
|
try:
|
||||||
|
copy_all()
|
||||||
|
except Exception:
|
||||||
|
plugin.log("copy worker crashed: " + traceback.format_exc())
|
||||||
|
notify_error("Copying protected messages failed")
|
||||||
|
|
||||||
|
threading.Thread(target=worker, name="unlocked-restrictions-copy", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def album_runs(keys):
|
||||||
|
"""Splits a sequence of album keys into runs of consecutive equal non-zero
|
||||||
|
keys, at most MAX_ALBUM_SIZE long. Returns (start, end) index pairs for
|
||||||
|
runs of two or more; key 0 means "not part of an album"."""
|
||||||
|
runs = []
|
||||||
|
i = 0
|
||||||
|
while i < len(keys):
|
||||||
|
j = i + 1
|
||||||
|
while j < len(keys) and keys[i] != 0 and keys[j] == keys[i] and j - i < MAX_ALBUM_SIZE:
|
||||||
|
j += 1
|
||||||
|
if j - i > 1:
|
||||||
|
runs.append((i, j))
|
||||||
|
i = j
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
def _group_albums(built):
|
||||||
|
"""Tags copies of album items so they are sent as one media group again.
|
||||||
|
|
||||||
|
SendMessagesHelper groups messages whose params share a "groupId" and
|
||||||
|
sends the whole group once it sees the one marked "final" — the same
|
||||||
|
mechanism the attach menu uses. Items whose media had to be dropped are
|
||||||
|
sent on their own.
|
||||||
|
"""
|
||||||
|
from java.util import HashMap
|
||||||
|
|
||||||
|
keys = [
|
||||||
|
message.messageOwner.grouped_id if has_media else 0 for message, _, has_media in built
|
||||||
|
]
|
||||||
|
for start, end in album_runs(keys):
|
||||||
|
group_id = str(random.getrandbits(62) + 1)
|
||||||
|
for index in range(start, end):
|
||||||
|
params = built[index][1]
|
||||||
|
params.params = HashMap()
|
||||||
|
params.params.put("groupId", group_id)
|
||||||
|
if index == end - 1:
|
||||||
|
params.params.put("final", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _send_on_ui_thread(account, params):
|
||||||
|
from org.telegram.messenger import SendMessagesHelper
|
||||||
|
|
||||||
|
done = threading.Event()
|
||||||
|
|
||||||
|
def send():
|
||||||
|
try:
|
||||||
|
SendMessagesHelper.getInstance(account).sendMessage(params)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"[unlocked_restrictions] send failed: {exc}")
|
||||||
|
finally:
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
run_on_ui_thread(send)
|
||||||
|
# Queue sends one at a time so copies keep the original order.
|
||||||
|
done.wait(30)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_params(text, peer):
|
||||||
|
from org.telegram.messenger import SendMessagesHelper
|
||||||
|
|
||||||
|
return SendMessagesHelper.SendMessageParams.of(text, peer)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_params(message, peer, hide_caption):
|
||||||
|
"""Builds a plain user-account send of `message`'s content.
|
||||||
|
|
||||||
|
Only what a regular client can send is copied. Bot-only parts (reply and
|
||||||
|
inline keyboards, games, invoices) and content that cannot be reproduced
|
||||||
|
(polls, dice, stories, giveaways, paid media) are dropped; if the message
|
||||||
|
has text, that text is still sent on its own.
|
||||||
|
"""
|
||||||
|
from org.telegram.messenger import MessageObject
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
owner = message.messageOwner
|
||||||
|
media = MessageObject.getMedia(owner)
|
||||||
|
text = owner.message or ""
|
||||||
|
entities = owner.entities
|
||||||
|
|
||||||
|
params = _new_params(None, peer)
|
||||||
|
has_media = True
|
||||||
|
if isinstance(media, TLRPC.TL_messageMediaPhoto) and media.photo is not None:
|
||||||
|
params.photo = _reupload_photo(message, media.photo)
|
||||||
|
elif isinstance(media, TLRPC.TL_messageMediaDocument) and media.document is not None:
|
||||||
|
if message.isAnyKindOfSticker():
|
||||||
|
# Stickers belong to public sets, so the original file id is fine.
|
||||||
|
params.document = media.document
|
||||||
|
else:
|
||||||
|
params.document, params.path = _reupload_document(message, media.document)
|
||||||
|
elif isinstance(media, (TLRPC.TL_messageMediaGeo, TLRPC.TL_messageMediaVenue)):
|
||||||
|
params.location = media
|
||||||
|
elif isinstance(media, TLRPC.TL_messageMediaContact):
|
||||||
|
params.user = _contact_user(media)
|
||||||
|
else:
|
||||||
|
# No media, a link preview (regenerated from the text), or media a
|
||||||
|
# user account cannot send.
|
||||||
|
has_media = False
|
||||||
|
|
||||||
|
if has_media:
|
||||||
|
if text and not hide_caption:
|
||||||
|
params.caption = text
|
||||||
|
params.entities = entities
|
||||||
|
params.invert_media = bool(getattr(owner, "invert_media", False))
|
||||||
|
params.hasMediaSpoilers = bool(getattr(media, "spoiler", False))
|
||||||
|
else:
|
||||||
|
if not text:
|
||||||
|
kind = media.getClass().getSimpleName() if media is not None else "empty message"
|
||||||
|
raise UnsupportedMessage(f"nothing a user account can send ({kind})")
|
||||||
|
params = _new_params(text, peer)
|
||||||
|
params.entities = entities
|
||||||
|
|
||||||
|
# Never carry over bot-only fields.
|
||||||
|
params.replyMarkup = None
|
||||||
|
params.params = None
|
||||||
|
params.webPage = None
|
||||||
|
return params, has_media
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_user(media):
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
user = TLRPC.TL_user()
|
||||||
|
user.id = media.user_id
|
||||||
|
user.phone = media.phone_number
|
||||||
|
user.first_name = media.first_name or ""
|
||||||
|
user.last_name = media.last_name or ""
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _reupload_photo(message, photo):
|
||||||
|
from org.telegram.messenger import FileLoader, ImageLocation, SendMessagesHelper
|
||||||
|
|
||||||
|
if photo is None:
|
||||||
|
raise UnsupportedMessage("photo is missing")
|
||||||
|
best = None
|
||||||
|
for size in java_list(photo.sizes):
|
||||||
|
if size.location is None or size.w <= 0 or size.h <= 0:
|
||||||
|
continue
|
||||||
|
if best is None or size.w * size.h > best.w * best.h:
|
||||||
|
best = size
|
||||||
|
if best is None:
|
||||||
|
raise UnsupportedMessage("photo has no downloadable size")
|
||||||
|
|
||||||
|
account = message.currentAccount
|
||||||
|
loader = FileLoader.getInstance(account)
|
||||||
|
candidates = [loader.getPathToAttach(best, True), loader.getPathToAttach(best, False)]
|
||||||
|
path = _existing(candidates)
|
||||||
|
if path is None:
|
||||||
|
loader.loadFile(
|
||||||
|
ImageLocation.getForPhoto(best, photo), message, "jpg", FileLoader.PRIORITY_HIGH, 0
|
||||||
|
)
|
||||||
|
path = _wait_for_file(candidates)
|
||||||
|
|
||||||
|
new_photo = SendMessagesHelper.getInstance(account).generatePhotoSizes(path, None)
|
||||||
|
if new_photo is None:
|
||||||
|
raise UnsupportedMessage("could not re-encode photo")
|
||||||
|
return new_photo
|
||||||
|
|
||||||
|
|
||||||
|
def _reupload_document(message, document):
|
||||||
|
from java.util import ArrayList
|
||||||
|
from org.telegram.messenger import FileLoader
|
||||||
|
from org.telegram.tgnet import ConnectionsManager, TLRPC
|
||||||
|
|
||||||
|
account = message.currentAccount
|
||||||
|
loader = FileLoader.getInstance(account)
|
||||||
|
candidates = [loader.getPathToAttach(document, True), loader.getPathToAttach(document, False)]
|
||||||
|
attach_path = message.messageOwner.attachPath
|
||||||
|
if attach_path:
|
||||||
|
from java.io import File
|
||||||
|
|
||||||
|
candidates.insert(0, File(attach_path))
|
||||||
|
path = _existing(candidates)
|
||||||
|
if path is None:
|
||||||
|
loader.loadFile(document, message, FileLoader.PRIORITY_HIGH, 0)
|
||||||
|
path = _wait_for_file(candidates)
|
||||||
|
|
||||||
|
# id == 0 and access_hash == 0 make SendMessagesHelper upload `path` as a
|
||||||
|
# new file instead of referencing the protected original.
|
||||||
|
copy = TLRPC.TL_document()
|
||||||
|
copy.id = 0
|
||||||
|
copy.access_hash = 0
|
||||||
|
copy.file_reference = bytes()
|
||||||
|
copy.date = ConnectionsManager.getInstance(account).getCurrentTime()
|
||||||
|
copy.mime_type = document.mime_type
|
||||||
|
copy.size = document.size
|
||||||
|
copy.dc_id = 0
|
||||||
|
copy.attributes = ArrayList(document.attributes)
|
||||||
|
return copy, path
|
||||||
|
|
||||||
|
|
||||||
|
def _existing(files):
|
||||||
|
for f in files:
|
||||||
|
if f is not None and f.exists() and f.length() > 0:
|
||||||
|
return f.getAbsolutePath()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_file(files):
|
||||||
|
deadline = time.monotonic() + DOWNLOAD_TIMEOUT_S
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
path = _existing(files)
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
time.sleep(DOWNLOAD_POLL_S)
|
||||||
|
raise UnsupportedMessage("download timed out")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from base_plugin import MethodHook
|
||||||
|
from hooking import ReturnFalseHook, hook_all, java_list
|
||||||
|
import copier
|
||||||
|
|
||||||
|
|
||||||
|
class CanForwardHook(MethodHook):
|
||||||
|
"""MessageObject.canForwardMessage(): evaluate as if `noforwards` were unset.
|
||||||
|
|
||||||
|
When the original says no and the message is flagged `noforwards`, the
|
||||||
|
method is called again with the flag cleared and the flag is restored
|
||||||
|
straight away, because copier.is_protected() still needs to see it. (A
|
||||||
|
before/after pair can't carry the message between the two callbacks in
|
||||||
|
this runtime.) The nested call sees `noforwards` unset, so it does not
|
||||||
|
recurse further.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, enabled):
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
if param.getResult() or not self.enabled():
|
||||||
|
return
|
||||||
|
message = param.thisObject
|
||||||
|
owner = getattr(message, "messageOwner", None)
|
||||||
|
if owner is None or not owner.noforwards:
|
||||||
|
return
|
||||||
|
owner.noforwards = False
|
||||||
|
try:
|
||||||
|
param.setResult(bool(param.method.invoke(message)))
|
||||||
|
finally:
|
||||||
|
owner.noforwards = True
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardInterceptHook(MethodHook):
|
||||||
|
"""SendMessagesHelper.sendMessage(ArrayList<MessageObject> messages, long peer,
|
||||||
|
boolean forwardFromMyName, boolean hideCaption, boolean notify, int scheduleDate, ...)
|
||||||
|
|
||||||
|
Protected messages are never forwarded: they are pulled out of the list
|
||||||
|
and re-sent as ordinary messages from the user's account (see copier);
|
||||||
|
the rest are forwarded as usual. Every overload shares the leading six
|
||||||
|
parameters, and the shorter ones delegate to the longest, so the outermost
|
||||||
|
call does the split and the inner calls see only unprotected messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugin, enabled):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
if not self.enabled():
|
||||||
|
return
|
||||||
|
from java.util import ArrayList
|
||||||
|
|
||||||
|
args = param.args
|
||||||
|
if len(args) < 6 or not isinstance(args[0], ArrayList) or args[0].isEmpty():
|
||||||
|
return
|
||||||
|
flagged = [(m, self._is_protected(m)) for m in java_list(args[0])]
|
||||||
|
protected = [m for m, is_protected in flagged if is_protected]
|
||||||
|
if not protected:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Removing the protected messages must happen even if copying fails,
|
||||||
|
# otherwise the original sendMessage() would forward them. setResult()
|
||||||
|
# does not reliably skip the original here, so the list is replaced
|
||||||
|
# instead: with nothing left, sendMessage() returns early on its own
|
||||||
|
# isEmpty() check, and the overloads it delegates to get the same list.
|
||||||
|
remaining = ArrayList()
|
||||||
|
for m, is_protected in flagged:
|
||||||
|
if not is_protected:
|
||||||
|
remaining.add(m)
|
||||||
|
args[0] = remaining
|
||||||
|
|
||||||
|
try:
|
||||||
|
copier.copy_messages(
|
||||||
|
plugin=self.plugin,
|
||||||
|
messages=protected,
|
||||||
|
peer=int(args[1]),
|
||||||
|
hide_caption=bool(args[3]),
|
||||||
|
notify=bool(args[4]),
|
||||||
|
schedule_date=int(args[5]),
|
||||||
|
top_msg=self._topic_root(args),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.plugin.log(f"copy failed: {exc}")
|
||||||
|
copier.notify_error(f"Could not copy protected messages: {exc}")
|
||||||
|
|
||||||
|
def _is_protected(self, message):
|
||||||
|
try:
|
||||||
|
return copier.is_protected(message)
|
||||||
|
except Exception as exc:
|
||||||
|
# When in doubt, copy: a copy always works, a forward of a
|
||||||
|
# protected message never does.
|
||||||
|
self.plugin.log(f"protection check failed, copying instead: {exc}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _topic_root(args):
|
||||||
|
"""Forwarding into a forum topic passes the topic's root message."""
|
||||||
|
from org.telegram.messenger import MessageObject
|
||||||
|
|
||||||
|
for i in range(6, len(args)):
|
||||||
|
if isinstance(args[i], MessageObject):
|
||||||
|
return args[i]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install(plugin, enabled):
|
||||||
|
no_forwards = ReturnFalseHook(enabled)
|
||||||
|
hooked = 0
|
||||||
|
for method in ("isChatNoForwards", "isUserNoForwards", "isPeerNoForwards"):
|
||||||
|
hooked += hook_all(plugin, "org.telegram.messenger.MessagesController", method, no_forwards)
|
||||||
|
hooked += hook_all(plugin, "org.telegram.ui.ChatActivity", "hasSelectedNoforwardsMessage", no_forwards)
|
||||||
|
hooked += hook_all(
|
||||||
|
plugin, "org.telegram.messenger.MessageObject", "canForwardMessage", CanForwardHook(enabled)
|
||||||
|
)
|
||||||
|
hooked += hook_all(
|
||||||
|
plugin,
|
||||||
|
"org.telegram.messenger.SendMessagesHelper",
|
||||||
|
"sendMessage",
|
||||||
|
ForwardInterceptHook(plugin, enabled),
|
||||||
|
)
|
||||||
|
return hooked
|
||||||
@@ -1 +0,0 @@
|
|||||||
from .template import render, TemplateError
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from textutil import normalize
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def render(template: str, name: str) -> str:
|
|
||||||
normalized = normalize(name)
|
|
||||||
try:
|
|
||||||
return template.format(name=normalized)
|
|
||||||
except (KeyError, IndexError, ValueError):
|
|
||||||
raise TemplateError(f"unsupported placeholder in {template!r}")
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from base_plugin import MethodHook
|
||||||
|
from hook_utils import find_class
|
||||||
|
|
||||||
|
|
||||||
|
def java_list(items):
|
||||||
|
"""Copies a java.util.List into a Python list (Java lists aren't iterable here)."""
|
||||||
|
if items is None:
|
||||||
|
return []
|
||||||
|
return [items.get(i) for i in range(items.size())]
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnFalseHook(MethodHook):
|
||||||
|
"""Forces a boolean method to return false while `enabled()` is true."""
|
||||||
|
|
||||||
|
def __init__(self, enabled):
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
if self.enabled():
|
||||||
|
param.setResult(False)
|
||||||
|
|
||||||
|
|
||||||
|
def hook_all(plugin, class_name, method_name, hook):
|
||||||
|
"""Hooks every overload of `class_name.method_name`; returns how many were hooked."""
|
||||||
|
clazz = find_class(class_name)
|
||||||
|
if clazz is None:
|
||||||
|
plugin.log(f"class not found: {class_name}")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
unhooks = plugin.hook_all_methods(clazz, method_name, hook)
|
||||||
|
except Exception as exc:
|
||||||
|
plugin.log(f"failed to hook {class_name}.{method_name}: {exc}")
|
||||||
|
return 0
|
||||||
|
count = len(unhooks) if unhooks else 0
|
||||||
|
if count == 0:
|
||||||
|
plugin.log(f"no overloads hooked for {class_name}.{method_name}")
|
||||||
|
return count
|
||||||
+70
-36
@@ -1,50 +1,84 @@
|
|||||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
from base_plugin import BasePlugin
|
||||||
from android_utils import log
|
from ui.settings import Header, Switch, Text
|
||||||
from ui.settings import Header, Input, Text
|
import badge
|
||||||
from greeting import render, TemplateError
|
import forwarding
|
||||||
|
import screenshots
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = "Hello, {name}!"
|
SCREENSHOTS_KEY = "allow_screenshots"
|
||||||
|
FORWARDING_KEY = "allow_forwarding"
|
||||||
|
LOCK_BADGE_KEY = "protected_lock_badge"
|
||||||
|
|
||||||
|
|
||||||
class HelloPlugin(BasePlugin):
|
class UnlockedRestrictionsPlugin(BasePlugin):
|
||||||
def on_plugin_load(self):
|
def on_plugin_load(self):
|
||||||
log("hello_bundle loaded")
|
# Hooks stay installed for the plugin's lifetime and check the
|
||||||
self.add_on_send_message_hook()
|
# settings on every call, so toggles apply without a reload.
|
||||||
|
screenshots_hooked = screenshots.install(self, self._screenshots_enabled)
|
||||||
|
forwarding_hooked = forwarding.install(self, self._forwarding_enabled)
|
||||||
|
badge_hooked = badge.install(self, self._lock_badge_enabled)
|
||||||
|
self.log(
|
||||||
|
f"unlocked_restrictions loaded: {screenshots_hooked} screenshot hook(s), "
|
||||||
|
f"{forwarding_hooked} forwarding hook(s), {badge_hooked} lock badge hook(s)"
|
||||||
|
)
|
||||||
|
if self._screenshots_enabled():
|
||||||
|
self._clear_current_window()
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
def on_plugin_unload(self):
|
||||||
log("hello_bundle unloaded")
|
self.log("unlocked_restrictions unloaded")
|
||||||
|
|
||||||
|
def _screenshots_enabled(self):
|
||||||
|
return bool(self.get_setting(SCREENSHOTS_KEY, True))
|
||||||
|
|
||||||
|
def _forwarding_enabled(self):
|
||||||
|
return bool(self.get_setting(FORWARDING_KEY, True))
|
||||||
|
|
||||||
|
def _lock_badge_enabled(self):
|
||||||
|
return bool(self.get_setting(LOCK_BADGE_KEY, True))
|
||||||
|
|
||||||
|
def _on_lock_badge_change(self, enabled):
|
||||||
|
try:
|
||||||
|
badge.refresh_dialogs()
|
||||||
|
except Exception as exc:
|
||||||
|
self.log(f"could not refresh the chat list: {exc}")
|
||||||
|
|
||||||
|
def _on_screenshots_change(self, enabled):
|
||||||
|
if enabled:
|
||||||
|
self._clear_current_window()
|
||||||
|
|
||||||
|
def _clear_current_window(self):
|
||||||
|
try:
|
||||||
|
screenshots.clear_current_window()
|
||||||
|
except Exception as exc:
|
||||||
|
self.log(f"could not clear FLAG_SECURE on the current window: {exc}")
|
||||||
|
|
||||||
def create_settings(self):
|
def create_settings(self):
|
||||||
return [
|
return [
|
||||||
Header(text="Hello Bundle"),
|
Header(text="Unlocked Restrictions"),
|
||||||
Input(
|
Switch(
|
||||||
key="template",
|
key=SCREENSHOTS_KEY,
|
||||||
text="Greeting template",
|
text="Allow screenshots",
|
||||||
default=DEFAULT_TEMPLATE,
|
default=True,
|
||||||
subtext="Use {name} as placeholder.",
|
subtext="Screenshots and screen recording in protected chats, secret chats and the passcode screen.",
|
||||||
icon="msg_edit",
|
icon="msg_photos",
|
||||||
|
on_change=self._on_screenshots_change,
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key=FORWARDING_KEY,
|
||||||
|
text="Allow forwarding",
|
||||||
|
default=True,
|
||||||
|
subtext="Forwarding from protected chats sends a copy of the message instead.",
|
||||||
|
icon="msg_forward",
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key=LOCK_BADGE_KEY,
|
||||||
|
text="Mark protected chats",
|
||||||
|
default=True,
|
||||||
|
subtext="Shows a red lock before the names of chats that restrict saving content.",
|
||||||
|
icon="msg_secret",
|
||||||
|
on_change=self._on_lock_badge_change,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
text="Example",
|
text="Copies have no \"Forwarded from\" header. Media is downloaded and uploaded again, so large files take a while.",
|
||||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
|
||||||
icon="msg_info",
|
icon="msg_info",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def on_send_message_hook(self, account, params):
|
|
||||||
message = getattr(params, "message", None)
|
|
||||||
if not isinstance(message, str):
|
|
||||||
return HookResult()
|
|
||||||
stripped = message.strip()
|
|
||||||
if not stripped.startswith(".hello"):
|
|
||||||
return HookResult()
|
|
||||||
parts = stripped.split(" ", 1)
|
|
||||||
name = parts[1] if len(parts) > 1 else ""
|
|
||||||
if not name.strip():
|
|
||||||
params.message = "Usage: .hello <name>"
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
|
|
||||||
except TemplateError as exc:
|
|
||||||
params.message = "Bad template: " + str(exc)
|
|
||||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
def chat_is_protected(controller, chat):
|
||||||
|
"""True when `chat` (or the supergroup it migrated to) restricts saving content.
|
||||||
|
|
||||||
|
Reads the raw `noforwards` field instead of calling
|
||||||
|
MessagesController.isChatNoForwards(), since the plugin hooks that to
|
||||||
|
return false.
|
||||||
|
"""
|
||||||
|
if chat is None:
|
||||||
|
return False
|
||||||
|
if chat.migrated_to is not None:
|
||||||
|
migrated = controller.getChat(chat.migrated_to.channel_id)
|
||||||
|
if migrated is not None:
|
||||||
|
return bool(migrated.noforwards)
|
||||||
|
return bool(chat.noforwards)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from base_plugin import MethodHook
|
||||||
|
from hooking import ReturnFalseHook, hook_all
|
||||||
|
|
||||||
|
# android.view.WindowManager.LayoutParams.FLAG_SECURE
|
||||||
|
FLAG_SECURE = 0x2000
|
||||||
|
|
||||||
|
|
||||||
|
class StripFlagSecureHook(MethodHook):
|
||||||
|
"""Window.setFlags(flags, mask): drop FLAG_SECURE from the flags being set.
|
||||||
|
|
||||||
|
Window.addFlags() delegates to setFlags(), so this catches every path that
|
||||||
|
tries to secure a window, including ones outside FlagSecureReason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, enabled):
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
if not self.enabled():
|
||||||
|
return
|
||||||
|
args = param.args
|
||||||
|
if len(args) != 2:
|
||||||
|
return
|
||||||
|
flags = int(args[0])
|
||||||
|
if flags & FLAG_SECURE:
|
||||||
|
args[0] = flags & ~FLAG_SECURE
|
||||||
|
|
||||||
|
|
||||||
|
def install(plugin, enabled):
|
||||||
|
hooked = hook_all(
|
||||||
|
plugin,
|
||||||
|
"org.telegram.messenger.FlagSecureReason",
|
||||||
|
"isSecuredNow",
|
||||||
|
ReturnFalseHook(enabled),
|
||||||
|
)
|
||||||
|
hooked += hook_all(plugin, "android.view.Window", "setFlags", StripFlagSecureHook(enabled))
|
||||||
|
return hooked
|
||||||
|
|
||||||
|
|
||||||
|
def clear_current_window():
|
||||||
|
"""Drops FLAG_SECURE from the window that is already on screen."""
|
||||||
|
from android_utils import run_on_ui_thread
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
fragment = get_last_fragment()
|
||||||
|
activity = fragment.getParentActivity() if fragment is not None else None
|
||||||
|
if activity is not None:
|
||||||
|
activity.getWindow().clearFlags(FLAG_SECURE)
|
||||||
|
|
||||||
|
run_on_ui_thread(clear)
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
def normalize(name: str) -> str:
|
|
||||||
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
|
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
|
||||||
|
|
||||||
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
|
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
|
||||||
|
|
||||||
|
Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on
|
||||||
|
first run; see ensure_git_hooks().
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,13 +12,18 @@ import argparse
|
|||||||
import ast
|
import ast
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
SRC_DIR = REPO_ROOT / "src"
|
SRC_DIR = REPO_ROOT / "src"
|
||||||
|
HOOKS_DIR = "scripts"
|
||||||
|
HOOK_NAMES = ("pre-commit", "post-commit")
|
||||||
PLUGIN_JSON = REPO_ROOT / "plugin.json"
|
PLUGIN_JSON = REPO_ROOT / "plugin.json"
|
||||||
|
|
||||||
RECOGNIZED_KEYS = {
|
RECOGNIZED_KEYS = {
|
||||||
@@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_git_hooks() -> None:
|
||||||
|
"""Point git at scripts/ for hooks, once per clone.
|
||||||
|
|
||||||
|
Git never enables hooks from repository content, so a fresh clone (or a
|
||||||
|
repo created from the template) starts without them. Every workflow runs
|
||||||
|
this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh,
|
||||||
|
which makes it the one place that can switch them on without an extra
|
||||||
|
setup step. Skipped in CI, outside a git work tree, and when
|
||||||
|
core.hooksPath is already set (including to something else on purpose).
|
||||||
|
Never fails the build.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CI"):
|
||||||
|
return
|
||||||
|
hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES]
|
||||||
|
if not any(h.is_file() for h in hooks):
|
||||||
|
return
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
top = git("rev-parse", "--show-toplevel")
|
||||||
|
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT:
|
||||||
|
return
|
||||||
|
if git("config", "--get", "core.hooksPath").stdout.strip():
|
||||||
|
return
|
||||||
|
# Git silently skips hooks that are not executable.
|
||||||
|
for hook in hooks:
|
||||||
|
if hook.is_file():
|
||||||
|
mode = hook.stat().st_mode
|
||||||
|
hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0:
|
||||||
|
print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
def parse_args(argv):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
|
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
|
||||||
@@ -318,6 +363,7 @@ def parse_args(argv):
|
|||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
|
ensure_git_hooks()
|
||||||
release = bool(args.release)
|
release = bool(args.release)
|
||||||
try:
|
try:
|
||||||
data = load_metadata()
|
data = load_metadata()
|
||||||
|
|||||||
+299
-59
@@ -45,6 +45,43 @@ class HookResult:
|
|||||||
|
|
||||||
|
|
||||||
LOG_LINES: list[str] = []
|
LOG_LINES: list[str] = []
|
||||||
|
HOOKED: list[tuple[str, str, object]] = []
|
||||||
|
|
||||||
|
# Classes the plugin is expected to hook, mapped to the hooked method names.
|
||||||
|
EXPECTED_HOOKS = {
|
||||||
|
("org.telegram.messenger.FlagSecureReason", "isSecuredNow"),
|
||||||
|
("android.view.Window", "setFlags"),
|
||||||
|
("org.telegram.messenger.MessagesController", "isChatNoForwards"),
|
||||||
|
("org.telegram.messenger.MessagesController", "isUserNoForwards"),
|
||||||
|
("org.telegram.messenger.MessagesController", "isPeerNoForwards"),
|
||||||
|
("org.telegram.ui.ChatActivity", "hasSelectedNoforwardsMessage"),
|
||||||
|
("org.telegram.messenger.MessageObject", "canForwardMessage"),
|
||||||
|
("org.telegram.messenger.SendMessagesHelper", "sendMessage"),
|
||||||
|
("org.telegram.ui.Cells.DialogCell", "buildLayout"),
|
||||||
|
("org.telegram.ui.Components.ChatAvatarContainer", "setTitle"),
|
||||||
|
("org.telegram.messenger.AndroidUtilities", "escape"),
|
||||||
|
("org.telegram.ui.ProfileActivity", "updateProfileData"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MethodHook:
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MethodReplacement:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeJavaClass:
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def getName(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin:
|
class BasePlugin:
|
||||||
@@ -54,12 +91,19 @@ class BasePlugin:
|
|||||||
def get_setting(self, key, default=None):
|
def get_setting(self, key, default=None):
|
||||||
return self._settings.get(key, default)
|
return self._settings.get(key, default)
|
||||||
|
|
||||||
def set_setting(self, key, value):
|
def set_setting(self, key, value, reload_settings=False):
|
||||||
self._settings[key] = value
|
self._settings[key] = value
|
||||||
|
|
||||||
def add_on_send_message_hook(self):
|
def add_on_send_message_hook(self, priority=0):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def hook_all_methods(self, hook_class, method_name, xposed_hook=None, priority=None, **kwargs):
|
||||||
|
HOOKED.append((hook_class.getName(), method_name, xposed_hook))
|
||||||
|
return [object()]
|
||||||
|
|
||||||
|
def log(self, message):
|
||||||
|
LOG_LINES.append(str(message))
|
||||||
|
|
||||||
def on_plugin_load(self):
|
def on_plugin_load(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -74,36 +118,87 @@ def fake_log(*args, **kwargs):
|
|||||||
LOG_LINES.append(" ".join(str(a) for a in args))
|
LOG_LINES.append(" ".join(str(a) for a in args))
|
||||||
|
|
||||||
|
|
||||||
|
def fake_find_class(name):
|
||||||
|
return FakeJavaClass(name)
|
||||||
|
|
||||||
|
|
||||||
class _SettingsRow:
|
class _SettingsRow:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.__dict__.update(kwargs)
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeArrayList(list):
|
||||||
|
def isEmpty(self):
|
||||||
|
return not self
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
self.append(item)
|
||||||
|
|
||||||
|
def size(self):
|
||||||
|
return len(self)
|
||||||
|
|
||||||
|
def get(self, i):
|
||||||
|
return self[i]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMessageObject:
|
||||||
|
def __init__(self, protected=False, noforwards=False):
|
||||||
|
self.protected = protected
|
||||||
|
self.messageOwner = types.SimpleNamespace(noforwards=noforwards)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeParam:
|
||||||
|
def __init__(self, args=(), this=None, result=None):
|
||||||
|
self.args = None if args is None else list(args)
|
||||||
|
self.thisObject = this
|
||||||
|
self._result = result
|
||||||
|
self.result_set = False
|
||||||
|
|
||||||
|
def getResult(self):
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
def setResult(self, value):
|
||||||
|
self._result = value
|
||||||
|
self.result_set = True
|
||||||
|
|
||||||
|
|
||||||
|
def _module(name, **attrs):
|
||||||
|
mod = types.ModuleType(name)
|
||||||
|
mod.__dict__.update(attrs)
|
||||||
|
sys.modules[name] = mod
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
def install_fakes() -> None:
|
def install_fakes() -> None:
|
||||||
base_plugin_mod = types.ModuleType("base_plugin")
|
_module(
|
||||||
base_plugin_mod.BasePlugin = BasePlugin
|
"base_plugin",
|
||||||
base_plugin_mod.HookResult = HookResult
|
BasePlugin=BasePlugin,
|
||||||
base_plugin_mod.HookStrategy = HookStrategy
|
HookResult=HookResult,
|
||||||
sys.modules["base_plugin"] = base_plugin_mod
|
HookStrategy=HookStrategy,
|
||||||
|
MethodHook=MethodHook,
|
||||||
android_utils_mod = types.ModuleType("android_utils")
|
MethodReplacement=MethodReplacement,
|
||||||
android_utils_mod.log = fake_log
|
)
|
||||||
sys.modules["android_utils"] = android_utils_mod
|
_module("android_utils", log=fake_log, run_on_ui_thread=lambda fn, delay=0: fn())
|
||||||
|
_module(
|
||||||
ui_mod = types.ModuleType("ui")
|
"hook_utils",
|
||||||
ui_mod.__path__ = []
|
find_class=fake_find_class,
|
||||||
sys.modules["ui"] = ui_mod
|
set_private_field=lambda obj, name, value: setattr(obj, name, value) or True,
|
||||||
|
get_private_field=lambda obj, name: getattr(obj, name, None),
|
||||||
ui_settings_mod = types.ModuleType("ui.settings")
|
)
|
||||||
for cls_name in ("Header", "Input", "Text", "Switch"):
|
_module("client_utils", get_last_fragment=lambda: None)
|
||||||
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
|
ui_mod = _module("ui", __path__=[])
|
||||||
sys.modules["ui.settings"] = ui_settings_mod
|
ui_mod.settings = _module(
|
||||||
ui_mod.settings = ui_settings_mod
|
"ui.settings",
|
||||||
|
**{n: type(n, (_SettingsRow,), {}) for n in ("Header", "Input", "Text", "Switch", "Divider")},
|
||||||
|
)
|
||||||
class SimpleParams:
|
ui_mod.bulletin = _module("ui.bulletin", BulletinHelper=types.SimpleNamespace(
|
||||||
def __init__(self, message):
|
show_info=fake_log, show_error=fake_log, show_success=fake_log,
|
||||||
self.message = message
|
))
|
||||||
|
java_mod = _module("java", __path__=[])
|
||||||
|
java_mod.util = _module("java.util", ArrayList=FakeArrayList)
|
||||||
|
org = _module("org", __path__=[])
|
||||||
|
org.telegram = _module("org.telegram", __path__=[])
|
||||||
|
org.telegram.messenger = _module("org.telegram.messenger", MessageObject=FakeMessageObject)
|
||||||
|
|
||||||
|
|
||||||
KEY_TO_DUNDER = {
|
KEY_TO_DUNDER = {
|
||||||
@@ -133,6 +228,154 @@ def expected_dunders(meta: dict) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hooks(plugin, hooks) -> None:
|
||||||
|
flag_secure = 0x2000
|
||||||
|
strip = hooks[("android.view.Window", "setFlags")]
|
||||||
|
for enabled in (True, False):
|
||||||
|
plugin.set_setting("allow_screenshots", enabled)
|
||||||
|
param = FakeParam(args=[flag_secure | 0x80, flag_secure | 0x80])
|
||||||
|
strip.before_hooked_method(param)
|
||||||
|
want = 0x80 if enabled else flag_secure | 0x80
|
||||||
|
check(
|
||||||
|
f"Window.setFlags strips FLAG_SECURE only when enabled (enabled={enabled})",
|
||||||
|
param.args[0] == want,
|
||||||
|
f"flags={param.args[0]:#x}",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin.set_setting("allow_screenshots", True)
|
||||||
|
param = FakeParam(result=True)
|
||||||
|
hooks[("org.telegram.messenger.FlagSecureReason", "isSecuredNow")].after_hooked_method(param)
|
||||||
|
check("FlagSecureReason.isSecuredNow forced false", param.getResult() is False)
|
||||||
|
|
||||||
|
no_forwards = hooks[("org.telegram.messenger.MessagesController", "isChatNoForwards")]
|
||||||
|
for enabled in (True, False):
|
||||||
|
plugin.set_setting("allow_forwarding", enabled)
|
||||||
|
param = FakeParam(result=True)
|
||||||
|
no_forwards.after_hooked_method(param)
|
||||||
|
check(
|
||||||
|
f"isChatNoForwards forced false only when enabled (enabled={enabled})",
|
||||||
|
param.getResult() is (not enabled),
|
||||||
|
f"got {param.getResult()!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin.set_setting("allow_forwarding", True)
|
||||||
|
can_forward = hooks[("org.telegram.messenger.MessageObject", "canForwardMessage")]
|
||||||
|
message = FakeMessageObject(noforwards=True)
|
||||||
|
seen = []
|
||||||
|
# The re-invoked original answers "forwardable" only if noforwards is unset.
|
||||||
|
method = types.SimpleNamespace(
|
||||||
|
invoke=lambda obj: seen.append(obj.messageOwner.noforwards) or not obj.messageOwner.noforwards
|
||||||
|
)
|
||||||
|
param = FakeParam(this=message, result=False)
|
||||||
|
param.method = method
|
||||||
|
can_forward.after_hooked_method(param)
|
||||||
|
check(
|
||||||
|
"canForwardMessage re-evaluates without noforwards and restores the flag",
|
||||||
|
param.getResult() is True and seen == [False] and message.messageOwner.noforwards is True,
|
||||||
|
f"result={param.getResult()!r} seen={seen} flag={message.messageOwner.noforwards!r}",
|
||||||
|
)
|
||||||
|
param = FakeParam(this=FakeMessageObject(noforwards=False), result=False)
|
||||||
|
param.method = method
|
||||||
|
can_forward.after_hooked_method(param)
|
||||||
|
check("canForwardMessage keeps a refusal unrelated to noforwards", param.getResult() is False)
|
||||||
|
|
||||||
|
escape = hooks[("org.telegram.messenger.AndroidUtilities", "escape")]
|
||||||
|
badge = type(escape).after_hooked_method.__globals__
|
||||||
|
badge["_building_title"] = "Chat"
|
||||||
|
results = []
|
||||||
|
for arg in ("Chat", "Other"):
|
||||||
|
param = FakeParam(args=[arg], result=arg)
|
||||||
|
escape.after_hooked_method(param)
|
||||||
|
results.append(param.getResult())
|
||||||
|
badge["_building_title"] = None
|
||||||
|
param = FakeParam(args=["Chat"], result="Chat")
|
||||||
|
escape.after_hooked_method(param)
|
||||||
|
results.append(param.getResult())
|
||||||
|
check(
|
||||||
|
"escape() prefixes only the title being laid out",
|
||||||
|
results == ["\u2003 Chat", "Other", "Chat"],
|
||||||
|
f"{results}",
|
||||||
|
)
|
||||||
|
|
||||||
|
intercept = hooks[("org.telegram.messenger.SendMessagesHelper", "sendMessage")]
|
||||||
|
copier = type(intercept).before_hooked_method.__globals__["copier"]
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
([], []),
|
||||||
|
([0, 0, 0], []),
|
||||||
|
([7], []),
|
||||||
|
([7, 7, 7], [(0, 3)]),
|
||||||
|
([0, 7, 7, 0, 9, 9, 9], [(1, 3), (4, 7)]),
|
||||||
|
([7, 7, 9, 9], [(0, 2), (2, 4)]),
|
||||||
|
([7, 0, 7], []),
|
||||||
|
([5] * 12, [(0, 10), (10, 12)]),
|
||||||
|
([5] * 11, [(0, 10)]),
|
||||||
|
]
|
||||||
|
bad = [(k, copier.album_runs(k), want) for k, want in cases if copier.album_runs(k) != want]
|
||||||
|
check("album_runs groups consecutive album items, max 10 per group", not bad, f"{bad}")
|
||||||
|
|
||||||
|
# Stub out the Java-facing parts of the bundled copier module.
|
||||||
|
copied = []
|
||||||
|
copier.is_protected = lambda m: m.protected
|
||||||
|
copier.copy_messages = lambda **kwargs: copied.append(kwargs)
|
||||||
|
|
||||||
|
free, locked = FakeMessageObject(), FakeMessageObject(protected=True)
|
||||||
|
param = FakeParam(args=[FakeArrayList([free, locked]), -100, False, True, True, 0, 0])
|
||||||
|
intercept.before_hooked_method(param)
|
||||||
|
check(
|
||||||
|
"mixed forward: protected message copied, the rest forwarded",
|
||||||
|
len(copied) == 1
|
||||||
|
and copied[0]["messages"] == [locked]
|
||||||
|
and copied[0]["peer"] == -100
|
||||||
|
and copied[0]["hide_caption"] is True
|
||||||
|
and list(param.args[0]) == [free]
|
||||||
|
and not param.result_set,
|
||||||
|
f"copied={copied} args0={list(param.args[0])} result_set={param.result_set}",
|
||||||
|
)
|
||||||
|
|
||||||
|
copied.clear()
|
||||||
|
param = FakeParam(args=[FakeArrayList([locked]), 5, False, False, True, 0, 0])
|
||||||
|
intercept.before_hooked_method(param)
|
||||||
|
check(
|
||||||
|
"all-protected forward: nothing left for the original sendMessage to forward",
|
||||||
|
len(copied) == 1 and list(param.args[0]) == [],
|
||||||
|
f"copied={copied} args0={list(param.args[0])}",
|
||||||
|
)
|
||||||
|
|
||||||
|
copied.clear()
|
||||||
|
|
||||||
|
def broken_check(m):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
copier.is_protected = broken_check
|
||||||
|
param = FakeParam(args=[FakeArrayList([free]), 5, False, False, True, 0, 0])
|
||||||
|
intercept.before_hooked_method(param)
|
||||||
|
check(
|
||||||
|
"failed protection check copies instead of forwarding",
|
||||||
|
len(copied) == 1 and list(param.args[0]) == [],
|
||||||
|
f"copied={copied} args0={list(param.args[0])}",
|
||||||
|
)
|
||||||
|
copier.is_protected = lambda m: m.protected
|
||||||
|
|
||||||
|
copied.clear()
|
||||||
|
param = FakeParam(args=[FakeArrayList([free]), 5, False, False, True, 0, 0])
|
||||||
|
intercept.before_hooked_method(param)
|
||||||
|
check(
|
||||||
|
"unprotected forward passes through untouched",
|
||||||
|
not copied and not param.result_set and list(param.args[0]) == [free],
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin.set_setting("allow_forwarding", False)
|
||||||
|
param = FakeParam(args=[FakeArrayList([locked]), 5, False, False, True, 0, 0])
|
||||||
|
intercept.before_hooked_method(param)
|
||||||
|
check("forward interception off when setting disabled", not copied and not param.result_set)
|
||||||
|
|
||||||
|
plugin.set_setting("allow_forwarding", True)
|
||||||
|
param = FakeParam(args=[object()])
|
||||||
|
intercept.before_hooked_method(param)
|
||||||
|
check("sendMessage(SendMessageParams) overload ignored", not copied and not param.result_set)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
argv = sys.argv[1:] if argv is None else argv
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
if len(argv) != 1:
|
if len(argv) != 1:
|
||||||
@@ -146,12 +389,12 @@ def main(argv=None) -> int:
|
|||||||
install_fakes()
|
install_fakes()
|
||||||
source = bundle_path.read_text(encoding="utf-8")
|
source = bundle_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
||||||
modules_before = set(sys.modules)
|
modules_before = set(sys.modules)
|
||||||
ns = {"__name__": "hello_bundle"}
|
ns = {"__name__": meta["id"]}
|
||||||
exec(compile(source, str(bundle_path), "exec"), ns)
|
exec(compile(source, str(bundle_path), "exec"), ns)
|
||||||
new_modules = set(sys.modules) - modules_before
|
new_modules = set(sys.modules) - modules_before
|
||||||
|
|
||||||
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
|
||||||
expected = expected_dunders(meta)
|
expected = expected_dunders(meta)
|
||||||
mismatches = [
|
mismatches = [
|
||||||
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
|
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
|
||||||
@@ -198,58 +441,55 @@ def main(argv=None) -> int:
|
|||||||
plugin.on_plugin_load()
|
plugin.on_plugin_load()
|
||||||
check(
|
check(
|
||||||
"on_plugin_load emits load log line",
|
"on_plugin_load emits load log line",
|
||||||
any("hello_bundle loaded" in line for line in LOG_LINES),
|
any("unlocked_restrictions loaded" in line for line in LOG_LINES),
|
||||||
f"log lines: {LOG_LINES}",
|
f"log lines: {LOG_LINES}",
|
||||||
)
|
)
|
||||||
|
hooked = {(cls, method) for cls, method, _ in HOOKED}
|
||||||
params = SimpleParams(".hello alice smith")
|
|
||||||
result = plugin.on_send_message_hook(account=0, params=params)
|
|
||||||
check(
|
check(
|
||||||
"'.hello alice smith' renders greeting",
|
"on_plugin_load hooks every target method",
|
||||||
params.message == "Hello, Alice Smith!",
|
EXPECTED_HOOKS <= hooked,
|
||||||
f"got {params.message!r}",
|
f"missing: {EXPECTED_HOOKS - hooked}",
|
||||||
)
|
|
||||||
check(
|
|
||||||
"on_send_message_hook returns MODIFY strategy",
|
|
||||||
result.strategy == HookStrategy.MODIFY,
|
|
||||||
f"got {result.strategy}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plugin.set_setting("template", "{nope}")
|
switches = [row for row in plugin.create_settings() if type(row).__name__ == "Switch"]
|
||||||
params2 = SimpleParams(".hello someone")
|
|
||||||
plugin.on_send_message_hook(account=0, params=params2)
|
|
||||||
check(
|
check(
|
||||||
"bad template produces 'Bad template:' message",
|
"settings expose all toggles, enabled by default",
|
||||||
params2.message.startswith("Bad template:"),
|
{(row.key, row.default) for row in switches}
|
||||||
f"got {params2.message!r}",
|
== {
|
||||||
|
("allow_screenshots", True),
|
||||||
|
("allow_forwarding", True),
|
||||||
|
("protected_lock_badge", True),
|
||||||
|
},
|
||||||
|
f"got {[(row.key, row.default) for row in switches]}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# main.py imports `render`/`TemplateError` by name, so they live directly
|
verify_hooks(plugin, {(cls, method): hook for cls, method, hook in HOOKED})
|
||||||
# in the executed namespace — call render() directly to inspect the raised
|
|
||||||
# traceback (proves linecache registration for bundled, non-file modules).
|
# Raise inside a bundled module to prove linecache registration: the
|
||||||
render = ns.get("render")
|
# traceback must name the original source file and show the failing line.
|
||||||
template_error = ns.get("TemplateError")
|
screenshots = ns.get("screenshots")
|
||||||
traceback_text = ""
|
traceback_text = ""
|
||||||
if render is not None and template_error is not None:
|
if screenshots is not None:
|
||||||
try:
|
try:
|
||||||
render("{nope}", "x")
|
screenshots.StripFlagSecureHook(lambda: True).before_hooked_method(FakeParam(args=None))
|
||||||
except template_error:
|
except TypeError:
|
||||||
traceback_text = traceback.format_exc()
|
traceback_text = traceback.format_exc()
|
||||||
check(
|
check(
|
||||||
"render() traceback names src/greeting/template.py",
|
"hook traceback names src/screenshots.py",
|
||||||
"src/greeting/template.py" in traceback_text,
|
"src/screenshots.py" in traceback_text,
|
||||||
f"traceback:\n{traceback_text}",
|
f"traceback:\n{traceback_text}",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
"traceback includes the raising source line text",
|
"traceback includes the raising source line text",
|
||||||
"unsupported placeholder" in traceback_text,
|
"if len(args) != 2" in traceback_text,
|
||||||
f"traceback:\n{traceback_text}",
|
f"traceback:\n{traceback_text}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bundled = {"main", "hooking", "screenshots", "forwarding", "copier", "badge", "protection"}
|
||||||
check(
|
check(
|
||||||
"sys.modules gained no entry for bundled module names",
|
"sys.modules gained no entry for bundled module names",
|
||||||
not ({"greeting", "textutil", "main"} & new_modules),
|
not (bundled & new_modules),
|
||||||
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
|
f"leaked: {bundled & new_modules}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _FAILURES:
|
if _FAILURES:
|
||||||
|
|||||||
Reference in new issue
Block a user