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>
14 KiB
Unlocked Restrictions
An exteraGram plugin that lifts the "restrict saving content" 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.localstate is not shared between a before-hook and its after-hook.- Java
Stringvalues arrive as Pythonstr, otherCharSequences as Java objects. - Fields named like
java.lang.Objectmethods (SendMessageParams.notify) must be set withhook_utils.set_private_field. - Exceptions escaping a Python thread vanish silently, so the copy worker catches everything.
- Shared TL objects (
TLRPC.Chatand friends) are never modified for display purposes.
The rest of this README documents the build toolchain inherited from pluginBase.
How exteraGram plugins actually work
A plugin, as exteraGram loads it, is exactly one Python file on the device:
/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py. The distribution
convention names the same content <id>.plugin.
The app reads plugin metadata statically via AST — it looks for top-level constant
assignments (__id__, __name__, __description__, __author__, __version__,
__icon__, __app_version__, __sdk_version__, __requirements__) without executing the
file. Only __id__ and __name__ are required.
The file must define exactly one class inheriting BasePlugin (from base_plugin import BasePlugin). Lifecycle methods (on_plugin_load, on_plugin_unload, create_settings) and
hooks (on_send_message_hook, pre_request_hook, post_request_hook, on_update_hook(s),
on_app_event) are all called by the host; hooks must be registered inside
on_plugin_load (e.g. self.add_on_send_message_hook()). A hook returns a
HookResult(strategy=HookStrategy.MODIFY, params=params) to apply changes, or a bare
HookResult() to pass through. Settings rows returned from create_settings come from
ui.settings (Header, Input, Switch, Text, Selector, Divider) and persist through
self.get_setting/self.set_setting. Logging goes through android_utils.log; Chaquopy also
redirects sys.stdout/sys.stderr to the logcat tags python.stdout/python.stderr.
This repo does not use the official Elyx/ElyxBuilder archive format — see
docs/sdk-notes.md for why.
Repo layout
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
screenshots.py FLAG_SECURE removal (FlagSecureReason + Window.setFlags hooks)
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/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
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
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
scripts/ ADB debug-loop automation and the git hooks (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
The three commands that matter
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
bundle.py walks src/ recursively, embeds every .py file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module __builtins__ dict, never touching
sys.modules) so cross-module and cross-package imports resolve exactly as they would if the
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
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).
Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (
adb devices).
Release-build APKs disallow adb run-as, so you cannot adb push a file straight into the
app's private plugin directory. The on-device dev server (tools/devclient.py,
scripts/deploy.sh) is the actual install path — it talks to the running app over a TCP
socket forwarded with adb forward, and the app writes the file into its own storage.
ADB automation
All scripts live in scripts/, are runnable from any working directory, and honor the
standard ANDROID_SERIAL env var for device selection.
scripts/adb-connect.sh [--debug]— starts adb, waits for the device, (re)creates thetcp:42690port forward (andtcp:5678with--debug), pings the dev server.scripts/deploy.sh [--release] [--enable] [--no-connect]— bundles, connects, writes the plugin to the device, optionally enables it, reloads it.scripts/watch.sh [deploy args…]— watchessrc/andplugin.json, redeploys on change (usesinotifywaitif available, otherwise polls).scripts/logs.sh [-c] [-t] [-g PATTERN]— streams logcat for the running app (-tfor the raw Chaquopy stdio tags,-gto grep,-cto clear first).scripts/debug.sh start|stop [--platform vscode|pycharm]— forwards the debugger port and tells the dev server to start/stop the in-app debugger. Requiresdebugpy(vscode) orpydevd-pycharm(pycharm) present in the app's Python runtime — add it torequirementsinplugin.jsonif it's missing.scripts/fetch-sdk-stubs.sh [--beta]— downloads the latest SDK type stubs intostubs/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
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-bumpandno_bumpalso work), NO_BUMP=1(orGIT_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,versioninplugin.jsonalready differs fromHEAD(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
.gitea/workflows/release.yml builds, verifies and publishes the .plugin to the repo's
Releases tab on every push to main. dist/ is gitignored, so the artifact is always built
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
version straight out of plugin.json, so it keeps working after you rename the plugin.
The version field in plugin.json is the single source of truth:
Push to main with… |
Result |
|---|---|
a new version |
creates tag v<version> and a new release |
the same version |
rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping
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).
Every path runs --check, builds with --release, and gates the upload on
verify_bundle.py, so a bundle that fails verification is never published. A rebuild
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
that first introduced that version.
Authentication uses secrets.API_TOKEN, an organisation-level Actions secret holding a user
token with the write:repository scope (which covers releases). Set it once under
Org → Settings → Actions → Secrets and every repo in the organisation inherits it; a
repo-level secret of the same name takes precedence if one repo ever needs different
credentials.
The workflow passes it to release.py as RELEASE_TOKEN, not GITEA_TOKEN. The Actions
runner injects its own GITEA_TOKEN into every step, and that value overrides a step-level
env: entry of the same name, so the org secret would be silently ignored.
If the org secret does not reach a repository the publish still works: release.py tries
RELEASE_TOKEN first and then falls back to the runner's GITEA_TOKEN, which can publish
releases in its own repository, and prints which one it used. An organisation secret is only
exposed to a repository that has Actions enabled, so check Repo → Settings → Actions if a
repo unexpectedly falls back.
If you would rather not manage a token at all, that injected GITEA_TOKEN can create
releases in its own repo: pass --token-env GITEA_TOKEN and drop the org secret. It is
scoped to the single job and expires with it, so it is the safer default; API_TOKEN is what
you want when publishing needs to reach beyond this repo.
Writing your own plugin
- Run
python3 tools/bundle.py --checkonce. It enables the git hooks that auto-bump the version on commit (see Git hooks and versioning). - Edit
plugin.json(id, name, description, etc.). - Write your plugin under
src/, withsrc/main.pyas the entry module (absolute imports only — it's executed at plugin-module top level, not inside a package). python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin(substitute youridfromplugin.json).bash scripts/deploy.sh --enablewith a device connected.