generated from exteraGramPlugins/pluginBase
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>
244 lines
14 KiB
Markdown
244 lines
14 KiB
Markdown
# Unlocked Restrictions
|
|
|
|
An [exteraGram](https://exteragram.app) 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.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
|
|
|
|
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
|
|
|
|
```bash
|
|
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](#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 the
|
|
`tcp:42690` port forward (and `tcp:5678` with `--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…]` — watches `src/` and `plugin.json`, redeploys on change
|
|
(uses `inotifywait` if available, otherwise polls).
|
|
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
|
|
raw Chaquopy stdio tags, `-g` to grep, `-c` to 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. Requires `debugpy` (vscode) or
|
|
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
|
|
`plugin.json` if it's missing.
|
|
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
|
|
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
|
|
|
|
`.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
|
|
|
|
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
|
|
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).
|
|
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
|
(substitute your `id` from `plugin.json`).
|
|
5. `bash scripts/deploy.sh --enable` with a device connected.
|