[nobump] Unlocked Restrictions: screenshots, copy-forwarding, protected-chat lock
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:
lyuksovannyyandClaude Opus 5.5 committed 2026-09-23 12:59:15 +02:00
1 parent e0586a3423
commit 5405adf485
16 files changed
+1296 -127

No files matched your search

+106 -11
View File
@@ -1,7 +1,56 @@
# exteraGram Plugin Base
# Unlocked Restrictions
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
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
@@ -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
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
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/ 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
```
@@ -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
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
@@ -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/`
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
@@ -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 |
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).
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
1. Edit `plugin.json` (id, name, description, etc.).
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
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).
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`).
4. `bash scripts/deploy.sh --enable` with a device connected.
5. `bash scripts/deploy.sh --enable` with a device connected.