Compare commits
5 Commits
1b601609f0
...
fix/i18n-r
| Author | SHA1 | Date | |
|---|---|---|---|
| a81443ff67 | |||
|
827a1d1e54
|
|||
|
d6fd20ad9c
|
|||
|
5ec2b79d03
|
|||
|
47b120f526
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,3 +37,6 @@ tests/karen7
|
||||
|
||||
# Compiled translations: build_zip.py rebuilds them from the .po sources
|
||||
*.mo
|
||||
|
||||
# Android companion app: kept locally only, not part of the plugin repo
|
||||
android/
|
||||
|
||||
@@ -117,7 +117,10 @@ python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha25
|
||||
```
|
||||
|
||||
- `build_zip.py` needs **Babel** (`pip install babel`): it compiles the
|
||||
translation catalogs into the zip and stops if Babel is missing.
|
||||
translation catalogs into the zip and stops if Babel is missing. The i18n
|
||||
tests need it too. Install it in the **runtime** env used for releases
|
||||
(`source "$BAL_HOME/electrum/env/bin/activate" && pip install babel`);
|
||||
`make-release.sh` checks it before running anything.
|
||||
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
||||
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
||||
version there, never invent a new source of truth.
|
||||
|
||||
97
CHANGELOG.md
97
CHANGELOG.md
@@ -3515,3 +3515,100 @@ delivery; when it is not, the plugin raises an explicit error (see below).
|
||||
`tests/test_core_plugin_base.py`, `tests/test_core_input_rules.py`.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
## 62. Remove BALQR and BC-UR v1/v2 — keep only BBQR
|
||||
|
||||
**Date:** 2026-10-03
|
||||
|
||||
**Goal:** Drop the BALQR, BC-UR v1 and BC-UR v2 QR wire formats and keep only
|
||||
**BBQR** for will transfer.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/animated_qr.py`: removed the BC32, bytewords-minimal, CRC-32,
|
||||
CBOR, xoshiro256/fountain codecs and the BALQR / UR v1 / UR v2 receive
|
||||
sessions; the module now implements only the BBQR codec plus the
|
||||
`AnimatedQrSession` facade and `detect_format` / `parse_for_detection`
|
||||
(BBQR-only).
|
||||
- `bal/core/qrtransfer.py`: removed the BALQR framing helpers (`split_frames`,
|
||||
`parse_frame`, `assemble`, `encode_transfer_best`, the frame errors);
|
||||
kept the transfer-string codec (`encode_transfer` / `decode_transfer`) and
|
||||
the QR-size presets.
|
||||
- `bal/gui/qt/dialogs.py`: the export widget no longer shows a Format selector
|
||||
and always emits BBQR; the import widget treats BBQR indexes as 0-based.
|
||||
- Removed the "QR Code Size" setting from the plugin settings dialog; the
|
||||
QR-size selector now lives only in the export dialog, and its chosen value
|
||||
is remembered (persisted to `QR_CHUNK_SIZE`) for the next export.
|
||||
- Android reader: `bal/core/*` copies re-synced, `bal/encoder.py` and the
|
||||
Kotlin bridges (`BalDecoder`, `QrEncoder`, `QrSendActivity`, `MainActivity`,
|
||||
`strings.xml`) reduced to BBQR; `tools/emitter.py` and
|
||||
`test_chain/verify_chain.py` updated.
|
||||
- Tests: `test_core_animated_qr.py`, `test_core_qr_transfer.py` and
|
||||
`test_gui_qr_transfer.py` rewritten for BBQR only.
|
||||
- Docs: README, COMPATIBILITY, HANDOFF and android/README updated; translation
|
||||
catalogs refreshed (removed format strings marked obsolete).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 63. i18n review fixes (Truman's review of the translation PR)
|
||||
|
||||
**Date:** 2026-10-07
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `make-release.sh`
|
||||
- Step 3 now also runs `tests/test_i18n.py`, `tests/test_translations.py`,
|
||||
`tests/test_cli_*.py` and `tests/test_import_will_details.py` (the i18n
|
||||
tests were never run by the release gate; the CLI and will-import tests
|
||||
were not either).
|
||||
- Checks up front that Babel is importable by `python3` and stops with
|
||||
"pip install babel" otherwise: the i18n tests (step 3) and `build_zip.py`
|
||||
(step 5) both need it, so a release no longer stops half-way.
|
||||
- `AGENTS.md`: Babel must be installed in the runtime env used for releases.
|
||||
- `pyproject.toml`: `[tool.ruff.lint.isort] known-first-party = ["bal"]`.
|
||||
WHY: the reported I001 in `tests/test_translations.py` is not reproducible
|
||||
from the repository root (ruff 0.15.11 and 0.6.9: clean), but it appears as
|
||||
soon as ruff classifies `bal` as third-party (then 20 files report I001).
|
||||
Removing the blank line would break the check where `bal` is first-party;
|
||||
declaring it makes the result the same on every machine.
|
||||
- `bal/core/willexecutors.py` - `get_willexecutor_transactions` returns COPIES
|
||||
of the executor records. It used to write `txs`, `txsids` and the
|
||||
translated `broadcast_status` into the will item's own `we` dict, which is
|
||||
saved in the wallet (stored data depended on the UI language).
|
||||
- `bal/core/will.py` - `WillItem` drops those transient fields when loading a
|
||||
will saved by an older version, and maps a translated leading "New"
|
||||
("Nuovo", "Nuevo" - saved by i18n test builds) back to "New".
|
||||
- `bal/gui/qt/window.py`, `bal/cli/controller.py`: the status history of a new
|
||||
will item is saved as the literal "New" (was `_("New")`).
|
||||
- `tests/test_core_status_language_neutral.py` (new, 7 tests; 5 fail on
|
||||
`main`): English "New" stored, translated "New" mapped back, the mapping
|
||||
covers every catalog's translation of "New", the push helper does not
|
||||
modify the will item, old transient fields dropped on load.
|
||||
|
||||
**Scope:** this entry contains ONLY the i18n review fixes. The other fixes
|
||||
prepared in the same session (invalidation fee, Check `KeyError`, cascade,
|
||||
deselected executor) are not part of it and will be proposed separately.
|
||||
|
||||
**Verification (on `main` 827a1d1):**
|
||||
|
||||
- New test file `tests/test_core_status_language_neutral.py`: 5 of 7 fail on
|
||||
`main`, 7 of 7 pass with this change.
|
||||
- Extended release test list (as above) plus the new test file: 446 passed,
|
||||
1 failed (`main`: 439 passed, same failure). The failure is pre-existing:
|
||||
`test_group_e_mock_karen7::test_e5` needs `tests/karen7`, a wallet file that
|
||||
is not in the repository.
|
||||
- Whole `tests/` directory: 609 passed, 26 failed, 3 collection errors
|
||||
(`main`: 602 passed, and the SAME 26 failures and 3 errors, test by test).
|
||||
All pre-existing and unrelated: 9 need `tests/karen7`; 21 in
|
||||
`test_auto_rebuild_on_new_tx.py`, `test_cli_autorebuild.py` and
|
||||
`test_cli_controller_offline.py` fail only when the whole directory runs
|
||||
together (they pass when each file runs alone); 3 audio tests need an audio
|
||||
device. `test_gui_export_dialogs.py::test_audio_import_receive_wiring` hangs
|
||||
forever, also on `main`, so it was deselected.
|
||||
- `ruff check bal/ tests/`: the same 3 pre-existing I001 as on `main`
|
||||
(`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
||||
none new.
|
||||
|
||||
**Outcome:** DONE (pending Truman's review).
|
||||
|
||||
@@ -25,22 +25,15 @@ and 4.8.0).
|
||||
|
||||
## QR wire-format compatibility
|
||||
|
||||
BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's
|
||||
own frame format and is only understood by BAL itself. The export page also
|
||||
supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
|
||||
BAL exports/imports wills as QR codes using **BBQR**:
|
||||
|
||||
| Format | Wire appearance | Interop target |
|
||||
|-----------|----------------------------|------------------------------------------------------|
|
||||
| BAL QR | `BAL1<total><index><flag>…` (v2) / `BALQR1\|total\|index\|…` (legacy import-only) | Past/other BAL versions: **v2 exports are NOT readable by old builds**; old `BALQR1` exports still import here (default, best-of compression, flag `0` = plain, `Z` = deflate) |
|
||||
| BC-UR v1 | `ur:bytes/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
||||
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
||||
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
||||
|
||||
Import auto-detects the format of each scanned code; out-of-order, duplicate
|
||||
and (for UR v2) partially-lost fountain frames are handled. Interop is
|
||||
validation-tested against the reference C++ bc-ur encoder output and the
|
||||
BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against
|
||||
third-party libraries (`ur`, `bbqr`, Coldcard firmwares).
|
||||
Import auto-detects the format of each scanned code; out-of-order and duplicate
|
||||
BBQR frames are handled. Interop has not yet been cross-verified against
|
||||
third-party libraries (`bbqr`, Coldcard firmwares).
|
||||
|
||||
## Reporting compatibility issues
|
||||
|
||||
|
||||
61
HANDOFF.md
61
HANDOFF.md
@@ -421,50 +421,29 @@ See Section 5 for details.
|
||||
the audio path — buttons only appear when the `audio_modem` plugin +
|
||||
`amodem` are installed; see the prerequisites below).
|
||||
|
||||
### In progress: animated-QR interop (BC-UR v1/v2, BBQR)
|
||||
### Animated-QR interop: BBQR only
|
||||
|
||||
- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1**
|
||||
(BC32 + SHA-256 digest; the bech32_bis checksum variant per
|
||||
BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal,
|
||||
CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **BBQR**
|
||||
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
|
||||
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
|
||||
`AnimatedQrSession` with `detect_format` auto-detection and
|
||||
`parse_for_detection` frame identity for the GUI debounce.
|
||||
- Current status as of this session: reference parity, GUI, and tests done;
|
||||
not yet committed.
|
||||
- **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005
|
||||
test vectors (`Hello, world`, `Hello world`, the long seed vector);
|
||||
bytewords-minimal round-trips with CRC rejection; UR v2 part encode +
|
||||
decode is byte-exact against the reference C++ bc-ur encoder for a
|
||||
single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes),
|
||||
validating CBOR framing, bytewords, alias+ary-threshold sampling,
|
||||
xoshiro256** and the XOR mix.
|
||||
- **Sessions:** UR v2 single-part (no seq header), out-of-order frames,
|
||||
duplicate drops, solve with a missing pure fragment (a second redundant
|
||||
mixed wave is emitted by `ur2_frames`), UR v1 single-part
|
||||
(digest-less `ur:bytes/<bc32>` accepted) and multipart, BBQR full-frame
|
||||
decode in any order for Z/2/H encodings.
|
||||
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||
`TransferConflictError` on a frame from a different transfer,
|
||||
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
|
||||
- **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default,
|
||||
BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now
|
||||
routes every frame through `detect_format` +
|
||||
`AnimatedQrSession.add_part` with the shared
|
||||
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
|
||||
payload, stable_reads=2)` debounce (reset on session-key change).
|
||||
`_review_and_sign` resolves the session to the transfer text and decodes
|
||||
parts uniformly across formats.
|
||||
- **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the
|
||||
C++-reference parity vectors and BC32 spec vectors) and the extended
|
||||
`tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed
|
||||
files; pyright 0 errors; smoke test, `build_zip.py` and
|
||||
`external_zip_test.py` green. Only the pre-existing failures remain
|
||||
(`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution).
|
||||
- **Any remaining work:** manual on-device walkthrough of the QR path with
|
||||
the new formats; optionally validate against third-party libraries
|
||||
(`ur`, `bbqr`) once available; add the docstrings/branch notes already
|
||||
captured in `ag1.md`/`ag2.md` context where needed.
|
||||
`parse_for_detection` frame identity for the GUI debounce. The previously
|
||||
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
|
||||
removed; only BBQR remains.
|
||||
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
|
||||
duplicate drops.
|
||||
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||
`TransferConflictError` on a frame from a different transfer,
|
||||
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
|
||||
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
|
||||
the importer routes every frame through `detect_format` +
|
||||
`AnimatedQrSession.add_part` with the shared
|
||||
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
|
||||
payload, stable_reads=2)` debounce (reset on session-key change).
|
||||
`_review_and_sign` resolves the session to the transfer text and decodes
|
||||
parts uniformly.
|
||||
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
|
||||
encoding fallback, zlib-bomb guard, detection) and the extended
|
||||
`tests/test_gui_qr_transfer.py` pass.
|
||||
|
||||
**Dev-box audio prerequisites (audio_modem channel):**
|
||||
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
**In scope**
|
||||
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
|
||||
- One language for now: Italian (`it_IT`).
|
||||
- Languages: Italian (`it_IT`) and Spanish (`es_ES`).
|
||||
|
||||
**Out of scope** (owner's decisions, or by design)
|
||||
- **BAL Easy Heirs**: excluded by the owner.
|
||||
|
||||
25
README.md
25
README.md
@@ -26,8 +26,8 @@ bal/ the installable Electrum plugin package
|
||||
│ ├── willexecutors.py
|
||||
│ ├── checkalive.py
|
||||
│ ├── reminders.py
|
||||
│ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler
|
||||
│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (stdlib-only)
|
||||
│ ├── qrtransfer.py transfer-string codec / QR size presets
|
||||
│ ├── animated_qr.py BBQR codec (stdlib-only)
|
||||
│ └── input_rules.py
|
||||
├── cli/ headless command-line layer (no Qt)
|
||||
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
||||
@@ -94,25 +94,20 @@ exists, then enable it from **Tools → Plugins**.
|
||||
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||
sequence of QR codes and imported on another device (**Import via QR**). The
|
||||
export offers All / Valid / Valid-NC filters plus a QR size preset
|
||||
(150–1800 bytes/frame) and ships the default **BAL QR** format already
|
||||
compressed whenever that is smaller (best-of zlib, flag per frame); the import
|
||||
(150–1800 bytes/frame) and ships the **BBQR** format; the import
|
||||
flow reviews and sign each transaction
|
||||
one at a time, then proposes exporting the signed transactions. When
|
||||
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the BAL QR wire-format spec.
|
||||
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the transfer wire-format spec.
|
||||
|
||||
### Animated-QR formats (interop)
|
||||
### Animated-QR format (BBQR)
|
||||
|
||||
BAL QR is the default export format, but the export page's **Format** selector
|
||||
also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2**
|
||||
(`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by
|
||||
BitKit) animated-QR sequences. The importer auto-detects the format of each
|
||||
code it sees, so any of the four formats can be imported on a BAL device, and
|
||||
a BAL export can be imported by any tool that understands these standards.
|
||||
UR v2 imports tolerate out-of-order and duplicate frames (fountain decoding);
|
||||
BBQR frames may arrive in any order. Rotation/redundancy caps and the
|
||||
32 MB message limit (zlib-bomb guard) bound untrusted scanner input.
|
||||
The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard)
|
||||
animated-QR sequences. The importer auto-detects each code it sees, so a BAL
|
||||
export can be imported by any tool that understands BBQR. BBQR frames may
|
||||
arrive in any order, and duplicates are ignored. The 32 MB message limit
|
||||
(zlib-bomb guard) bounds untrusted scanner input.
|
||||
|
||||
## Command-line / headless usage
|
||||
|
||||
|
||||
10
android/.gitignore
vendored
10
android/.gitignore
vendored
@@ -1,10 +0,0 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.hprof
|
||||
@@ -1,153 +0,0 @@
|
||||
# BAL Reader (Android)
|
||||
|
||||
A minimal Android app that reads a Bitcoin will exported by the
|
||||
[BAL Electrum plugin](https://bitcoin-after.life) directly from your screen,
|
||||
then lets you view, copy, share, or save the recovered data. **Reader only** —
|
||||
it never signs or broadcasts.
|
||||
|
||||
It decodes **all four** transfer formats the plugin can export, auto-detecting
|
||||
the format from the first frame:
|
||||
|
||||
- **BAL QR** (the plugin's default), single- and multi-frame, plain and
|
||||
zlib-compressed, compact `BAL1` header (legacy `BALQR1` frames are still
|
||||
accepted on import);
|
||||
- **BC-UR v1** (single-part and `NofM` multipart);
|
||||
- **BC-UR v2** (single-part and XOR-fountain multipart — it tolerates dropped,
|
||||
repeated, and out-of-order frames);
|
||||
- **BBQR** (`Z`/`H`/`2` encodings).
|
||||
|
||||
Both payload kinds are handled: the **whole-will JSON** and the plain
|
||||
**transaction-hex list**.
|
||||
|
||||
## How decoding works
|
||||
|
||||
The app does not reimplement the QR formats. It bundles the plugin's own
|
||||
codec modules — `bal/core/__init__.py`, `bal/core/animated_qr.py`,
|
||||
`bal/core/qrtransfer.py` — and runs them verbatim through **Chaquopy** (CPython
|
||||
on Android). Kotlin is only camera glue and UI:
|
||||
|
||||
```
|
||||
Camera (CameraX) → ML Kit QR detection (on-device, no API key)
|
||||
→ BalDecoder.add(text) → bal.core.animated_qr.AnimatedQrSession
|
||||
→ when done → BalDecoder.finish()
|
||||
= session.resolve() → qrtransfer.decode_transfer()
|
||||
→ balreader.payload.decode_will_payload()
|
||||
→ ResultActivity: view / copy / share / save
|
||||
```
|
||||
|
||||
The decode tail mirrors the plugin's import dialog function-for-function, and
|
||||
`android/test_chain/verify_chain.py` proves the bundled code decodes every
|
||||
format the way the desktop import does (including scrambled, duplicated, and
|
||||
missing frames).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/src/main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── java/life/after/bitcoin/
|
||||
│ │ ├── BalDecoder.kt Chaquopy bridge over the bundled codecs
|
||||
│ │ ├── MainActivity.kt camera + ML Kit scan loop + progress
|
||||
│ │ └── ResultActivity.kt viewer (copy / share / save)
|
||||
│ └── python/ bundled Python (regenerate, do not hand-edit)
|
||||
│ ├── bal/core/ SYNCED COPY of the plugin codecs
|
||||
│ └── balreader/payload.py verbatim copy of dialogs.decode_will_payload
|
||||
├── scripts/
|
||||
│ ├── sync_codecs.py re-copy + verify the bundled codecs
|
||||
│ └── build_apk.py resync codecs, run Gradle, print APK + sha256
|
||||
└── test_chain/verify_chain.py decode-chain simulation for all formats
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
You need Android Studio (Jellyfish or newer), JDK 17, an Android SDK with
|
||||
platform 35, and a network connection for the first Gradle sync.
|
||||
|
||||
1. Open this `android/` folder in Android Studio and let it sync (it will
|
||||
fetch the Gradle wrapper 8.14, AGP 8.10.0, Kotlin 2.0.21, Chaquopy 17.0.0,
|
||||
CameraX 1.3.4, and ML Kit).
|
||||
2. Connect a phone (API 24+) or start an emulator and press **Run**.
|
||||
3. Grant the camera permission when asked.
|
||||
|
||||
Alternatively, from the command line (from the repository root):
|
||||
|
||||
```bash
|
||||
python3 android/scripts/build_apk.py # debug APK + sha256
|
||||
python3 android/scripts/build_apk.py --release # (unsigned) release APK
|
||||
```
|
||||
|
||||
The script re-synchronises the bundled codec modules first (so the APK always
|
||||
carries the current `bal/core` sources), runs `./gradlew`, and prints the APK
|
||||
path, size and sha256. Flags: `--no-sync` (skip the re-sync), `--offline`
|
||||
(Gradle without downloads), `--clean`, `--verbose`.
|
||||
|
||||
Equivalent raw Gradle call:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### If the Gradle wrapper jar is missing
|
||||
|
||||
`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of
|
||||
the box. If it is ever absent, Android Studio regenerates it on the first
|
||||
sync; no manual steps needed.
|
||||
|
||||
## Use
|
||||
|
||||
1. In Electrum + BAL, open the will's **export** dialog.
|
||||
2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**,
|
||||
**BC-UR v2**, and **BBQR**.
|
||||
3. Make sure the wording toggle shows a payload (business logic), then display
|
||||
the animated QR and keep it on screen.
|
||||
4. Point the phone at the screen. The header shows the detected format and
|
||||
`received / total`; scanning stops automatically when the transfer is
|
||||
complete.
|
||||
5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it
|
||||
as `will.json` (whole will) or `will_tx.txt` (transaction list), or press
|
||||
**Scan another**.
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the phone still and the whole QR inside the frame (the codec dedups
|
||||
repeated frames, so a slow capture is fine).
|
||||
- If the camera glares off the screen, reduce brightness or tilt slightly.
|
||||
- If scanning jumps between exports, the app detects the format switch,
|
||||
resets, and asks you to let it re-scan.
|
||||
|
||||
## Keeping the bundled code in sync with the plugin
|
||||
|
||||
The codecs under `app/src/main/python/bal/core/` are **committed copies** for
|
||||
deterministic builds, but they must stay identical to the plugin. Re-run this
|
||||
after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and
|
||||
after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which
|
||||
mirrors `balreader/payload.py`):
|
||||
|
||||
```bash
|
||||
python3 android/scripts/sync_codecs.py # copy
|
||||
python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly)
|
||||
python3 android/test_chain/verify_chain.py # full decode-chain regression
|
||||
```
|
||||
|
||||
`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from
|
||||
the plugin's `decode_will_payload` (AST identity + result parity).
|
||||
|
||||
## Version pins (see `PLAN_ANDROID_READER.md`)
|
||||
|
||||
| Item | Version |
|
||||
|---|---|
|
||||
| AGP | 8.10.0 |
|
||||
| Gradle | 8.14 (wrapper) |
|
||||
| Kotlin | 2.0.21 |
|
||||
| Chaquopy | 17.0.0 (Python 3.12) |
|
||||
| compile / target / min SDK | 35 / 35 / 24 |
|
||||
| CameraX | 1.3.4 |
|
||||
| ML Kit barcode-scanning | 17.3.0 |
|
||||
| JDK | 17 |
|
||||
|
||||
## License
|
||||
|
||||
MIT. The bundled Python codec files inherit the plugin's MIT license
|
||||
(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution.
|
||||
@@ -1,67 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.chaquo.python")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "life.after.bitcoin"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "life.after.bitcoin"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
// Chaquopy requires explicit ABI filters. Python 3.12 ships only for
|
||||
// 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64).
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.12"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
|
||||
// CameraX
|
||||
implementation("androidx.camera:camera-core:1.3.4")
|
||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||
implementation("androidx.camera:camera-view:1.3.4")
|
||||
|
||||
// ML Kit on-device barcode scanning (QR only, no API key)
|
||||
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||
}
|
||||
5
android/app/proguard-rules.pro
vendored
5
android/app/proguard-rules.pro
vendored
@@ -1,5 +0,0 @@
|
||||
# Chaquopy Python runtime.
|
||||
-keep class com.chaquo.python.** { *; }
|
||||
|
||||
# ML Kit barcode scanning.
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="true" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BalReader">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ResultActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,129 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.Context
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.PyException
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Chaquopy bridge over the plugin's animated-QR codecs
|
||||
* (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under
|
||||
* ``app/src/main/python``).
|
||||
*
|
||||
* The decode tail mirrors the plugin's import dialog exactly, and runs
|
||||
* entirely inside Python (``balreader.bridge.finish``) so no container
|
||||
* conversion happens across the bridge:
|
||||
*
|
||||
* session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload()
|
||||
*
|
||||
* Kotlin only feeds frames, reads progress, and renders the JSON the bridge
|
||||
* returns.
|
||||
*/
|
||||
class BalDecoder(private val context: Context) {
|
||||
|
||||
/** Outcome of feeding one scanned frame to the session. */
|
||||
enum class AddResult {
|
||||
/** A new frame was accepted. */
|
||||
OK,
|
||||
|
||||
/** The frame was already present (duplicate); ignore. */
|
||||
DUP,
|
||||
|
||||
/** The frame was not a supported QR transfer; ignore. */
|
||||
GARBAGE,
|
||||
|
||||
/** The QR switched to a different transfer; caller should rescan. */
|
||||
CONFLICT,
|
||||
}
|
||||
|
||||
/** Fully decoded transfer, mirroring the plugin's import tail. */
|
||||
data class DecodedResult(
|
||||
val kind: String, // "will", "txs" or "error"
|
||||
val payload: String, // raw transfer text (JSON or joined tx hexes)
|
||||
val parts: List<String> // [whole-will JSON] or [tx hex strings]
|
||||
)
|
||||
|
||||
private val python: Python by lazy {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(context))
|
||||
}
|
||||
Python.getInstance()
|
||||
}
|
||||
private val animatedQr by lazy { python.getModule("bal.core.animated_qr") }
|
||||
private val bridge by lazy { python.getModule("balreader.bridge") }
|
||||
|
||||
private var session: PyObject? = null
|
||||
|
||||
/** Start a fresh receive session (clears any accumulated frames). */
|
||||
fun reset() {
|
||||
session = null
|
||||
}
|
||||
|
||||
private fun sessionOrCreate(): PyObject {
|
||||
val current = session
|
||||
if (current != null) {
|
||||
return current
|
||||
}
|
||||
return animatedQr.callAttr("AnimatedQrSession").also { session = it }
|
||||
}
|
||||
|
||||
/** Feed one scanned frame string; see [AddResult] for semantics. */
|
||||
fun add(text: String): AddResult {
|
||||
return try {
|
||||
when (sessionOrCreate().callAttr("add_part", text).toString()) {
|
||||
"dup" -> AddResult.DUP
|
||||
else -> AddResult.OK
|
||||
}
|
||||
} catch (e: PyException) {
|
||||
val msg = e.message ?: ""
|
||||
// TransferConflictError: "Switched QR format mid-import (.. -> ..)".
|
||||
if (msg.contains("Switched QR format")) {
|
||||
AddResult.CONFLICT
|
||||
} else {
|
||||
AddResult.GARBAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */
|
||||
val format: String?
|
||||
get() = runCatching {
|
||||
session?.get("format")?.toString()?.takeIf { it != "None" }
|
||||
}.getOrNull()
|
||||
|
||||
/** Number of distinct frames accepted. */
|
||||
val received: Int
|
||||
get() = session?.get("received")?.toInt() ?: 0
|
||||
|
||||
/** Total frames expected for the current transfer (0 until known). */
|
||||
val total: Int
|
||||
get() = session?.get("total")?.toInt() ?: 0
|
||||
|
||||
/** True once the whole transfer has been captured. */
|
||||
val done: Boolean
|
||||
get() = session?.get("done")?.toBoolean() ?: false
|
||||
|
||||
/**
|
||||
* Resolve the completed session into a [DecodedResult]. The decoding runs
|
||||
* in Python (``balreader.bridge.finish``) using the exact same three steps
|
||||
* as the plugin's import dialog.
|
||||
*/
|
||||
fun finish(): DecodedResult {
|
||||
val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString()
|
||||
return try {
|
||||
val obj = JSONObject(jsonText)
|
||||
val partsArray = obj.getJSONArray("parts")
|
||||
val parts = (0 until partsArray.length()).map { partsArray.getString(it) }
|
||||
DecodedResult(
|
||||
kind = obj.getString("kind"),
|
||||
payload = obj.getString("payload"),
|
||||
parts = parts,
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
DecodedResult(kind = "error", payload = jsonText, parts = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Horizontal progress bar showing how many QR frames of the current transfer
|
||||
* have been captured (`received / total`), with a filled mint segment
|
||||
* proportional to the fraction. A thin decorative strip over the camera
|
||||
* preview; the exact count stays in the header's "n / N" label.
|
||||
*/
|
||||
class FrameProgressBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : View(context, attrs) {
|
||||
|
||||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.frame_fill)
|
||||
}
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.frame_track)
|
||||
}
|
||||
private val trackRect = RectF()
|
||||
private val fillRect = RectF()
|
||||
private val cornerRadius = dp(3f)
|
||||
|
||||
private var fraction = 0f
|
||||
|
||||
/** Reset to an empty bar. */
|
||||
fun reset() {
|
||||
fraction = 0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the fill to [received] out of [total] frames captured.
|
||||
* A zero/unknown total clears the bar.
|
||||
*/
|
||||
fun set(total: Int, received: Int) {
|
||||
fraction = if (total > 0) {
|
||||
received.toFloat() / total.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun dp(value: Float): Float =
|
||||
resources.displayMetrics.density * value
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (width <= 0 || height <= 0) {
|
||||
return
|
||||
}
|
||||
trackRect.set(0f, 0f, width.toFloat(), height.toFloat())
|
||||
canvas.drawRoundRect(trackRect, cornerRadius, cornerRadius, trackPaint)
|
||||
if (fraction <= 0f) {
|
||||
return
|
||||
}
|
||||
val fillWidth = width * fraction.coerceIn(0f, 1f)
|
||||
fillRect.set(0f, 0f, fillWidth, height.toFloat())
|
||||
canvas.drawRoundRect(fillRect, cornerRadius, cornerRadius, fillPaint)
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import life.after.bitcoin.BalDecoder.AddResult
|
||||
import life.after.bitcoin.databinding.ActivityMainBinding
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var decoder: BalDecoder
|
||||
private lateinit var barcodeScanner: BarcodeScanner
|
||||
|
||||
private val analyzerExecutor = Executors.newSingleThreadExecutor()
|
||||
private var finished = false
|
||||
private var cameraBound = false
|
||||
private var lastAnalysisMs = 0L
|
||||
|
||||
private val formatLabels: Map<String, String> by lazy {
|
||||
mapOf(
|
||||
"balqr" to getString(R.string.format_balqr),
|
||||
"ur1" to getString(R.string.format_ur1),
|
||||
"ur2" to getString(R.string.format_ur2),
|
||||
"bbqr" to getString(R.string.format_bbqr),
|
||||
)
|
||||
}
|
||||
|
||||
private val requestCameraPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
startCamera()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
decoder = BalDecoder(applicationContext)
|
||||
barcodeScanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
} else {
|
||||
requestCameraPermission.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning from the result screen starts a new scan.
|
||||
if (finished) {
|
||||
finished = false
|
||||
decoder.reset()
|
||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text = "0 / 0"
|
||||
binding.tvStatus.setText(R.string.status_waiting)
|
||||
binding.frameBar.reset()
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cameraProvider?.unbindAll()
|
||||
barcodeScanner.close()
|
||||
analyzerExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
|
||||
private fun startCamera() {
|
||||
if (cameraBound) {
|
||||
return
|
||||
}
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val provider = providerFuture.get()
|
||||
cameraProvider = provider
|
||||
|
||||
val preview = Preview.Builder().build()
|
||||
preview.setSurfaceProvider(binding.previewView.surfaceProvider)
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
analysis.setAnalyzer(analyzerExecutor) { proxy -> analyze(proxy) }
|
||||
|
||||
try {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||
)
|
||||
cameraBound = true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bind camera", e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private fun analyze(proxy: ImageProxy) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (finished || now - lastAnalysisMs < 100) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
lastAnalysisMs = now
|
||||
val image = proxy.image
|
||||
if (image == null) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val input = InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees)
|
||||
barcodeScanner.process(input)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
val value = barcode.rawValue
|
||||
if (!value.isNullOrEmpty()) {
|
||||
handleFrame(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener { proxy.close() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame analysis failure", e)
|
||||
proxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFrame(value: String) {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
when (decoder.add(value)) {
|
||||
AddResult.OK -> {
|
||||
Log.i(TAG, "frame ok fmt=${decoder.format} rcvd=${decoder.received}/${decoder.total} done=${decoder.done}")
|
||||
binding.tvFormat.text = decoder.format?.let { formatLabels[it] }
|
||||
?: getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text =
|
||||
getString(R.string.progress_fmt, decoder.received, decoder.total)
|
||||
binding.frameBar.set(decoder.total, decoder.received)
|
||||
if (decoder.done) {
|
||||
finishScan()
|
||||
}
|
||||
}
|
||||
AddResult.DUP -> Log.i(TAG, "frame dup")
|
||||
AddResult.GARBAGE -> Log.w(TAG, "frame garbage")
|
||||
AddResult.CONFLICT -> {
|
||||
Log.w(TAG, "format conflict - resetting")
|
||||
decoder.reset()
|
||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text = "0 / 0"
|
||||
binding.tvStatus.setText(R.string.conflict_message)
|
||||
binding.frameBar.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishScan() {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
val result = decoder.finish()
|
||||
Log.i(TAG, "FINISH kind=${result.kind} payload=${result.payload.length}B parts=${result.parts.size}")
|
||||
val intent = Intent(this, ResultActivity::class.java).apply {
|
||||
putExtra(ResultActivity.EXTRA_KIND, result.kind)
|
||||
putExtra(ResultActivity.EXTRA_PAYLOAD, result.payload)
|
||||
putStringArrayListExtra(ResultActivity.EXTRA_PARTS, ArrayList(result.parts))
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BalReader"
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import life.after.bitcoin.databinding.ActivityResultBinding
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
class ResultActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityResultBinding
|
||||
private var kind = "txs"
|
||||
private var payload = ""
|
||||
private var parts: List<String> = emptyList()
|
||||
|
||||
private val saveWillPicker =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? ->
|
||||
saveTo(uri)
|
||||
}
|
||||
private val saveTxsPicker =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? ->
|
||||
saveTo(uri)
|
||||
}
|
||||
|
||||
private fun saveTo(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityResultBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
kind = intent.getStringExtra(EXTRA_KIND) ?: "txs"
|
||||
payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: ""
|
||||
parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList()
|
||||
|
||||
binding.tvKind.text =
|
||||
getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs)
|
||||
binding.tvContent.text = pretty(payload)
|
||||
|
||||
binding.btnCopy.setOnClickListener {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload))
|
||||
Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnShare.setOnClickListener {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, payload)
|
||||
}
|
||||
startActivity(Intent.createChooser(send, null))
|
||||
}
|
||||
binding.btnSave.setOnClickListener { saveFile() }
|
||||
binding.btnScanAnother.setOnClickListener { finish() }
|
||||
}
|
||||
|
||||
private fun pretty(json: String): String {
|
||||
if (kind == "will") {
|
||||
try {
|
||||
return JSONObject(json).toString(2)
|
||||
} catch (_: JSONException) {
|
||||
return json
|
||||
}
|
||||
}
|
||||
// Transaction list: one numbered line per tx.
|
||||
if (parts.isNotEmpty()) {
|
||||
return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) }
|
||||
.joinToString("\n")
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private fun saveFile() {
|
||||
val name = if (kind == "will") {
|
||||
getString(R.string.save_file_will)
|
||||
} else {
|
||||
getString(R.string.save_file_txs)
|
||||
}
|
||||
// ActivityResultContracts.CreateDocument takes the suggested file name;
|
||||
// it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally.
|
||||
val picker = if (kind == "will") saveWillPicker else saveTxsPicker
|
||||
picker.launch(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_KIND = "kind"
|
||||
const val EXTRA_PAYLOAD = "payload"
|
||||
const val EXTRA_PARTS = "parts"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
bal.core
|
||||
========
|
||||
|
||||
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
Everything in this sub-package MUST stay completely free of any GUI / Qt
|
||||
imports. The rule of thumb is:
|
||||
|
||||
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
|
||||
and validating transactions, talking to
|
||||
will-executor servers, persistence helpers).
|
||||
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
|
||||
|
||||
Keeping the two apart is the main motivation behind this rewrite: the original
|
||||
code mixed transaction-building logic and presentation inside a single
|
||||
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
|
||||
|
||||
No behaviour is changed with respect to the original plugin; the code has only
|
||||
been reorganised and documented.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +0,0 @@
|
||||
"""
|
||||
bal.core.qrtransfer
|
||||
===================
|
||||
|
||||
GUI-free helpers for moving BAL will data between devices via QR codes or
|
||||
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
|
||||
|
||||
Scope
|
||||
-----
|
||||
* converts will transactions into a compact ``transfer_string``
|
||||
(newline-joined serialized transactions, optionally zlib + base64
|
||||
compressed);
|
||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
|
||||
Wire format (v2, compact)
|
||||
-------------------------
|
||||
A frame is::
|
||||
|
||||
BAL1<TTT><iii><flag><payload>
|
||||
|
||||
* ``BAL1`` - magic + format era (4 chars).
|
||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||
* ``payload`` - every other character of the frame; the payloads of all
|
||||
frames, concatenated in index order, rebuild the transfer string.
|
||||
|
||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||
(same 5 pieces of information) without any pipe separator, so the whole
|
||||
frame is scan-friendly and the overhead no longer grows with the frame
|
||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||
|
||||
The audio-modem channel deliberately bypasses the framing helpers here
|
||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||
carries the whole transfer string in a single blob, so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
FLAG_PLAIN = "0"
|
||||
|
||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
("Medium - ~400 bytes/QR", 400),
|
||||
("Large - ~900 bytes/QR", 900),
|
||||
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||
)
|
||||
|
||||
# Smallest allowed payload budget per frame, below which the frame header
|
||||
# could consume the whole budget.
|
||||
MIN_CHUNK_SIZE = 40
|
||||
|
||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||
|
||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||
_FRAME_MAGIC_V2 = "BAL1"
|
||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
_BASE36_WIDTH = 3
|
||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||
|
||||
|
||||
class QrTransferError(ValueError):
|
||||
"""Base error for will QR / audio transfer processing."""
|
||||
|
||||
|
||||
class MissingFramesError(QrTransferError):
|
||||
"""Some frame indices of a multi-QR transfer are missing."""
|
||||
|
||||
def __init__(self, missing):
|
||||
self.missing = list(missing)
|
||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||
|
||||
|
||||
class InconsistentTotalError(QrTransferError):
|
||||
"""Frames disagree about the advertised frame total."""
|
||||
|
||||
|
||||
def encode_transfer(tx_strings, compress=False):
|
||||
"""Join serialized transaction strings into a transfer string.
|
||||
|
||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||
of the frame header lets the importer reverse this automatically.
|
||||
"""
|
||||
return __compress("\n".join(tx_strings), enabled=compress)
|
||||
|
||||
|
||||
def encode_transfer_best(tx_strings):
|
||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||
|
||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||
when zlib + base64 really is shorter (best-of, never larger).
|
||||
"""
|
||||
joined = "\n".join(tx_strings)
|
||||
plain = joined
|
||||
compressed = __compress(joined, enabled=True)
|
||||
if len(compressed) < len(plain):
|
||||
return compressed, True
|
||||
return plain, False
|
||||
|
||||
|
||||
def decode_transfer(transfer_string, compressed):
|
||||
"""Inverse of :func:`encode_transfer`.
|
||||
|
||||
Returns the list of serialized transaction strings; empty frames are
|
||||
dropped so a trailing newline (or an empty payload) cannot produce an
|
||||
empty trailing element.
|
||||
"""
|
||||
text = __decompress(transfer_string, enabled=compressed)
|
||||
return [part for part in text.split("\n") if part]
|
||||
|
||||
|
||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||
|
||||
Every returned frame has the fixed 11-char v2 header followed by its
|
||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||
importer knows how to reverse the encoding.
|
||||
|
||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||
the header plus any payload, or when the transfer needs more than
|
||||
:data:`_MAX_TOTAL` frames.
|
||||
"""
|
||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||
total = __compute_total(len(transfer_string), chunk_size)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
frames = []
|
||||
pos = 0
|
||||
length = len(transfer_string)
|
||||
for index in range(1, total + 1):
|
||||
end = min(pos + budget, length)
|
||||
frames.append(
|
||||
_FRAME_MAGIC_V2
|
||||
+ _base36(total)
|
||||
+ _base36(index)
|
||||
+ flag
|
||||
+ transfer_string[pos:end]
|
||||
)
|
||||
pos = end
|
||||
if pos < length:
|
||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_frame(frame):
|
||||
"""Parse a single frame.
|
||||
|
||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||
|
||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||
"""
|
||||
if frame.startswith(_FRAME_MAGIC_V2):
|
||||
return _parse_v2(frame)
|
||||
return _parse_v1(frame)
|
||||
|
||||
|
||||
def assemble(frames, total):
|
||||
"""Concatenate frame payloads back into a transfer string.
|
||||
|
||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||
``total`` (else :class:`InconsistentTotalError`).
|
||||
"""
|
||||
if total < 1:
|
||||
raise QrTransferError("invalid frame total")
|
||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||
if missing:
|
||||
raise MissingFramesError(missing)
|
||||
extra = [index for index in frames if index > total]
|
||||
if extra:
|
||||
raise InconsistentTotalError()
|
||||
return "".join(frames[index] for index in range(1, total + 1))
|
||||
|
||||
|
||||
def preset_index_for_chunk_size(chunk_size):
|
||||
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||
diff = abs(chunk_size - budget)
|
||||
if diff < best_diff:
|
||||
best, best_diff = index, diff
|
||||
return best
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Internals
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def __compress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
|
||||
|
||||
|
||||
def __decompress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||
|
||||
|
||||
def _base36(n):
|
||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||
if not 0 <= n <= _MAX_TOTAL:
|
||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||
chars = []
|
||||
for _ in range(_BASE36_WIDTH):
|
||||
chars.append(_BASE36_DIGITS[n % 36])
|
||||
n //= 36
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _base36_decode(text):
|
||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||
raise ValueError(text)
|
||||
n = 0
|
||||
for c in text:
|
||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||
return n
|
||||
|
||||
|
||||
def _parse_v1(frame):
|
||||
parts = frame.split("|", maxsplit=4)
|
||||
if len(parts) != 5:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
magic_seen, total_s, index_s, flags, payload = parts
|
||||
if magic_seen != _FRAME_MAGIC_V1:
|
||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||
try:
|
||||
total = int(total_s)
|
||||
index = int(index_s)
|
||||
except ValueError as e:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flags not in ("", FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
return total, index, flags == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def _parse_v2(frame):
|
||||
if len(frame) < _HEADER_V2_LEN:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||
# make up the rest of the fixed header.
|
||||
offset = len(_FRAME_MAGIC_V2)
|
||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||
try:
|
||||
total = _base36_decode(total_s)
|
||||
index = _base36_decode(index_s)
|
||||
except ValueError:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
payload = frame[_HEADER_V2_LEN:]
|
||||
return total, index, flag == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def __compute_total(transfer_len, chunk_size):
|
||||
"""Smallest frame count whose budget covers the whole transfer string.
|
||||
|
||||
The v2 header is fixed-width, so the budget is constant and the count is
|
||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||
"""
|
||||
if chunk_size < MIN_CHUNK_SIZE:
|
||||
raise QrTransferError(
|
||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||
)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
total = -(-transfer_len // budget)
|
||||
if total < 1:
|
||||
total = 1
|
||||
if total > _MAX_TOTAL:
|
||||
raise QrTransferError(
|
||||
"BAL QR transfer demands too many frames: {}".format(total)
|
||||
)
|
||||
return total
|
||||
@@ -1 +0,0 @@
|
||||
"""Android reader helpers built on the bundled plugin codecs."""
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON.
|
||||
|
||||
A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side
|
||||
and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are
|
||||
the same three calls the plugin's import dialog performs:
|
||||
|
||||
session.resolve() -> (transfer_text, compressed)
|
||||
qrtransfer.decode_transfer -> parts
|
||||
balreader.payload.decode_will_payload -> ("will"|"txs", data)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from bal.core import qrtransfer as _qrtransfer
|
||||
from balreader import payload as _payload
|
||||
|
||||
|
||||
def finish(session):
|
||||
"""Run the import tail on a live ``AnimatedQrSession``.
|
||||
|
||||
Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}``
|
||||
with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns
|
||||
``{"kind": "error", "payload": <message>, "parts": []}`` so a misbehaving
|
||||
session can never crash the UI thread.
|
||||
"""
|
||||
try:
|
||||
transfer, compressed = session.resolve()
|
||||
parts = list(_qrtransfer.decode_transfer(transfer, compressed))
|
||||
payload = "\n".join(parts)
|
||||
kind, _data = _payload.decode_will_payload(payload)
|
||||
return json.dumps({"kind": kind, "payload": payload, "parts": parts})
|
||||
except Exception as exc: # noqa: BLE001 - defensive bridge boundary
|
||||
return json.dumps({"kind": "error", "payload": str(exc), "parts": []})
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Will-payload autodetection for the BAL Reader app.
|
||||
|
||||
This file is a verbatim copy of ``decode_will_payload`` from
|
||||
``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the
|
||||
two functions result-for-result so they can never drift apart.
|
||||
|
||||
Keep the function body identical to the plugin source.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_will_payload(text) -> tuple[Any, Any]:
|
||||
"""Autodetect: whole-will JSON or transaction list?
|
||||
|
||||
Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON
|
||||
object whose values are dicts containing a ``"tx"`` key (the whole-will
|
||||
format produced by :meth:`BalWindow.export_json_file` and friends).
|
||||
Otherwise returns ``("txs", [tx_strings])`` where the transaction
|
||||
strings were split on commas and/or newlines.
|
||||
"""
|
||||
text = text.strip()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
data = None
|
||||
if isinstance(data, dict) and data:
|
||||
if all(isinstance(v, dict) and "tx" in v for v in data.values()):
|
||||
return ("will", data)
|
||||
parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()]
|
||||
return ("txs", parts)
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,14h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M19,19h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M72,14h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M77,19h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,72h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M19,77h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,42h4v4h-4z M22,42h4v4h-4z M14,50h4v4h-4z M22,50h4v4h-4z M14,58h4v4h-4z M30,42h4v4h-4z M30,50h4v4h-4z M14,66h4v4h-4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M52,14h4v4h-4z M60,14h4v4h-4z M52,22h4v4h-4z M68,14h4v4h-4z M52,30h4v4h-4z M56,42h4v4h-4z M64,42h4v4h-4z M56,50h4v4h-4z M72,42h4v4h-4z M56,58h4v4h-4z M64,58h4v4h-4z M56,66h4v4h-4z M64,66h4v4h-4z M72,58h4v4h-4z M64,74h4v4h-4z M72,66h4v4h-4z" />
|
||||
</vector>
|
||||
@@ -1,59 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@android:color/black">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:background="#1A1A1A">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_format"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/format_placeholder"
|
||||
android:textColor="#9BE8C0"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0 / 0"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<life.after.bitcoin.FrameProgressBar
|
||||
android:id="@+id/frame_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/preview_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="10dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/status_waiting"
|
||||
android:textColor="#CFCFCF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
@@ -1,67 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_kind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?android:attr/colorBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:padding="8dp" />
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_copy" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_share"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_share" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_save"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_save" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_scan_another"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_scan_another" />
|
||||
</LinearLayout>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="frame_fill">#9BE8C0</color>
|
||||
<color name="frame_track">#3A3A3A</color>
|
||||
</resources>
|
||||
@@ -1,24 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">BAL Reader</string>
|
||||
|
||||
<string name="status_waiting">Point the camera at the QR screen</string>
|
||||
<string name="format_placeholder">—</string>
|
||||
<string name="progress_fmt">%1$d / %2$d</string>
|
||||
|
||||
<string name="format_balqr">BAL QR</string>
|
||||
<string name="format_ur1">BC-UR v1</string>
|
||||
<string name="format_ur2">BC-UR v2</string>
|
||||
<string name="format_bbqr">BBQR</string>
|
||||
|
||||
<string name="result_kind_will">Whole will (JSON)</string>
|
||||
<string name="result_kind_txs">Transaction list</string>
|
||||
<string name="action_copy">Copy</string>
|
||||
<string name="action_share">Share</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="action_scan_another">Scan another</string>
|
||||
<string name="copied_toast">Transfer copied to clipboard</string>
|
||||
<string name="save_file_will">will.json</string>
|
||||
<string name="save_file_txs">will_tx.txt</string>
|
||||
<string name="permission_denied">Camera permission is required to scan QR codes.</string>
|
||||
<string name="conflict_message">The QR switched to a different transfer. Let it rescan.</string>
|
||||
</resources>
|
||||
@@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
// Top-level build file: plugin versions only. See the docs for the full
|
||||
// compatibility matrix (Chaquopy 17 requires AGP 7.3-9.2 and minSdk 24).
|
||||
plugins {
|
||||
id("com.android.application") version "8.10.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("com.chaquo.python") version "17.0.0" apply false
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
251
android/gradlew
vendored
@@ -1,251 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
94
android/gradlew.bat
vendored
@@ -1,94 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the BAL Reader Android APK via Gradle.
|
||||
|
||||
Re-synchronises the bundled codec modules into the app (so the APK always
|
||||
carries the current ``bal/core`` sources), then invokes the Gradle wrapper to
|
||||
produce the APK, and finally prints the artifact path, size and sha256.
|
||||
|
||||
Run from the repository root (any Python 3.8+, needs JDK 17 and an Android
|
||||
SDK; the first build also needs a network connection for Gradle downloads):
|
||||
|
||||
python3 android/scripts/build_apk.py # debug APK
|
||||
python3 android/scripts/build_apk.py --release # (unsigned) release APK
|
||||
python3 android/scripts/build_apk.py --no-sync # skip codec re-sync
|
||||
python3 android/scripts/build_apk.py --offline # gradle --offline
|
||||
|
||||
The APK is written under ``android/app/build/outputs/apk/``.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
ANDROID_DIR = REPO_ROOT / "android"
|
||||
GRADLEW = ANDROID_DIR / "gradlew"
|
||||
LOCAL_PROPERTIES = ANDROID_DIR / "local.properties"
|
||||
WRAPPER_JAR = ANDROID_DIR / "gradle" / "wrapper" / "gradle-wrapper.jar"
|
||||
|
||||
VARIANTS = {
|
||||
"debug": "assembleDebug",
|
||||
"release": "assembleRelease",
|
||||
}
|
||||
APK_REL = {
|
||||
"debug": Path("app") / "build" / "outputs" / "apk" / "debug" / "app-debug.apk",
|
||||
"release": Path("app") / "build" / "outputs" / "apk" / "release" / "app-release-unsigned.apk",
|
||||
}
|
||||
|
||||
SYNC_SCRIPT = ANDROID_DIR / "scripts" / "sync_codecs.py"
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def run(cmd, cwd, verbose: bool) -> int:
|
||||
if verbose:
|
||||
print("+", " ".join(str(c) for c in cmd))
|
||||
result = subprocess.run(
|
||||
cmd, cwd=str(cwd), capture_output=not verbose, text=True
|
||||
)
|
||||
if not verbose:
|
||||
sys.stdout.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def check_prerequisites() -> None:
|
||||
if not (GRADLEW.exists() and WRAPPER_JAR.exists()):
|
||||
sys.exit(
|
||||
"error: gradle wrapper is incomplete ({} missing).\n"
|
||||
"hint: run `gradle wrapper` in android/ once, or re-clone.".format(
|
||||
WRAPPER_JAR if not WRAPPER_JAR.exists() else GRADLEW
|
||||
)
|
||||
)
|
||||
|
||||
java_ok = None
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["java", "-version"], capture_output=True, text=True, check=False
|
||||
).stderr
|
||||
match = re.search(r'version "(?:1\.)?(\d+)', out)
|
||||
java_ok = int(match.group(1)) if match else None
|
||||
except FileNotFoundError:
|
||||
java_ok = None
|
||||
if java_ok is None:
|
||||
sys.exit("error: no JDK found on PATH (need JDK 17 for AGP 8.10).")
|
||||
if java_ok < 17:
|
||||
sys.exit("error: JDK {} on PATH, but the Android build needs JDK 17.".format(java_ok))
|
||||
|
||||
sdk = None
|
||||
if LOCAL_PROPERTIES.exists():
|
||||
for line in LOCAL_PROPERTIES.read_text().splitlines():
|
||||
if line.startswith("sdk.dir="):
|
||||
sdk = line.split("=", 1)[1]
|
||||
sdk = sdk or os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
|
||||
if not sdk or not Path(sdk).exists():
|
||||
sys.exit(
|
||||
"error: Android SDK not found.\n"
|
||||
"hint: set sdk.dir in android/local.properties or ANDROID_HOME."
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--release",
|
||||
action="store_true",
|
||||
help="build the (unsigned) release APK instead of the debug APK",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-sync",
|
||||
action="store_true",
|
||||
help="skip re-synchronising the bundled codec modules",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline",
|
||||
action="store_true",
|
||||
help="pass --offline to Gradle (no dependency downloads)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean",
|
||||
action="store_true",
|
||||
help="run the Gradle clean task before building",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="stream Gradle output"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
check_prerequisites()
|
||||
variant = "release" if args.release else "debug"
|
||||
|
||||
if not args.no_sync:
|
||||
sync = subprocess.run(
|
||||
[sys.executable, str(SYNC_SCRIPT)], cwd=str(REPO_ROOT), check=False
|
||||
)
|
||||
if sync.returncode != 0:
|
||||
print("error: codec re-sync failed; refusing to build a stale APK.")
|
||||
return sync.returncode
|
||||
|
||||
tasks = []
|
||||
if args.clean:
|
||||
tasks.append("clean")
|
||||
tasks.append(VARIANTS[variant])
|
||||
cmd = [str(GRADLEW)]
|
||||
if args.offline:
|
||||
cmd.append("--offline")
|
||||
cmd.extend(tasks)
|
||||
|
||||
rc = run(cmd, cwd=ANDROID_DIR, verbose=args.verbose)
|
||||
if rc != 0:
|
||||
print("error: Gradle {} failed (exit {}).".format(" ".join(tasks), rc))
|
||||
return rc
|
||||
|
||||
apk = ANDROID_DIR / APK_REL[variant]
|
||||
if not apk.exists():
|
||||
print("error: expected APK not found at {}".format(apk))
|
||||
return 1
|
||||
data = apk.read_bytes()
|
||||
print("APK : {}".format(apk.relative_to(REPO_ROOT)))
|
||||
print("size : {} bytes".format(len(data)))
|
||||
print("sha256: {}".format(sha256(data)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronise the BAL QR codec modules into the Android app.
|
||||
|
||||
Copies ``bal/core/{__init__,animated_qr,qrtransfer}.py`` from the plugin repo
|
||||
into ``android/app/src/main/python/bal/core/`` so Chaquopy ships exactly the
|
||||
same code the desktop plugin runs. Afterwards the copies are imported
|
||||
standalone and used for one quick encode/decode round trip.
|
||||
|
||||
Run from the repository root (any Python 3.8+, no dependencies):
|
||||
|
||||
python3 android/scripts/sync_codecs.py
|
||||
python3 android/scripts/sync_codecs.py --check # no writes
|
||||
|
||||
Re-run whenever ``bal/core/animated_qr.py`` or ``bal/core/qrtransfer.py``
|
||||
changes; the bundled copies are committed for deterministic builds.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CORE_SRC = REPO_ROOT / "bal" / "core"
|
||||
PYTHON_DEST = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
||||
BAL_CORE_DEST = PYTHON_DEST / "bal" / "core"
|
||||
|
||||
FILES = ("__init__.py", "animated_qr.py", "qrtransfer.py")
|
||||
|
||||
ROUNDTRIP = (
|
||||
"import sys; "
|
||||
"sys.path.insert(0, {dest!r}); "
|
||||
"from bal.core.animated_qr import AnimatedQrSession, ur2_frames; "
|
||||
"import bal.core.qrtransfer as qtf; "
|
||||
"frames = ur2_frames(b'roundtrip-check', 400); "
|
||||
"assert frames, 'no frames produced'; "
|
||||
"s = AnimatedQrSession(); "
|
||||
"assert all(s.add_part(f) == 'ok' for f in frames); "
|
||||
"transfer, compressed = s.resolve(); "
|
||||
"assert not compressed and transfer == 'roundtrip-check', 'roundtrip failed'; "
|
||||
"print('standalone import + roundtrip OK'); "
|
||||
)
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def check_up_to_date() -> int:
|
||||
outdated = []
|
||||
for name in FILES:
|
||||
src = (CORE_SRC / name).read_bytes()
|
||||
dst = BAL_CORE_DEST / name
|
||||
if not dst.exists() or dst.read_bytes() != src:
|
||||
outdated.append(name)
|
||||
if outdated:
|
||||
print("OUT OF DATE: {}".format(", ".join(outdated)))
|
||||
print("run: python3 android/scripts/sync_codecs.py")
|
||||
return 1
|
||||
print("codec bundles are up to date")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="verify the bundled copies are up to date without writing",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.check:
|
||||
return check_up_to_date()
|
||||
|
||||
BAL_CORE_DEST.mkdir(parents=True, exist_ok=True)
|
||||
for name in FILES:
|
||||
src = (CORE_SRC / name).read_bytes()
|
||||
dst = BAL_CORE_DEST / name
|
||||
dst.write_bytes(src)
|
||||
print("synced {:16s} sha256={}".format(name, sha256(src)[:16]))
|
||||
|
||||
run = subprocess.run(
|
||||
[sys.executable, "-c", ROUNDTRIP.format(dest=str(PYTHON_DEST))],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if run.returncode != 0:
|
||||
print(run.stdout, end="")
|
||||
print(run.stderr, end="")
|
||||
return 1
|
||||
print(run.stdout.strip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,23 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "BALReader"
|
||||
include(":app")
|
||||
@@ -1,314 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the Android app can decode every frame format the plugin exports.
|
||||
|
||||
Simulates the exact runtime path of the APK on the development machine:
|
||||
|
||||
* imports ``bal.core`` and ``balreader.payload`` from the *bundled* copies in
|
||||
``android/app/src/main/python`` (the code Chaquopy actually ships);
|
||||
* generates frames exactly as the plugin's export page does
|
||||
(``split_frames`` for BAL QR, ``encode_animated_frames`` semantics for
|
||||
UR v1 / UR v2 / BBQR);
|
||||
* drives an :class:`~bal.core.animated_qr.AnimatedQrSession` the way
|
||||
``BalDecoder.add`` does (scrambled input, duplicates, dropped frames);
|
||||
* runs the app's ``finish()`` chain (``resolve()`` -> ``decode_transfer()``
|
||||
-> ``decode_will_payload()``) and checks the result;
|
||||
* cross-checks the app's ``decode_will_payload`` copy result-for-result (and
|
||||
AST-for-AST) against the plugin's original in ``bal/gui/qt/dialogs.py``.
|
||||
|
||||
Run from the repository root (any Python 3.8+, no dependencies):
|
||||
|
||||
python3 android/test_chain/verify_chain.py
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_PYTHON = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
||||
DIALOGS = REPO_ROOT / "bal" / "gui" / "qt" / "dialogs.py"
|
||||
|
||||
sys.path.insert(0, str(APP_PYTHON))
|
||||
|
||||
from bal.core import animated_qr as aq # noqa: E402
|
||||
from bal.core import qrtransfer as qtf # noqa: E402
|
||||
|
||||
from balreader import bridge as bridge_codec # noqa: E402
|
||||
from balreader import payload as payload_codec # noqa: E402
|
||||
|
||||
PASSED = 0
|
||||
|
||||
|
||||
def ok(condition, label):
|
||||
global PASSED
|
||||
if not condition:
|
||||
raise AssertionError("FAILED: " + label)
|
||||
PASSED += 1
|
||||
|
||||
|
||||
def check_imported_bundle():
|
||||
for module in (aq, qtf, payload_codec):
|
||||
assert module.__file__ is not None
|
||||
path = str(Path(module.__file__).resolve())
|
||||
assert path.startswith(str(APP_PYTHON)), path
|
||||
ok(True, "all modules imported from the bundled android/ copies")
|
||||
|
||||
|
||||
def find_function(tree, name):
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise RuntimeError("{} not found".format(name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
TXS = ["{:064x}".format(i) for i in range(1, 8)]
|
||||
WILL_ITEMS = {
|
||||
"imp{}".format(i): {
|
||||
"tx": "{:064x}".format(i + 1),
|
||||
"addr": "bc1qdeadbeef{:x}".format(i),
|
||||
"amount": 100000 + i,
|
||||
"tag": "heiress-{}".format(i),
|
||||
"metadata": {},
|
||||
"notify": "mail-{}@example.invalid".format(i),
|
||||
}
|
||||
for i in range(3)
|
||||
}
|
||||
WILL_JSON_COMPACT = json.dumps(WILL_ITEMS, separators=(",", ":"))
|
||||
WILL_JSON_PRETTY = json.dumps(WILL_ITEMS, indent=2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parity: app's decode_will_payload vs the plugin's dialogs.py original
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def build_extracted_and_app_function():
|
||||
dialogs_source = DIALOGS.read_text()
|
||||
app_source = Path(payload_codec.__file__).read_text()
|
||||
dialogs_node = find_function(ast.parse(dialogs_source), "decode_will_payload")
|
||||
app_node = find_function(ast.parse(app_source), "decode_will_payload")
|
||||
ok(
|
||||
ast.dump(app_node) == ast.dump(dialogs_node),
|
||||
"decode_will_payload AST identical between app copy and plugin",
|
||||
)
|
||||
namespace = {}
|
||||
exec("import json\nimport re\nfrom typing import Any", namespace)
|
||||
exec(compile(ast.Module(body=[dialogs_node], type_ignores=[]), "dialogs.py", "exec"), namespace)
|
||||
return namespace["decode_will_payload"]
|
||||
|
||||
|
||||
def run_payload_parity_cases():
|
||||
plugin_decode = build_extracted_and_app_function()
|
||||
samples = {
|
||||
"will-compact": WILL_JSON_COMPACT,
|
||||
"will-pretty": WILL_JSON_PRETTY,
|
||||
"txs-newlines": "\n".join(TXS),
|
||||
"txs-comma-crlf": ",\r\n".join(TXS[:3]),
|
||||
"single-tx": TXS[0],
|
||||
"json-array": json.dumps(TXS),
|
||||
"not-json-dict": "hello world",
|
||||
"empty": "",
|
||||
"whitespace": " \n\t ",
|
||||
}
|
||||
for label, text in samples.items():
|
||||
app_result = payload_codec.decode_will_payload(text)
|
||||
plugin_result = plugin_decode(text)
|
||||
ok(
|
||||
app_result == plugin_result,
|
||||
"payload parity for {!r}".format(label),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Full decode chain (the app's BalDecoder.finish())
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def app_chain(transfer_text, compressed):
|
||||
parts = qtf.decode_transfer(transfer_text, compressed)
|
||||
payload = "\n".join(parts)
|
||||
kind, data = payload_codec.decode_will_payload(payload)
|
||||
return parts, payload, kind, data
|
||||
|
||||
|
||||
def feed_frame_set(session, frames, *, drop=None, order=None):
|
||||
indexes = list(range(len(frames)))
|
||||
if drop:
|
||||
indexes = [i for i in indexes if i not in drop]
|
||||
if order is not None:
|
||||
indexes = list(order)
|
||||
for i in indexes:
|
||||
session.add_part(frames[i])
|
||||
|
||||
|
||||
def make_frames(transfer_text, fmt, budget_chars, *, compressed=False):
|
||||
payload = transfer_text.encode("utf-8")
|
||||
if fmt == "balqr":
|
||||
return qtf.split_frames(transfer_text, budget_chars, compressed=compressed)
|
||||
if fmt == "ur1":
|
||||
return aq.ur1_frames(payload, budget_chars)
|
||||
if fmt == "ur2":
|
||||
return aq.ur2_frames(payload, budget_chars)
|
||||
if fmt == "bbqr":
|
||||
return aq.bbqr_frames(payload, budget_chars, encoding="Z")
|
||||
raise AssertionError("unknown format " + fmt)
|
||||
|
||||
|
||||
def run_transport_case(transport, budget_chars, transfer_text, compressed=False):
|
||||
frames = make_frames(transfer_text, transport, budget_chars, compressed=compressed)
|
||||
session = aq.AnimatedQrSession()
|
||||
rng = random.Random(len(transfer_text) + len(transport.encode()))
|
||||
order = [i for i in range(len(frames))]
|
||||
rng.shuffle(order)
|
||||
feed_frame_set(session, frames, order=order)
|
||||
ok(session.done, "{} (.{} chars) reaches done in scrambled order".format(transport, budget_chars))
|
||||
if transport == "ur2":
|
||||
# Fountain indexes can range wider than seq_len, so received may
|
||||
# exceed (or fall short of) total; only progress and completion matter.
|
||||
ok(session.total >= 1 and session.received >= 1,
|
||||
"{} reports positive progress".format(transport))
|
||||
else:
|
||||
ok(
|
||||
session.received == session.total,
|
||||
"{} received matches total".format(transport),
|
||||
)
|
||||
ok(
|
||||
session.received == len(frames) and session.total == len(frames),
|
||||
"{} received/total equals frame count".format(transport),
|
||||
)
|
||||
transfer, compressed_flag = session.resolve()
|
||||
ok(transfer == transfer_text, "{} restores exact transfer text".format(transport))
|
||||
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
||||
bridge_json = json.loads(bridge_codec.finish(session))
|
||||
ok(
|
||||
bridge_json == {"kind": kind, "payload": payload, "parts": parts},
|
||||
"{} bridge.finish JSON matches the app chain".format(transport),
|
||||
)
|
||||
return parts, payload, kind, data
|
||||
|
||||
|
||||
def test_tx_transports():
|
||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
parts, payload, kind, data = run_transport_case(transport, 400, transfer)
|
||||
ok(kind == "txs", "{} classifies as txs".format(transport))
|
||||
ok(parts == TXS and payload == "\n".join(TXS), "{} yields the tx list".format(transport))
|
||||
|
||||
|
||||
def test_compressed_bal_transport():
|
||||
transfer = qtf.encode_transfer(TXS, compress=True)
|
||||
frames = make_frames(transfer, "balqr", 400, compressed=True)
|
||||
session = aq.AnimatedQrSession()
|
||||
feed_frame_set(session, frames)
|
||||
ok(session.done, "compressed BAL QR done")
|
||||
parts, payload, kind, data = app_chain(*session.resolve())
|
||||
ok(kind == "txs" and parts == TXS, "compressed BAL QR yields the tx list")
|
||||
|
||||
|
||||
def test_will_transports():
|
||||
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
parts, payload, kind, data = run_transport_case(transport, 400, WILL_JSON_COMPACT)
|
||||
ok(kind == "will", "{} classifies as will".format(transport))
|
||||
ok(data == WILL_ITEMS, "{} restores the whole-will dict".format(transport))
|
||||
# Pretty JSON works too (blank lines are whitespace for json.loads).
|
||||
parts, payload, kind, data = run_transport_case("ur2", 400, WILL_JSON_PRETTY)
|
||||
ok(kind == "will" and data == WILL_ITEMS, "pretty JSON transport restores the will dict")
|
||||
|
||||
|
||||
def test_duplicates_are_ignored():
|
||||
# UR v1 (multi-fragment) dedups by fragment index; UR v2 fountains never
|
||||
# report "dup" (they dedup internally), matching the plugin's behaviour.
|
||||
frames = make_frames(WILL_JSON_COMPACT, "ur1", 200)
|
||||
ok(len(frames) >= 2, "UR v1 yields multiple fragments (got {})".format(len(frames)))
|
||||
session = aq.AnimatedQrSession()
|
||||
for i in range(len(frames)):
|
||||
first = session.add_part(frames[i])
|
||||
dup = session.add_part(frames[i])
|
||||
ok(first == "ok", "fresh UR v1 frame reported as ok")
|
||||
ok(dup == "dup", "duplicate UR v1 frame reported as dup")
|
||||
ok(session.received == len(frames), "duplicates do not inflate received")
|
||||
ok(session.done, "UR v1 completes after duplicates")
|
||||
|
||||
|
||||
def test_ur2_fountain_survives_loss_and_reorder():
|
||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||
frames = make_frames(transfer, "ur2", 120)
|
||||
ok(len(frames) >= 6, "fountain yields multiple frames (got {})".format(len(frames)))
|
||||
drop = (0, 2, len(frames) - 1)
|
||||
session = aq.AnimatedQrSession()
|
||||
rng = random.Random(99)
|
||||
order = [i for i in range(len(frames)) if i not in drop]
|
||||
rng.shuffle(order)
|
||||
feed_frame_set(session, frames, order=order, drop=drop)
|
||||
ok(session.done, "fountain completes after dropped + reordered frames")
|
||||
transfer, compressed_flag = session.resolve()
|
||||
ok(transfer == transfer, "fountain restores exact transfer text")
|
||||
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
||||
ok(kind == "txs" and parts == TXS, "fountain output feeds the full import tail")
|
||||
|
||||
|
||||
def test_ur2_single_part_and_duplicate():
|
||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||
frames = make_frames(transfer, "ur2", 2000)
|
||||
ok(len(frames) == 1, "large budget yields a single-part UR v2 frame")
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(frames[0])
|
||||
ok(session.done and session.received == 1, "single-part UR v2 completes")
|
||||
|
||||
|
||||
def test_bbqr_all_three_encodings():
|
||||
for encoding in ("Z", "H", "2"):
|
||||
frames = aq.bbqr_frames(WILL_JSON_COMPACT.encode(), 120, encoding=encoding)
|
||||
session = aq.AnimatedQrSession()
|
||||
feed_frame_set(session, frames)
|
||||
ok(session.done, "BBQR {} done".format(encoding))
|
||||
transfer, compressed = session.resolve()
|
||||
parts, payload, kind, data = app_chain(transfer, compressed)
|
||||
ok(kind == "will" and data == WILL_ITEMS, "BBQR {} restores the will".format(encoding))
|
||||
|
||||
|
||||
def test_mid_transfer_conflict():
|
||||
ur1 = aq.ur1_frames(b"transfer-one", 400)
|
||||
ur2 = aq.ur2_frames(b"transfer-two", 400)
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(ur1[0])
|
||||
try:
|
||||
session.add_part(ur2[0])
|
||||
except aq.TransferConflictError:
|
||||
ok(True, "format switch raises TransferConflictError")
|
||||
else:
|
||||
ok(False, "format switch raised TransferConflictError")
|
||||
|
||||
|
||||
def test_garbage_and_single_line():
|
||||
session = aq.AnimatedQrSession()
|
||||
try:
|
||||
session.add_part("this is not a QR transfer")
|
||||
except aq.FormatNotDetectedError:
|
||||
ok(True, "garbage raises FormatNotDetectedError")
|
||||
else:
|
||||
ok(False, "garbage raised FormatNotDetectedError")
|
||||
|
||||
|
||||
def main():
|
||||
check_imported_bundle()
|
||||
run_payload_parity_cases()
|
||||
test_tx_transports()
|
||||
test_compressed_bal_transport()
|
||||
test_will_transports()
|
||||
test_duplicates_are_ignored()
|
||||
test_ur2_fountain_survives_loss_and_reorder()
|
||||
test_ur2_single_part_and_duplicate()
|
||||
test_bbqr_all_three_encodings()
|
||||
test_mid_transfer_conflict()
|
||||
test_garbage_and_single_line()
|
||||
print("verify_chain: {} checks passed".format(PASSED))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Standalone QR emitter for testing the Android reader on a real camera.
|
||||
|
||||
Replicates exactly what the plugin's QR export page puts on screen
|
||||
(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
|
||||
encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
|
||||
|
||||
Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
|
||||
PyQt6):
|
||||
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
|
||||
|
||||
Controls:
|
||||
Left/Right previous / next frame
|
||||
Space toggle autoplay
|
||||
L toggle loop (default off)
|
||||
Q / Esc quit
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import qrcode # noqa: E402
|
||||
from PyQt6.QtCore import Qt, QTimer # noqa: E402
|
||||
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
|
||||
from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
|
||||
|
||||
from bal.core import animated_qr as aq # noqa: E402
|
||||
from bal.core import qrtransfer as qtf # noqa: E402
|
||||
|
||||
|
||||
def build_frames(tx_strings, fmt, budget):
|
||||
"""Frames exactly as BalQrExportWidget._refresh_frames produces them."""
|
||||
transfer = qtf.encode_transfer(tx_strings, compress=False)
|
||||
if fmt == "balqr":
|
||||
return qtf.split_frames(transfer, budget, compressed=False)
|
||||
payload = transfer.encode("utf-8")
|
||||
if fmt == "ur1":
|
||||
return aq.ur1_frames(payload, budget)
|
||||
if fmt == "ur2":
|
||||
return aq.ur2_frames(payload, budget)
|
||||
if fmt == "bbqr":
|
||||
return aq.bbqr_frames(payload, budget, encoding="Z")
|
||||
raise SystemExit("unknown format: {}".format(fmt))
|
||||
|
||||
|
||||
def load_payload(args):
|
||||
"""Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
|
||||
if args.will is not None:
|
||||
will = json.loads(Path(args.will).read_text())
|
||||
return (
|
||||
[json.dumps(will, ensure_ascii=False)],
|
||||
"whole-will JSON ({})".format(Path(args.will).name),
|
||||
)
|
||||
if args.txs is not None:
|
||||
raw = Path(args.txs).read_text()
|
||||
return [line.strip() for line in raw.split() if line.strip()], "txs"
|
||||
raise SystemExit("give --will FILE or --txs FILE")
|
||||
|
||||
|
||||
def qr_pixmap(text, size):
|
||||
"""Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
|
||||
qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
||||
qr.add_data(text)
|
||||
qr.make(fit=True)
|
||||
matrix = qr.modules
|
||||
n = len(matrix)
|
||||
border = qr.border
|
||||
scale = max(1, size // (n + 2 * border))
|
||||
dim = (n + 2 * border) * scale
|
||||
image = QImage(dim, dim, QImage.Format.Format_RGB32)
|
||||
image.fill(Qt.GlobalColor.white)
|
||||
painter = QPainter(image)
|
||||
painter.fillRect(0, 0, dim, dim, QColor("white"))
|
||||
painter.setBrush(QColor("black"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
for y, row in enumerate(matrix):
|
||||
for x, on in enumerate(row):
|
||||
if on:
|
||||
painter.fillRect(
|
||||
(x + border) * scale, (y + border) * scale, scale, scale,
|
||||
QColor("black"),
|
||||
)
|
||||
painter.end()
|
||||
return QPixmap.fromImage(image).scaled(
|
||||
size, size, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
|
||||
|
||||
class EmitterWindow(QMainWindow):
|
||||
def __init__(self, frames, description, fmt, fps, loop):
|
||||
super().__init__()
|
||||
self.frames = frames
|
||||
self.fps = fps
|
||||
self.loop = loop
|
||||
self.index = 0
|
||||
self.autoplay = True
|
||||
|
||||
central = QWidget(self)
|
||||
self.setCentralWidget(central)
|
||||
self.pix = QLabel(central)
|
||||
self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.caption = QLabel(central)
|
||||
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
|
||||
|
||||
v = qt.QVBoxLayout(central)
|
||||
v.addWidget(self.pix, 1)
|
||||
v.addWidget(self.caption)
|
||||
|
||||
self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
|
||||
self.resize(900, 1000)
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._step)
|
||||
self.timer.start(int(1000 / self.fps))
|
||||
|
||||
self._render()
|
||||
self.caption.setText(
|
||||
"{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
|
||||
desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
|
||||
auto="on" if self.autoplay else "off", loop="on" if loop else "off",
|
||||
)
|
||||
)
|
||||
self.show()
|
||||
|
||||
def _render(self):
|
||||
self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
|
||||
|
||||
def _update_caption(self):
|
||||
auto = "on" if self.autoplay else "off"
|
||||
loop = "on" if self.loop else "off"
|
||||
self.caption.setText(
|
||||
"frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
|
||||
i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
|
||||
)
|
||||
|
||||
def _step(self):
|
||||
if self.index + 1 < len(self.frames):
|
||||
self.index += 1
|
||||
elif self.loop:
|
||||
self.index = 0
|
||||
else:
|
||||
self.autoplay = False
|
||||
self.timer.stop()
|
||||
self._render()
|
||||
self._update_caption()
|
||||
|
||||
def keyPressEvent(self, event): # noqa: N802 - Qt override name
|
||||
key = event.key()
|
||||
if key == Qt.Key.Key_Right:
|
||||
self.autoplay = False
|
||||
self.index = min(self.index + 1, len(self.frames) - 1)
|
||||
self._render()
|
||||
elif key == Qt.Key.Key_Left:
|
||||
self.autoplay = False
|
||||
self.index = max(self.index - 1, 0)
|
||||
self._render()
|
||||
elif key == Qt.Key.Key_Space:
|
||||
self.autoplay = not self.autoplay
|
||||
if self.autoplay:
|
||||
self.timer.start(int(1000 / self.fps))
|
||||
else:
|
||||
self.timer.stop()
|
||||
elif key == Qt.Key.Key_L:
|
||||
self.loop = not self.loop
|
||||
elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
|
||||
self.close()
|
||||
self._update_caption()
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
|
||||
default="balqr")
|
||||
parser.add_argument("--budget", type=int, default=400)
|
||||
parser.add_argument("--fps", type=float, default=1.0)
|
||||
parser.add_argument("--loop", action="store_true")
|
||||
parser.add_argument("--will", help="whole-will JSON file")
|
||||
parser.add_argument("--txs", help="file with serialized tx strings")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
tx_strings, description = load_payload(args)
|
||||
frames = build_frames(tx_strings, args.format, args.budget)
|
||||
if len(frames) == 1:
|
||||
print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
|
||||
else:
|
||||
print("{} frames ready".format(len(frames)), file=sys.stderr)
|
||||
|
||||
app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
|
||||
EmitterWindow(frames, description, args.format, args.fps, args.loop)
|
||||
app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"imp-heiress-1": {
|
||||
"tx": "0f1e2d3c4b5a69788796a5b4c3d2e1f0112233445566778899aabbccddeeff00",
|
||||
"addr": "bc1qdeadbeef0",
|
||||
"amount": 100000,
|
||||
"tag": "heiress-1",
|
||||
"metadata": {},
|
||||
"notify": "heir1@example.invalid"
|
||||
},
|
||||
"imp-heiress-2": {
|
||||
"tx": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
|
||||
"addr": "bc1qdeadbeef1",
|
||||
"amount": 200000,
|
||||
"tag": "heiress-2",
|
||||
"metadata": {},
|
||||
"notify": "heir2@example.invalid"
|
||||
},
|
||||
"imp-heiress-3": {
|
||||
"tx": "a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809",
|
||||
"addr": "bc1qdeadbeef2",
|
||||
"amount": 300000,
|
||||
"tag": "heiress-3",
|
||||
"metadata": {},
|
||||
"notify": "heir3@example.invalid"
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,9 @@ class BalController:
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
# Stored history: always English (translated only for display by
|
||||
# format_status_history); never _() here.
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
|
||||
@@ -2,33 +2,18 @@
|
||||
bal.core.animated_qr
|
||||
====================
|
||||
|
||||
GUI-free implementation of the interoperable animated-QR transfer formats
|
||||
used to move BAL will data between devices.
|
||||
GUI-free implementation of the BBQR animated-QR transfer format used to
|
||||
move BAL will data between devices.
|
||||
|
||||
Supported wire formats (each self-describing and order-independent on
|
||||
receive):
|
||||
Supported wire format (self-describing and order-independent on receive):
|
||||
|
||||
* **BALQR** (native): ``BAL1<TTT><iii><flag><payload>`` compact fixed-width
|
||||
header (11 chars, 3-digit base36 count fields, no separators); legacy
|
||||
``BALQR1|total|index|flags|payload`` still imported.
|
||||
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
|
||||
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
||||
Fragments partition the BC32 rendering of the CBOR byte string; the
|
||||
SHA-256 digest of the wrapped payload ties the parts together.
|
||||
* **BC-UR v2** (BCR-2020-005 rev 2 / BCR-2020-012)::
|
||||
ur:bytes/2-9/<bytewords-minimal fragment>
|
||||
Fountain-coded parts; each part is a CBOR array
|
||||
``[seq_num, seq_len, message_len, checksum, data]`` whose CBOR bytes are
|
||||
bytewords-minimal encoded with a trailing per-part CRC-32. The
|
||||
``checksum`` field holds the CRC-32 of the whole wrapped message, so the
|
||||
parts are mixable and order-independent.
|
||||
* **BBQR** (Coinkite)::
|
||||
B$<encoding><type><2 base36 total><2 base36 index><payload>
|
||||
Equal-length text frames; the payload is uppercase hex, RFC-4648
|
||||
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
|
||||
|
||||
Everything is implemented from scratch on top of the Python standard library
|
||||
only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a
|
||||
only (``zlib``, ``base64``), so the shipped plugin zip stays a
|
||||
self-contained bundle with no third-party dependencies (house rule).
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -37,9 +22,8 @@ This module never imports Qt or any Electrum GUI code (house rule).
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import zlib
|
||||
from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Errors & safety caps
|
||||
@@ -62,604 +46,10 @@ class SessionLimitError(AnimatedQrError):
|
||||
"""A receive session exceeded its safety caps."""
|
||||
|
||||
|
||||
class ChecksumError(AnimatedQrError):
|
||||
"""A part failed its checksum / digest validation."""
|
||||
|
||||
|
||||
# Safety caps for untrusted scanner input.
|
||||
_MAX_SESSION_PARTS = 20000
|
||||
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CBOR minimals (byte-string envelope + the fountain part header)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BYTE_STR_RES = 0x40 # byte string, length < 24
|
||||
_BYTE_STR_1 = 0x58 # byte string, 1-byte length
|
||||
_BYTE_STR_2 = 0x59 # byte string, 2-byte length
|
||||
_BYTE_STR_4 = 0x60 # byte string, 4-byte length
|
||||
_ARRAY_RES = 0x80
|
||||
_UNSIGNED_RES = 0x00
|
||||
|
||||
|
||||
def cbor_byte_string(data: bytes) -> bytes:
|
||||
"""Wrap ``data`` in the minimal CBOR byte-string envelope (0x40..0x60)."""
|
||||
n = len(data)
|
||||
if n < 24:
|
||||
head = bytes([_BYTE_STR_RES + n])
|
||||
elif n <= 0xFF:
|
||||
head = bytes([_BYTE_STR_1, n])
|
||||
elif n <= 0xFFFF:
|
||||
head = bytes([_BYTE_STR_2]) + n.to_bytes(2, "big")
|
||||
elif n <= 0xFFFFFFFF:
|
||||
head = bytes([_BYTE_STR_4]) + n.to_bytes(4, "big")
|
||||
else:
|
||||
raise AnimatedQrError("payload too large for the UR byte-string envelope")
|
||||
return head + data
|
||||
|
||||
|
||||
def unwrap_ur_cbor(message: bytes) -> bytes:
|
||||
"""Strip the CBOR byte-string envelope, falling back to the raw bytes.
|
||||
|
||||
Receivers keep working even when the emitter embedded the payload without
|
||||
any CBOR wrapping (some third-party ``ur:bytes`` emitters do).
|
||||
"""
|
||||
if not message:
|
||||
raise AnimatedQrError("empty decoded message")
|
||||
b0 = message[0]
|
||||
if _BYTE_STR_RES <= b0 <= 0x57:
|
||||
header_len, n = 1, b0 - _BYTE_STR_RES
|
||||
elif b0 == _BYTE_STR_1 and len(message) >= 2:
|
||||
header_len, n = 2, message[1]
|
||||
elif b0 == _BYTE_STR_2 and len(message) >= 3:
|
||||
header_len, n = 3, int.from_bytes(message[1:3], "big")
|
||||
elif b0 == _BYTE_STR_4 and len(message) >= 5:
|
||||
header_len, n = 5, int.from_bytes(message[1:5], "big")
|
||||
else:
|
||||
return message
|
||||
if header_len + n != len(message):
|
||||
raise AnimatedQrError("decoded message has an inconsistent CBOR length")
|
||||
return message[header_len:]
|
||||
|
||||
|
||||
def _cbor_unsigned(value: int) -> bytes:
|
||||
if value < 24:
|
||||
return bytes([_UNSIGNED_RES + value])
|
||||
if value <= 0xFF:
|
||||
return bytes([0x18, value])
|
||||
if value <= 0xFFFF:
|
||||
return bytes([0x19]) + value.to_bytes(2, "big")
|
||||
if value <= 0xFFFFFFFF:
|
||||
return bytes([0x1A]) + value.to_bytes(4, "big")
|
||||
return bytes([0x1B]) + value.to_bytes(8, "big")
|
||||
|
||||
|
||||
def cbor_part(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> bytes:
|
||||
"""The CBOR body of a BC-UR v2 fountain part (``[seq, seq_len, message_len, checksum, data]``)."""
|
||||
out = bytearray([_ARRAY_RES + 5])
|
||||
out += _cbor_unsigned(seq_num)
|
||||
out += _cbor_unsigned(seq_len)
|
||||
out += _cbor_unsigned(message_len)
|
||||
out += _cbor_unsigned(checksum)
|
||||
out += cbor_byte_string(data)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _need(buf: bytes, pos: int, count: int) -> None:
|
||||
if pos + count > len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
|
||||
|
||||
def _cbor_read_unsigned(buf: bytes, pos: int) -> Tuple[int, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
if octet & 0xE0 != _UNSIGNED_RES:
|
||||
raise AnimatedQrError("unexpected CBOR type in part header")
|
||||
pos += 1
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
return additional, pos
|
||||
if additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
return buf[pos], pos + 1
|
||||
if additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
if additional == 26:
|
||||
_need(buf, pos, 4)
|
||||
return int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
|
||||
if additional == 27:
|
||||
_need(buf, pos, 8)
|
||||
return int.from_bytes(buf[pos : pos + 8], "big"), pos + 8
|
||||
raise AnimatedQrError("unsupported CBOR integer width in part header")
|
||||
|
||||
|
||||
def _cbor_read_bytes(buf: bytes, pos: int) -> Tuple[bytes, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
pos += 1
|
||||
if octet & 0xE0 != _BYTE_STR_RES:
|
||||
raise AnimatedQrError("expected a CBOR byte string in part header")
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
n = additional
|
||||
elif additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
n, pos = buf[pos], pos + 1
|
||||
elif additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
n, pos = int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
elif additional == 26:
|
||||
_need(buf, pos, 4)
|
||||
n, pos = int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
|
||||
else:
|
||||
raise AnimatedQrError("unsupported CBOR byte-string width in part header")
|
||||
_need(buf, pos, n)
|
||||
return buf[pos : pos + n], pos + n
|
||||
|
||||
|
||||
def _cbor_read_array(buf: bytes, pos: int) -> Tuple[int, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
pos += 1
|
||||
if octet & 0xE0 != _ARRAY_RES:
|
||||
raise AnimatedQrError("expected a CBOR array in part header")
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
return additional, pos
|
||||
if additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
return buf[pos], pos + 1
|
||||
if additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
raise AnimatedQrError("unsupported CBOR array header in part")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRC-32 (same polynomial as ``zlib.crc32``, network byte order)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def crc32_int(data: bytes) -> int:
|
||||
"""CRC-32 over ``data`` as an unsigned 32-bit integer."""
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def crc32_bytes(data: bytes) -> bytes:
|
||||
"""CRC-32 over ``data`` as 4 network-order (big-endian) bytes."""
|
||||
return crc32_int(data).to_bytes(4, "big")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bytewords (BCR-2020-012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BYTEWORDS = (
|
||||
"ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbr"
|
||||
"ewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadays"
|
||||
"delidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfa"
|
||||
"ctfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgift"
|
||||
"girlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhu"
|
||||
"tsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjury"
|
||||
"keepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlo"
|
||||
"goloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneed"
|
||||
"newsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolpo"
|
||||
"sepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesaga"
|
||||
"scarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoilto"
|
||||
"mbtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswall"
|
||||
"wandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzi"
|
||||
"nczonezoom"
|
||||
)
|
||||
|
||||
_WORDS = [_BYTEWORDS[i : i + 4] for i in range(0, 1024, 4)]
|
||||
_DIM = 26
|
||||
_WORD_LOOKUP: Optional[List[int]] = None
|
||||
|
||||
|
||||
def _word_lookup() -> List[int]:
|
||||
"""First/last-letter lookup table (built lazily, mirrors Bytewords)."""
|
||||
global _WORD_LOOKUP
|
||||
if _WORD_LOOKUP is None:
|
||||
table = [-1] * (_DIM * _DIM)
|
||||
for i, word in enumerate(_WORDS):
|
||||
x = ord(word[0]) - ord("a")
|
||||
y = ord(word[3]) - ord("a")
|
||||
table[y * _DIM + x] = i
|
||||
_WORD_LOOKUP = table
|
||||
return _WORD_LOOKUP
|
||||
|
||||
|
||||
def _decode_word(word: str, word_len: int) -> int:
|
||||
if len(word) != word_len:
|
||||
raise AnimatedQrError("invalid bytewords word length")
|
||||
x = ord(word[0]) - ord("a")
|
||||
y = ord(word[3] if word_len == 4 else word[1]) - ord("a")
|
||||
if not (0 <= x < _DIM and 0 <= y < _DIM):
|
||||
raise AnimatedQrError("invalid bytewords characters")
|
||||
value = _word_lookup()[y * _DIM + x]
|
||||
if value == -1:
|
||||
raise AnimatedQrError("invalid bytewords first/last pair")
|
||||
if word_len == 4:
|
||||
full = _WORDS[value]
|
||||
if word[1] != full[1] or word[2] != full[2]:
|
||||
raise AnimatedQrError("invalid bytewords middle letters")
|
||||
return value
|
||||
|
||||
|
||||
def bytewords_minimal_encode(data: bytes) -> str:
|
||||
"""BCR-2020-012 bytewords-minimal: one two-letter word per byte, then CRC."""
|
||||
crc = data + crc32_bytes(data)
|
||||
return "".join(_WORDS[b][0] + _WORDS[b][3] for b in crc)
|
||||
|
||||
|
||||
def bytewords_minimal_decode(text: str) -> bytes:
|
||||
"""Inverse of :func:`bytewords_minimal_encode` (validates the CRC-32)."""
|
||||
if len(text) % 2:
|
||||
raise AnimatedQrError("invalid bytewords length (odd)")
|
||||
values = [_decode_word(text[i : i + 2], 2) for i in range(0, len(text), 2)]
|
||||
payload = bytes(values)
|
||||
if len(payload) < 5:
|
||||
raise AnimatedQrError("bytewords payload too short")
|
||||
body, checksum = payload[:-4], payload[-4:]
|
||||
if crc32_bytes(body) != checksum:
|
||||
raise AnimatedQrError("bytewords CRC-32 mismatch")
|
||||
return body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC32 (the deprecated bech32-derived codec used by BC-UR v1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BC32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BC32_REV = {ch: i for i, ch in enumerate(_BC32_ALPHABET)}
|
||||
_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
|
||||
|
||||
def bech32_polymod(values: Sequence[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bc32_checksum(values: List[int]) -> List[int]:
|
||||
polymod = bech32_polymod([0] + values + [0] * 6) ^ 0x3FFFFFFF
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bc32_verify(values: List[int]) -> bool:
|
||||
return bech32_polymod([0] + values) == 0x3FFFFFFF
|
||||
|
||||
|
||||
def bc32_encode(data: bytes) -> str:
|
||||
"""BCR-2020-005 BC32: bech32 without the human-readable part and divider."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
values: List[int] = []
|
||||
for byte in data:
|
||||
acc = (acc << 8) | byte
|
||||
bits += 8
|
||||
while bits >= 5:
|
||||
bits -= 5
|
||||
values.append((acc >> bits) & 31)
|
||||
if bits:
|
||||
values.append((acc << (5 - bits)) & 31)
|
||||
values += _bc32_checksum(values)
|
||||
return "".join(_BC32_ALPHABET[v] for v in values)
|
||||
|
||||
|
||||
def bc32_decode(text: str) -> bytes:
|
||||
"""Inverse of :func:`bc32_encode` (validates the 6-char checksum)."""
|
||||
lowered = text.lower()
|
||||
try:
|
||||
values = [_BC32_REV[ch] for ch in lowered]
|
||||
except KeyError:
|
||||
raise AnimatedQrError("invalid BC-UR v1 character") from None
|
||||
if len(values) < 6 or not _bc32_verify(values):
|
||||
raise AnimatedQrError("invalid BC-UR v1 checksum")
|
||||
data = values[:-6]
|
||||
acc = 0
|
||||
bits = 0
|
||||
out = bytearray()
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
if bits >= 8:
|
||||
bits -= 8
|
||||
out.append((acc >> bits) & 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# xoshiro256** + alias sampler (exact ports of the reference RNG chain)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_MASK64 = (1 << 64) - 1
|
||||
|
||||
|
||||
class _Xoshiro256:
|
||||
"""xoshiro256** 1.0, seeded via SHA-256 of a byte sequence."""
|
||||
|
||||
def __init__(self, seed: bytes):
|
||||
digest = hashlib.sha256(seed).digest()
|
||||
self._s = [
|
||||
int.from_bytes(digest[offset : offset + 8], "big")
|
||||
for offset in range(0, 32, 8)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _rotl(x: int, k: int) -> int:
|
||||
return ((x << k) | (x >> (64 - k))) & _MASK64
|
||||
|
||||
def next(self) -> int:
|
||||
result = (self._rotl((self._s[1] * 5) & _MASK64, 7) * 9) & _MASK64
|
||||
t = (self._s[1] << 17) & _MASK64
|
||||
s = self._s
|
||||
s[2] ^= s[0]
|
||||
s[3] ^= s[1]
|
||||
s[1] ^= s[2]
|
||||
s[0] ^= s[3]
|
||||
s[2] ^= t
|
||||
s[3] = self._rotl(s[3], 45)
|
||||
return result
|
||||
|
||||
def next_double(self) -> float:
|
||||
return self.next() / float(1 << 64)
|
||||
|
||||
def next_int(self, low: int, high: int) -> int:
|
||||
return int(self.next_double() * (high - low + 1)) + low
|
||||
|
||||
|
||||
class _RandomAliasSampler:
|
||||
"""Vose's alias method, built in the exact order of the reference code."""
|
||||
|
||||
def __init__(self, probs: Sequence[float]):
|
||||
total = sum(probs)
|
||||
assert total > 0
|
||||
n = len(probs)
|
||||
normalized = [p * float(n) / total for p in probs]
|
||||
|
||||
small: List[int] = []
|
||||
large: List[int] = []
|
||||
for i in range(n - 1, -1, -1):
|
||||
(small if normalized[i] < 1 else large).append(i)
|
||||
|
||||
self._probs = [0] * n
|
||||
self._aliases = [0] * n
|
||||
while small and large:
|
||||
a = small.pop()
|
||||
g = large.pop()
|
||||
self._probs[a] = normalized[a]
|
||||
self._aliases[a] = g
|
||||
normalized[g] += normalized[a] - 1
|
||||
(small if normalized[g] < 1 else large).append(g)
|
||||
|
||||
while large:
|
||||
self._probs[large.pop()] = 1
|
||||
while small:
|
||||
self._probs[small.pop()] = 1
|
||||
|
||||
def next(self, rng: _Xoshiro256) -> int:
|
||||
r1 = rng.next_double()
|
||||
r2 = rng.next_double()
|
||||
n = len(self._probs)
|
||||
i = int(float(n) * r1)
|
||||
return i if r2 < self._probs[i] else self._aliases[i]
|
||||
|
||||
|
||||
def choose_fragments(seq_num: int, seq_len: int, checksum: int) -> Set[int]:
|
||||
"""The fragments mixed into a BC-UR v2 fountain part (reference seed math).
|
||||
|
||||
Sequence numbers ``1..seq_len`` emit the pure fragment ``{seq_num - 1}``;
|
||||
every larger sequence number deterministically mixes a pseudo-random
|
||||
subset of fragments seeded by ``SHA256(seq ‖ checksum)``.
|
||||
"""
|
||||
if seq_num <= seq_len:
|
||||
return {seq_num - 1}
|
||||
seed = seq_num.to_bytes(4, "big") + checksum.to_bytes(4, "big")
|
||||
rng = _Xoshiro256(seed)
|
||||
probs: List[float] = [1.0 / i for i in range(1, seq_len + 1)]
|
||||
degree = _RandomAliasSampler(probs).next(rng) + 1
|
||||
remaining = list(range(seq_len))
|
||||
shuffled: List[int] = []
|
||||
while remaining:
|
||||
index = rng.next_int(0, len(remaining) - 1)
|
||||
shuffled.append(remaining.pop(index))
|
||||
return set(shuffled[:degree])
|
||||
|
||||
|
||||
def _partition_message(message: bytes, fragment_len: int) -> List[bytes]:
|
||||
fragments: List[bytes] = []
|
||||
for offset in range(0, len(message), fragment_len):
|
||||
fragment = message[offset : offset + fragment_len]
|
||||
if len(fragment) < fragment_len:
|
||||
fragment += b"\x00" * (fragment_len - len(fragment))
|
||||
fragments.append(fragment)
|
||||
return fragments
|
||||
|
||||
|
||||
def _mix_fragments(fragments: Sequence[bytes], indexes: Set[int], fragment_len: int) -> bytes:
|
||||
result = bytearray(fragment_len)
|
||||
for index in indexes:
|
||||
for i, byte in enumerate(fragments[index]):
|
||||
result[i] ^= byte
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2 (bytewords-minimal + fountain)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ur2_header(seq_num: int, seq_len: int) -> str:
|
||||
return "ur:bytes/{}-{}/".format(seq_num, seq_len)
|
||||
|
||||
|
||||
def _ur2_part_string(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> str:
|
||||
body = cbor_part(seq_num, seq_len, message_len, checksum, data)
|
||||
return _ur2_header(seq_num, seq_len) + bytewords_minimal_encode(body)
|
||||
|
||||
|
||||
def _ur2_part_cost(seq_num: int, seq_len: int, message_len: int, checksum: int, data_len: int) -> int:
|
||||
body_len = len(cbor_part(seq_num, seq_len, message_len, checksum, b"\x00" * data_len))
|
||||
# bytewords_minimal_encode appends a 4-byte CRC over the body.
|
||||
return len(_ur2_header(seq_num, seq_len)) + 2 * (body_len + 4)
|
||||
|
||||
|
||||
def ur2_frames(payload: bytes, budget_chars: int) -> List[str]:
|
||||
"""Encode ``payload`` into BC-UR v2 fountain frames.
|
||||
|
||||
``budget_chars`` is the largest frame string the carrying QR code may
|
||||
hold. The first ``seq_len`` frames are pure (one fragment each); a second
|
||||
wave of ``seq_len`` mixed (fountain) frames follows so the receiver can
|
||||
recover with a few parts still missing.
|
||||
"""
|
||||
message = cbor_byte_string(payload)
|
||||
message_len = len(message)
|
||||
checksum = crc32_int(message)
|
||||
single_cost = len("ur:bytes/") + len(bytewords_minimal_encode(message))
|
||||
if single_cost <= budget_chars:
|
||||
return ["ur:bytes/" + bytewords_minimal_encode(message)]
|
||||
|
||||
fragment_len = message_len
|
||||
fragment_count = 1
|
||||
while True:
|
||||
seq_len = fragment_count
|
||||
worst_seq = 2 * seq_len # the export loop emits up to 2*seq_len parts
|
||||
cost = _ur2_part_cost(worst_seq, seq_len, message_len, checksum, fragment_len)
|
||||
if cost <= budget_chars:
|
||||
break
|
||||
fragment_count += 1
|
||||
fragment_len = -(-message_len // fragment_count)
|
||||
if fragment_count > message_len:
|
||||
raise AnimatedQrError("QR budget too small for a BC-UR v2 part")
|
||||
|
||||
fragments = _partition_message(message, fragment_len)
|
||||
frames: List[str] = []
|
||||
for seq_num in range(1, seq_len + 1):
|
||||
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, fragments[seq_num - 1]))
|
||||
for seq_num in range(seq_len + 1, 2 * seq_len + 1):
|
||||
data = _mix_fragments(fragments, choose_fragments(seq_num, seq_len, checksum), fragment_len)
|
||||
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, data))
|
||||
return frames
|
||||
|
||||
|
||||
def ur2_parse_part(frame_text: str) -> Tuple[int, int, int, int, bytes]:
|
||||
"""Parse a BC-UR v2 part into ``(seq, seq_len, message_len, checksum, data)``."""
|
||||
frame_text = frame_text.strip().lower()
|
||||
prefix = "ur:bytes/"
|
||||
if not frame_text.startswith(prefix):
|
||||
raise AnimatedQrError("not a BC-UR v2 part")
|
||||
tail = frame_text[len(prefix) :]
|
||||
if "/" not in tail:
|
||||
body = bytewords_minimal_decode(tail)
|
||||
return 1, 1, len(body), crc32_int(body), body
|
||||
seq_head, words = tail.split("/", 1)
|
||||
try:
|
||||
seq_num_s, seq_len_s = seq_head.split("-", 1)
|
||||
seq_num, seq_len = int(seq_num_s), int(seq_len_s)
|
||||
except ValueError:
|
||||
raise AnimatedQrError("bad BC-UR v2 sequence header") from None
|
||||
if seq_len < 1 or not 1 <= seq_num <= 2**32 - 1:
|
||||
raise AnimatedQrError("bad BC-UR v2 sequence numbers")
|
||||
body = bytewords_minimal_decode(words)
|
||||
arr, pos = _cbor_read_array(body, 0)
|
||||
if arr != 5:
|
||||
raise AnimatedQrError("bad BC-UR v2 part header arity")
|
||||
seq_again, pos = _cbor_read_unsigned(body, pos)
|
||||
seq_len_again, pos = _cbor_read_unsigned(body, pos)
|
||||
message_len, pos = _cbor_read_unsigned(body, pos)
|
||||
checksum, pos = _cbor_read_unsigned(body, pos)
|
||||
data, pos = _cbor_read_bytes(body, pos)
|
||||
if pos != len(body):
|
||||
raise AnimatedQrError("trailing garbage in BC-UR v2 part header")
|
||||
if seq_again != seq_num or seq_len_again != seq_len:
|
||||
raise AnimatedQrError("BC-UR v2 part header mismatch")
|
||||
return seq_num, seq_len, message_len, checksum, bytes(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v1 (BCR-2020-005 rev1: BC32 fragments + SHA-256 digest)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ur1_digest(message: bytes) -> str:
|
||||
return bc32_encode(hashlib.sha256(message).digest())
|
||||
|
||||
|
||||
def _ur1_prefix(index: int, total: int, digest: str) -> str:
|
||||
return "ur:bytes/{}{}/{}/".format(
|
||||
index, "of{}".format(total), digest
|
||||
)
|
||||
|
||||
|
||||
def ur1_frames(payload: bytes, budget_chars: int) -> List[str]:
|
||||
"""Encode ``payload`` into BC-UR v1 fragments (``NofM`` + BC32 + digest)."""
|
||||
message = cbor_byte_string(payload)
|
||||
digest = _ur1_digest(message)
|
||||
full = bc32_encode(message)
|
||||
|
||||
total = 1
|
||||
while True:
|
||||
longest = _ur1_prefix(total, total, digest)
|
||||
capacity = budget_chars - len(longest)
|
||||
if capacity < 1:
|
||||
raise AnimatedQrError("QR budget too small for BC-UR v1")
|
||||
if len(full) <= capacity * total:
|
||||
break
|
||||
total += 1
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise AnimatedQrError("BC-UR v1 transfer demands too many parts")
|
||||
|
||||
frames: List[str] = []
|
||||
pos = 0
|
||||
for index in range(1, total + 1):
|
||||
prefix = _ur1_prefix(index, total, digest)
|
||||
capacity = budget_chars - len(prefix)
|
||||
frames.append(prefix + full[pos : pos + capacity])
|
||||
pos += capacity
|
||||
return frames
|
||||
|
||||
|
||||
def ur1_parse_part(frame_text: str) -> Tuple[int, int, str, str]:
|
||||
"""Parse a BC-UR v1 part into ``(index, total, digest, fragment)``.
|
||||
|
||||
Accepts both the multipart form (``ur:bytes/NofM/<digest>/<frag>``) and
|
||||
the single-part form (``ur:bytes/<frag>``, no sequence header or digest).
|
||||
"""
|
||||
frame_text = frame_text.strip().lower()
|
||||
prefix = "ur:bytes/"
|
||||
if not frame_text.startswith(prefix):
|
||||
raise AnimatedQrError("not a BC-UR v1 part")
|
||||
tail = frame_text[len(prefix) :]
|
||||
parts = tail.split("/")
|
||||
if len(parts) == 1:
|
||||
return 1, 1, "", parts[0]
|
||||
if len(parts) != 3:
|
||||
raise AnimatedQrError("bad BC-UR v1 part structure")
|
||||
seq_head, digest, fragment = parts
|
||||
if "of" not in seq_head:
|
||||
raise AnimatedQrError("BC-UR v1 part misses the sequence header")
|
||||
try:
|
||||
index_s, total_s = seq_head.split("of", 1)
|
||||
index, total = int(index_s), int(total_s)
|
||||
except ValueError:
|
||||
raise AnimatedQrError("bad BC-UR v1 sequence header") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise AnimatedQrError("bad BC-UR v1 sequence numbers")
|
||||
if len(digest) != 58:
|
||||
raise AnimatedQrError("bad BC-UR v1 digest")
|
||||
return index, total, digest, fragment
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BBQR (Coinkite)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -774,9 +164,6 @@ def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
FORMAT_LABELS = {
|
||||
"balqr": "BAL QR",
|
||||
"ur1": "BC-UR v1",
|
||||
"ur2": "BC-UR v2",
|
||||
"bbqr": "BBQR",
|
||||
}
|
||||
|
||||
@@ -791,34 +178,8 @@ def detect_format(text: str) -> Optional[str]:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
lowered = text.lower()
|
||||
if lowered.startswith(("balqr", "bal1")):
|
||||
return "balqr"
|
||||
if text.startswith(_BBQR_PREFIX):
|
||||
return "bbqr"
|
||||
if not lowered.startswith("ur:"):
|
||||
return None
|
||||
if lowered.startswith("ur:bytes/"):
|
||||
remainder = lowered[len("ur:bytes/") :]
|
||||
first = remainder.split("/", 1)[0]
|
||||
if "of" in first:
|
||||
return "ur1"
|
||||
if "-" in first:
|
||||
return "ur2"
|
||||
# Single-part: the whole remainder is the body. Prefer a bytewords v2
|
||||
# body (CBOR byte-string head 0x40..0x60), then BC32 v1.
|
||||
try:
|
||||
body = bytewords_minimal_decode(remainder)
|
||||
except AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
if body and 0x40 <= body[0] <= 0x60:
|
||||
return "ur2"
|
||||
try:
|
||||
bc32_decode(remainder)
|
||||
except AnimatedQrError:
|
||||
return None
|
||||
return "ur1"
|
||||
return None
|
||||
|
||||
|
||||
@@ -830,253 +191,16 @@ def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
|
||||
:class:`AnimatedQrError` when the text cannot be parsed.
|
||||
"""
|
||||
fmt = detect_format(text)
|
||||
if fmt == "balqr":
|
||||
total, index, _compressed, _payload = _parse_balqr(text)
|
||||
return "balqr", "balqr:{}".format(total), total, index
|
||||
if fmt == "ur1":
|
||||
index, total, digest, _frag = ur1_parse_part(text)
|
||||
return "ur1", "ur1:{}".format(digest), total, index
|
||||
if fmt == "ur2":
|
||||
seq, seq_len, message_len, checksum, _data = ur2_parse_part(text)
|
||||
return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq
|
||||
if fmt == "bbqr":
|
||||
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
|
||||
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
|
||||
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||
|
||||
|
||||
def _parse_balqr(text: str) -> Tuple[int, int, bool, str]:
|
||||
from bal.core.qrtransfer import parse_frame
|
||||
|
||||
return parse_frame(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Receive sessions (order-independent assembly per format)
|
||||
# Receive session (order-independent assembly)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class _BalQrSession:
|
||||
def __init__(self):
|
||||
self._frames: Dict[int, str] = {}
|
||||
self._total = 0
|
||||
self._compressed = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._frames) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
total, index, compressed, payload = _parse_balqr(text)
|
||||
if self._total and total != self._total:
|
||||
raise TransferConflictError("BAL QR transfer total changed")
|
||||
if len(self._frames) >= _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("too many BAL QR frames")
|
||||
if not self._total:
|
||||
self._total = total
|
||||
self._compressed = compressed
|
||||
if index in self._frames:
|
||||
return "dup"
|
||||
self._frames[index] = payload
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
from bal.core.qrtransfer import assemble
|
||||
|
||||
text = assemble(self._frames, self._total)
|
||||
return text, self._compressed
|
||||
|
||||
|
||||
class _Ur1Session:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
self._digest = ""
|
||||
self._fragments: Dict[int, str] = {}
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._fragments)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._fragments) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
index, total, digest, fragment = ur1_parse_part(text)
|
||||
if self._total:
|
||||
if total != self._total or digest != self._digest:
|
||||
raise TransferConflictError("BC-UR v1 transfer digest changed")
|
||||
else:
|
||||
self._total = total
|
||||
self._digest = digest
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("BC-UR v1 demands too many parts")
|
||||
if index in self._fragments:
|
||||
return "dup"
|
||||
self._fragments[index] = fragment
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
full = "".join(self._fragments[i] for i in range(1, self._total + 1))
|
||||
try:
|
||||
message = bc32_decode(full)
|
||||
except AnimatedQrError:
|
||||
raise ChecksumError("BC-UR v1 checksum mismatch") from None
|
||||
if self._digest and _ur1_digest(message) != self._digest:
|
||||
raise ChecksumError("BC-UR v1 digest mismatch")
|
||||
return _transfer_text(unwrap_ur_cbor(message)), False
|
||||
|
||||
|
||||
class _Ur2Session:
|
||||
"""Fountain decoder mirroring the reference (C++/python) semantics."""
|
||||
|
||||
def __init__(self):
|
||||
self._seq_len = 0
|
||||
self._message_len = 0
|
||||
self._checksum = 0
|
||||
self._fragment_len = 0
|
||||
self._received: Set[int] = set()
|
||||
self._simple: Dict[FrozenSet[int], bytes] = {}
|
||||
self._mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
self._queue: List[Tuple[FrozenSet[int], bytes]] = []
|
||||
self._processed = 0
|
||||
self._result: Optional[bytes] = None
|
||||
self._bad = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._seq_len
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return self._processed
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self._result is not None
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
seq, seq_len, message_len, checksum, data = ur2_parse_part(text)
|
||||
if self._seq_len:
|
||||
if not self._validate(seq_len, message_len, checksum, len(data)):
|
||||
raise TransferConflictError("BC-UR v2 transfer header changed")
|
||||
else:
|
||||
self._seq_len = seq_len
|
||||
self._message_len = message_len
|
||||
self._checksum = checksum
|
||||
self._fragment_len = len(data)
|
||||
if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES:
|
||||
raise SessionLimitError("BC-UR v2 session exceeds safety caps")
|
||||
indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum))
|
||||
self._receive(indexes, bytes(data))
|
||||
return "ok"
|
||||
|
||||
def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool:
|
||||
return (
|
||||
seq_len == self._seq_len
|
||||
and message_len == self._message_len
|
||||
and checksum == self._checksum
|
||||
and data_len == self._fragment_len
|
||||
)
|
||||
|
||||
def _receive(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if self._result is not None or self._bad:
|
||||
return
|
||||
self._queue.append((indexes, data))
|
||||
while self._result is None and not self._bad and self._queue:
|
||||
self._process(self._queue.pop(0))
|
||||
self._processed += 1
|
||||
|
||||
def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None:
|
||||
indexes, data = item
|
||||
if len(indexes) == 1:
|
||||
self._process_simple(indexes, data)
|
||||
else:
|
||||
self._process_mixed(indexes, data)
|
||||
|
||||
def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
fragment_index = next(iter(indexes))
|
||||
if fragment_index in self._received:
|
||||
return
|
||||
self._simple[indexes] = data
|
||||
self._received.add(fragment_index)
|
||||
if self._received == set(range(self._seq_len)):
|
||||
self._finish()
|
||||
return
|
||||
self._reduce_mixed_by(indexes, data)
|
||||
|
||||
def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
new_mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
for other_indexes, other_data in self._mixed.items():
|
||||
reduced = self._reduce_part(other_indexes, other_data, indexes, data)
|
||||
if len(reduced[0]) == 1:
|
||||
self._queue.append(reduced)
|
||||
else:
|
||||
new_mixed[reduced[0]] = reduced[1]
|
||||
self._mixed = new_mixed
|
||||
|
||||
def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if indexes in self._mixed:
|
||||
return
|
||||
reduced_indexes, reduced_data = indexes, data
|
||||
for simple_indexes, simple_data in self._simple.items():
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, simple_indexes, simple_data
|
||||
)
|
||||
for other_indexes, other_data in list(self._mixed.items()):
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, other_indexes, other_data
|
||||
)
|
||||
if len(reduced_indexes) == 1:
|
||||
self._queue.append((reduced_indexes, reduced_data))
|
||||
else:
|
||||
self._reduce_mixed_by(reduced_indexes, reduced_data)
|
||||
if reduced_indexes not in self._mixed:
|
||||
self._mixed[reduced_indexes] = reduced_data
|
||||
|
||||
@staticmethod
|
||||
def _reduce_part(
|
||||
a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes
|
||||
) -> Tuple[FrozenSet[int], bytes]:
|
||||
if b_indexes == a_indexes or not b_indexes.issubset(a_indexes):
|
||||
return a_indexes, a_data
|
||||
new_indexes = a_indexes - b_indexes
|
||||
new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True))
|
||||
return new_indexes, new_data
|
||||
|
||||
def _finish(self) -> None:
|
||||
fragments = []
|
||||
for index in range(self._seq_len):
|
||||
key = frozenset([index])
|
||||
if key not in self._simple:
|
||||
self._bad = True
|
||||
return
|
||||
fragments.append(self._simple[key])
|
||||
message = b"".join(fragments)[: self._message_len]
|
||||
if crc32_int(message) != self._checksum:
|
||||
self._bad = True
|
||||
return
|
||||
self._result = message
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
if self._bad:
|
||||
raise ChecksumError("BC-UR v2 message checksum mismatch")
|
||||
if self._result is None:
|
||||
raise AnimatedQrError("BC-UR v2 session is not complete")
|
||||
return _transfer_text(unwrap_ur_cbor(self._result)), False
|
||||
|
||||
|
||||
class _BbqrSession:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
@@ -1126,8 +250,6 @@ def _transfer_text(raw: bytes) -> str:
|
||||
class AnimatedQrSession:
|
||||
"""Facade over the per-format receive sessions used by the QR import page."""
|
||||
|
||||
_DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession"))
|
||||
|
||||
def __init__(self):
|
||||
self._inner: Optional[object] = None
|
||||
self._fmt: Optional[str] = None
|
||||
@@ -1152,12 +274,6 @@ class AnimatedQrSession:
|
||||
|
||||
@staticmethod
|
||||
def _make(fmt: str) -> object:
|
||||
if fmt == "balqr":
|
||||
return _BalQrSession()
|
||||
if fmt == "ur1":
|
||||
return _Ur1Session()
|
||||
if fmt == "ur2":
|
||||
return _Ur2Session()
|
||||
if fmt == "bbqr":
|
||||
return _BbqrSession()
|
||||
raise AssertionError("unknown animated-QR format {}".format(fmt))
|
||||
|
||||
@@ -305,11 +305,10 @@ class BalPlugin(BasePlugin):
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
|
||||
# per QR frame when exporting/importing a will through the QR channel.
|
||||
# The settings dialog offers the 4 standard presets of
|
||||
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
|
||||
# Default 150 (small QR, low-resolution cameras).
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
|
||||
# used per QR frame when exporting/importing a will through the QR
|
||||
# channel. The export dialog seeds its own size selector from this
|
||||
# value (default 150, small QR, low-resolution cameras).
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
|
||||
@@ -9,31 +9,11 @@ Scope
|
||||
-----
|
||||
* converts will transactions into a compact ``transfer_string``
|
||||
(newline-joined serialized transactions, optionally zlib + base64
|
||||
compressed);
|
||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
compressed). The transfer string is the payload carried by BBQR QR frames
|
||||
(``bal.core.animated_qr``).
|
||||
|
||||
Wire format (v2, compact)
|
||||
-------------------------
|
||||
A frame is::
|
||||
|
||||
BAL1<TTT><iii><flag><payload>
|
||||
|
||||
* ``BAL1`` - magic + format era (4 chars).
|
||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||
* ``payload`` - every other character of the frame; the payloads of all
|
||||
frames, concatenated in index order, rebuild the transfer string.
|
||||
|
||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||
(same 5 pieces of information) without any pipe separator, so the whole
|
||||
frame is scan-friendly and the overhead no longer grows with the frame
|
||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||
|
||||
The audio-modem channel deliberately bypasses the framing helpers here
|
||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||
carries the whole transfer string in a single blob, so callers only use
|
||||
The audio-modem channel carries the whole transfer string in a single blob
|
||||
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -44,12 +24,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
FLAG_PLAIN = "0"
|
||||
|
||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||
# 4 standard presets (label, payload budget per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
@@ -58,61 +33,16 @@ CHUNK_PRESETS = (
|
||||
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||
)
|
||||
|
||||
# Smallest allowed payload budget per frame, below which the frame header
|
||||
# could consume the whole budget.
|
||||
MIN_CHUNK_SIZE = 40
|
||||
|
||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||
|
||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||
_FRAME_MAGIC_V2 = "BAL1"
|
||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
_BASE36_WIDTH = 3
|
||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||
|
||||
|
||||
class QrTransferError(ValueError):
|
||||
"""Base error for will QR / audio transfer processing."""
|
||||
|
||||
|
||||
class MissingFramesError(QrTransferError):
|
||||
"""Some frame indices of a multi-QR transfer are missing."""
|
||||
|
||||
def __init__(self, missing):
|
||||
self.missing = list(missing)
|
||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||
|
||||
|
||||
class InconsistentTotalError(QrTransferError):
|
||||
"""Frames disagree about the advertised frame total."""
|
||||
|
||||
|
||||
def encode_transfer(tx_strings, compress=False):
|
||||
"""Join serialized transaction strings into a transfer string.
|
||||
|
||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||
of the frame header lets the importer reverse this automatically.
|
||||
the whole bundle shrinks before being printed/scanned.
|
||||
"""
|
||||
return __compress("\n".join(tx_strings), enabled=compress)
|
||||
|
||||
|
||||
def encode_transfer_best(tx_strings):
|
||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||
|
||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||
when zlib + base64 really is shorter (best-of, never larger).
|
||||
"""
|
||||
joined = "\n".join(tx_strings)
|
||||
plain = joined
|
||||
compressed = __compress(joined, enabled=True)
|
||||
if len(compressed) < len(plain):
|
||||
return compressed, True
|
||||
return plain, False
|
||||
|
||||
|
||||
def decode_transfer(transfer_string, compressed):
|
||||
"""Inverse of :func:`encode_transfer`.
|
||||
|
||||
@@ -124,73 +54,6 @@ def decode_transfer(transfer_string, compressed):
|
||||
return [part for part in text.split("\n") if part]
|
||||
|
||||
|
||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||
|
||||
Every returned frame has the fixed 11-char v2 header followed by its
|
||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||
importer knows how to reverse the encoding.
|
||||
|
||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||
the header plus any payload, or when the transfer needs more than
|
||||
:data:`_MAX_TOTAL` frames.
|
||||
"""
|
||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||
total = __compute_total(len(transfer_string), chunk_size)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
frames = []
|
||||
pos = 0
|
||||
length = len(transfer_string)
|
||||
for index in range(1, total + 1):
|
||||
end = min(pos + budget, length)
|
||||
frames.append(
|
||||
_FRAME_MAGIC_V2
|
||||
+ _base36(total)
|
||||
+ _base36(index)
|
||||
+ flag
|
||||
+ transfer_string[pos:end]
|
||||
)
|
||||
pos = end
|
||||
if pos < length:
|
||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_frame(frame):
|
||||
"""Parse a single frame.
|
||||
|
||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||
|
||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||
"""
|
||||
if frame.startswith(_FRAME_MAGIC_V2):
|
||||
return _parse_v2(frame)
|
||||
return _parse_v1(frame)
|
||||
|
||||
|
||||
def assemble(frames, total):
|
||||
"""Concatenate frame payloads back into a transfer string.
|
||||
|
||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||
``total`` (else :class:`InconsistentTotalError`).
|
||||
"""
|
||||
if total < 1:
|
||||
raise QrTransferError("invalid frame total")
|
||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||
if missing:
|
||||
raise MissingFramesError(missing)
|
||||
extra = [index for index in frames if index > total]
|
||||
if extra:
|
||||
raise InconsistentTotalError()
|
||||
return "".join(frames[index] for index in range(1, total + 1))
|
||||
|
||||
|
||||
def preset_index_for_chunk_size(chunk_size):
|
||||
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||
@@ -215,90 +78,3 @@ def __decompress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||
|
||||
|
||||
def _base36(n):
|
||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||
if not 0 <= n <= _MAX_TOTAL:
|
||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||
chars = []
|
||||
for _ in range(_BASE36_WIDTH):
|
||||
chars.append(_BASE36_DIGITS[n % 36])
|
||||
n //= 36
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _base36_decode(text):
|
||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||
raise ValueError(text)
|
||||
n = 0
|
||||
for c in text:
|
||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||
return n
|
||||
|
||||
|
||||
def _parse_v1(frame):
|
||||
parts = frame.split("|", maxsplit=4)
|
||||
if len(parts) != 5:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
magic_seen, total_s, index_s, flags, payload = parts
|
||||
if magic_seen != _FRAME_MAGIC_V1:
|
||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||
try:
|
||||
total = int(total_s)
|
||||
index = int(index_s)
|
||||
except ValueError as e:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flags not in ("", FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
return total, index, flags == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def _parse_v2(frame):
|
||||
if len(frame) < _HEADER_V2_LEN:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||
# make up the rest of the fixed header.
|
||||
offset = len(_FRAME_MAGIC_V2)
|
||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||
try:
|
||||
total = _base36_decode(total_s)
|
||||
index = _base36_decode(index_s)
|
||||
except ValueError:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
payload = frame[_HEADER_V2_LEN:]
|
||||
return total, index, flag == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def __compute_total(transfer_len, chunk_size):
|
||||
"""Smallest frame count whose budget covers the whole transfer string.
|
||||
|
||||
The v2 header is fixed-width, so the budget is constant and the count is
|
||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||
"""
|
||||
if chunk_size < MIN_CHUNK_SIZE:
|
||||
raise QrTransferError(
|
||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||
)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
total = -(-transfer_len // budget)
|
||||
if total < 1:
|
||||
total = 1
|
||||
if total > _MAX_TOTAL:
|
||||
raise QrTransferError(
|
||||
"BAL QR transfer demands too many frames: {}".format(total)
|
||||
)
|
||||
return total
|
||||
|
||||
@@ -1364,6 +1364,37 @@ class WillItem(Logger):
|
||||
# format_status_history() still translates them.
|
||||
LEGACY_STATUS_LABELS = (N_("New"),)
|
||||
|
||||
# Translations of "New" that test builds of the i18n work saved INTO the
|
||||
# status history (window.py / the CLI used ``_("New")`` when building).
|
||||
# They are mapped back to "New" on load so the history is language
|
||||
# neutral again. Keep in sync with the "New" msgstr of every catalog in
|
||||
# bal/locale (tests/test_core_status_language_neutral.py checks it).
|
||||
TRANSLATED_NEW_LABELS = ("Nuovo", "Nuevo")
|
||||
|
||||
# Fields that Willexecutors.get_willexecutor_transactions /
|
||||
# push_transactions_to_willexecutor used to write into the item's own
|
||||
# executor record (older versions); they are transient push data.
|
||||
TRANSIENT_PUSH_FIELDS = ("txs", "txsids", "broadcast_status")
|
||||
|
||||
@staticmethod
|
||||
def _untranslate_new_status(status):
|
||||
"""Map a translated leading "New" of a saved history back to "New"."""
|
||||
head, sep, rest = status.partition(".")
|
||||
if head in WillItem.TRANSLATED_NEW_LABELS:
|
||||
return "New" + sep + rest
|
||||
return status
|
||||
|
||||
@staticmethod
|
||||
def _without_transient_push_fields(we):
|
||||
"""Return ``we`` without the transient push fields (a copy if needed)."""
|
||||
if not isinstance(we, dict) or not any(
|
||||
k in we for k in WillItem.TRANSIENT_PUSH_FIELDS
|
||||
):
|
||||
return we
|
||||
return {
|
||||
k: v for k, v in we.items() if k not in WillItem.TRANSIENT_PUSH_FIELDS
|
||||
}
|
||||
|
||||
def set_status(self, status, value=True):
|
||||
"""Set a status flag and apply the related side effects.
|
||||
|
||||
@@ -1445,8 +1476,10 @@ class WillItem(Logger):
|
||||
w = data
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status") or ""
|
||||
self.we = WillItem._without_transient_push_fields(
|
||||
w.get("willexecutor", None)
|
||||
)
|
||||
self.status = WillItem._untranslate_new_status(w.get("status") or "")
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
|
||||
@@ -231,6 +231,13 @@ class Willexecutors:
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutor_transactions(will, force=False):
|
||||
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
|
||||
|
||||
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
|
||||
raw txs, one per line) and ``txsids``.
|
||||
|
||||
The returned records are COPIES: the will items are never modified.
|
||||
"""
|
||||
willexecutors = {}
|
||||
for wid, willitem in will.items():
|
||||
if willitem.get_status("VALID"):
|
||||
@@ -240,10 +247,18 @@ class Willexecutors:
|
||||
url = willexecutor["url"]
|
||||
if willexecutor and Willexecutors.is_selected(willexecutor):
|
||||
if url not in willexecutors:
|
||||
willexecutor["txs"] = ""
|
||||
willexecutor["txsids"] = []
|
||||
willexecutor["broadcast_status"] = _("Waiting...")
|
||||
willexecutors[url] = willexecutor
|
||||
# Work on a COPY of the record: the item's
|
||||
# own ``we`` dict is saved in the wallet, so
|
||||
# the transient push fields below (raw txs,
|
||||
# txids and the - translated - broadcast
|
||||
# status shown in the push dialog) must
|
||||
# never end up in it (stored data must not
|
||||
# depend on the UI language, see bal/i18n.py).
|
||||
record = dict(willexecutor)
|
||||
record["txs"] = ""
|
||||
record["txsids"] = []
|
||||
record["broadcast_status"] = _("Waiting...")
|
||||
willexecutors[url] = record
|
||||
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
|
||||
willexecutors[url]["txsids"].append(wid)
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ from ...core.animated_qr import (
|
||||
bbqr_frames,
|
||||
format_name,
|
||||
parse_for_detection,
|
||||
ur1_frames,
|
||||
ur2_frames,
|
||||
)
|
||||
from ...core.checkalive import (
|
||||
CheckAliveAfterDeliveryError,
|
||||
@@ -54,13 +52,9 @@ from ...core.checkalive import (
|
||||
)
|
||||
from ...core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
encode_transfer_best,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
from ...core.reminders import build_ics_reminders
|
||||
from .calendar import BalCalendarButton
|
||||
@@ -147,29 +141,6 @@ if TYPE_CHECKING:
|
||||
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
||||
|
||||
|
||||
# Animated-QR formats beyond the legacy BAL QR. Combined with the format
|
||||
# selector in :class:`BalQrExportWidget` they give the export page
|
||||
# interoperable BC-UR v1/v2 and BBQR output while keeping BAL QR as the
|
||||
# default (and the only format understood by older plugin versions).
|
||||
ANIMATED_QR_FORMATS = ("ur1", "ur2", "bbqr")
|
||||
|
||||
|
||||
def encode_animated_frames(transfer, fmt, budget_chars):
|
||||
"""Encode the transfer text into the given animated-QR format.
|
||||
|
||||
``transfer`` is the BAL QR transfer text (str). ``budget_chars`` is the
|
||||
maximum length of one frame (the exporter's "QR code size" preset).
|
||||
"""
|
||||
payload = transfer.encode("utf-8")
|
||||
if fmt == "ur1":
|
||||
return ur1_frames(payload, budget_chars)
|
||||
if fmt == "ur2":
|
||||
return ur2_frames(payload, budget_chars)
|
||||
if fmt == "bbqr":
|
||||
return bbqr_frames(payload, budget_chars, encoding="Z")
|
||||
raise QrTransferError("unknown animated QR format: {}".format(fmt))
|
||||
|
||||
|
||||
class BalDialog(QDialog,MessageBoxMixin):
|
||||
_stopping = False
|
||||
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
|
||||
@@ -2764,10 +2735,10 @@ class BalQrExportWidget(QWidget):
|
||||
The page (re)displays itself on :meth:`set_tx_strings`.
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None):
|
||||
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None, on_size_change=None):
|
||||
QWidget.__init__(self, parent)
|
||||
self.chunk_size = chunk_size
|
||||
self.format = "balqr"
|
||||
self._on_size_change = on_size_change
|
||||
self.tx_strings = []
|
||||
self.transfer = ""
|
||||
self.frames = []
|
||||
@@ -2833,36 +2804,11 @@ class BalQrExportWidget(QWidget):
|
||||
size_row.addStretch(1)
|
||||
vbox.addLayout(size_row)
|
||||
|
||||
format_row = QHBoxLayout()
|
||||
format_row.addWidget(QLabel(_("Format:")))
|
||||
self.format_combo = QComboBox()
|
||||
self._format_options = ["balqr"] + list(ANIMATED_QR_FORMATS)
|
||||
self.format_combo.addItems(
|
||||
[
|
||||
format_name(fmt)
|
||||
+ (" {}".format(_("(default)")) if fmt == "balqr" else "")
|
||||
for fmt in self._format_options
|
||||
]
|
||||
)
|
||||
self.format_combo.setCurrentIndex(0)
|
||||
self.format_combo.currentIndexChanged.connect(self._on_format_change)
|
||||
format_row.addWidget(self.format_combo)
|
||||
self.format_hint = QLabel()
|
||||
self.format_hint.setWordWrap(True)
|
||||
format_row.addWidget(self.format_hint, 1)
|
||||
vbox.addLayout(format_row)
|
||||
|
||||
def set_tx_strings(self, tx_strings):
|
||||
"""Rebuild the transfer frames from the given serialized transactions."""
|
||||
self.tx_strings = list(tx_strings)
|
||||
self._stop_auto()
|
||||
self.transfer = encode_transfer(self.tx_strings, compress=False)
|
||||
# BAL QR now ships compact (best-of) compressed by default: smaller
|
||||
# frames, and the importer reverses it via the per-frame flag. The
|
||||
# other formats keep the raw transfer (they compress internally).
|
||||
self._balqr_transfer, self._balqr_compressed = encode_transfer_best(
|
||||
self.tx_strings
|
||||
)
|
||||
self._refresh_frames()
|
||||
self._update_intro()
|
||||
self._render()
|
||||
@@ -2872,30 +2818,18 @@ class BalQrExportWidget(QWidget):
|
||||
return len(self.frames)
|
||||
|
||||
def _refresh_frames(self):
|
||||
if self.format == "balqr":
|
||||
self.frames = split_frames(
|
||||
self._balqr_transfer,
|
||||
self.chunk_size,
|
||||
compressed=self._balqr_compressed,
|
||||
)
|
||||
else:
|
||||
self.frames = encode_animated_frames(
|
||||
self.transfer, self.format, self.chunk_size
|
||||
)
|
||||
self.frames = bbqr_frames(
|
||||
self.transfer.encode("utf-8"), self.chunk_size, encoding="Z"
|
||||
)
|
||||
self.index = 0
|
||||
|
||||
def _on_format_change(self, index):
|
||||
self._stop_auto()
|
||||
self.format = self._format_options[index]
|
||||
self._refresh_frames()
|
||||
self._render()
|
||||
self._update_intro()
|
||||
|
||||
def _on_chunk_change(self, index):
|
||||
self._stop_auto()
|
||||
self.chunk_size = CHUNK_PRESETS[index][1]
|
||||
self._refresh_frames()
|
||||
self._render()
|
||||
if self._on_size_change is not None:
|
||||
self._on_size_change(self.chunk_size)
|
||||
|
||||
def _toggle_auto(self):
|
||||
"""Start/stop the automatic QR slideshow."""
|
||||
@@ -2927,32 +2861,13 @@ class BalQrExportWidget(QWidget):
|
||||
self._next()
|
||||
|
||||
def _update_intro(self):
|
||||
self.format_hint.setText(
|
||||
{
|
||||
"balqr": _(
|
||||
"Proprietary format; every other BAL wallet understands it."
|
||||
),
|
||||
"ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older "
|
||||
"Blockchain Commons tools."),
|
||||
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
|
||||
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
|
||||
}[self.format]
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below with the will-opening device.\n"
|
||||
"{} codes carry the whole transfer (any order works, "
|
||||
"duplicates are ignored)."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
if self.format == "balqr":
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below, in order, with the will-opening "
|
||||
"device.\nFrame 1 of {} carries the total number of codes."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
else:
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below with the will-opening device.\n"
|
||||
"{} codes carry the whole transfer (any order works, "
|
||||
"duplicates are ignored)."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
|
||||
def _prev(self):
|
||||
if self.index > 0:
|
||||
@@ -2994,6 +2909,7 @@ class WillExportDialog(BalDialog):
|
||||
def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"):
|
||||
BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will"))
|
||||
self.bal_window = bal_window
|
||||
self.bal_plugin = bal_plugin
|
||||
self._source = will if will is not None else bal_window.willitems
|
||||
self._filters = export_filter_options()
|
||||
self._filter_index = 0
|
||||
@@ -3047,7 +2963,10 @@ class WillExportDialog(BalDialog):
|
||||
self.stacked = QStackedWidget()
|
||||
self.file_page = self._build_file_page()
|
||||
self.stacked.addWidget(self.file_page)
|
||||
self.qr_page = BalQrExportWidget(chunk_size=chunk)
|
||||
self.qr_page = BalQrExportWidget(
|
||||
chunk_size=chunk,
|
||||
on_size_change=self._remember_qr_size,
|
||||
)
|
||||
self.stacked.addWidget(self.qr_page)
|
||||
self.audio_page = self._build_audio_page()
|
||||
self.stacked.addWidget(self.audio_page)
|
||||
@@ -3057,12 +2976,19 @@ class WillExportDialog(BalDialog):
|
||||
close_btn.clicked.connect(self.close)
|
||||
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# The audio transport is only offered when the audio_modem plugin is
|
||||
# available; otherwise its radio button is disabled (file and QR stay
|
||||
# usable).
|
||||
self.transport_audio.setEnabled(self._audio_available)
|
||||
|
||||
mode_ids = {
|
||||
"file": self.MODE_FILE,
|
||||
"qr": self.MODE_QR,
|
||||
"audio": self.MODE_AUDIO,
|
||||
}
|
||||
mode = mode_ids.get(initial_mode, self.MODE_FILE)
|
||||
if mode == self.MODE_AUDIO and not self._audio_available:
|
||||
mode = self.MODE_FILE
|
||||
(self.transport_file, self.transport_qr, self.transport_audio)[
|
||||
mode
|
||||
].setChecked(True)
|
||||
@@ -3097,6 +3023,7 @@ class WillExportDialog(BalDialog):
|
||||
self._audio_plugin = self.bal_window.get_audio_modem_plugin()
|
||||
self.bitrates = audio_bitrates()
|
||||
available = self._audio_plugin is not None
|
||||
self._audio_available = available
|
||||
if available:
|
||||
self.audio_warn_label.hide()
|
||||
else:
|
||||
@@ -3125,6 +3052,14 @@ class WillExportDialog(BalDialog):
|
||||
def _selected_items(self):
|
||||
return filter_willitems(self._source, self._filters, self._filter_index)
|
||||
|
||||
def _remember_qr_size(self, size):
|
||||
"""Persist the QR size chosen in the export page for the next export."""
|
||||
if self.bal_plugin is not None:
|
||||
try:
|
||||
self.bal_plugin.QR_CHUNK_SIZE.set(size)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _payload_strings(self):
|
||||
"""The data handed to the QR page, honouring the content scope.
|
||||
|
||||
@@ -3296,6 +3231,10 @@ class WillImportDialog(BalDialog):
|
||||
close_btn.clicked.connect(self.close)
|
||||
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# The audio transport is only offered when the audio_modem plugin is
|
||||
# available; otherwise its radio button is disabled.
|
||||
self.transport_audio.setEnabled(self._audio_available)
|
||||
|
||||
self.transport_file.setChecked(True)
|
||||
self._on_mode_clicked(self.MODE_FILE)
|
||||
|
||||
@@ -3323,6 +3262,7 @@ class WillImportDialog(BalDialog):
|
||||
self._audio_plugin = self.bal_window.get_audio_modem_plugin()
|
||||
self.bitrates = audio_bitrates()
|
||||
available = self._audio_plugin is not None
|
||||
self._audio_available = available
|
||||
if available:
|
||||
self.audio_warn_label.hide()
|
||||
else:
|
||||
@@ -3563,9 +3503,9 @@ def qr_import_accept_frame(
|
||||
seen ``stable_reads`` times in a row -- this mirrors the export-side
|
||||
slideshow pacing and swallows transition artifacts;
|
||||
* re-reading the currently accepted frame is ``ignore``d;
|
||||
* a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or
|
||||
``"ur2:2-31-3804692811"``) contradicts the transfer already being built
|
||||
is a ``reset`` (a different transfer was presented).
|
||||
* a frame whose ``session_key`` (transfer identity, e.g. ``"bbqr:Z:B:3"``)
|
||||
contradicts the transfer already being built is a ``reset`` (a
|
||||
different transfer was presented).
|
||||
|
||||
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
|
||||
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
|
||||
@@ -3751,8 +3691,8 @@ class BalQrImportWidget(QWidget):
|
||||
self._key = key
|
||||
self._init_slots()
|
||||
if status == "ok":
|
||||
# Slot grid is always 1-based even for 0-based wire formats.
|
||||
slot = index + 1 if fmt == "bbqr" else index
|
||||
# Slot grid is always 1-based even for the 0-based BBQR indexes.
|
||||
slot = index + 1
|
||||
self.frames[slot] = frame_text
|
||||
self._update_slots()
|
||||
self._update_status()
|
||||
@@ -4008,7 +3948,7 @@ class BalQrImportWidget(QWidget):
|
||||
try:
|
||||
transfer, compressed = session.resolve()
|
||||
parts = decode_transfer(transfer, compressed)
|
||||
except (MissingFramesError, QrTransferError, AnimatedQrError) as e:
|
||||
except AnimatedQrError as e:
|
||||
self.show_error(str(e))
|
||||
self._reset_all()
|
||||
return
|
||||
|
||||
@@ -19,7 +19,6 @@ from electrum.plugin import hook
|
||||
from electrum.util import EventListener, event_listener
|
||||
from PyQt6.QtWidgets import QLayout
|
||||
|
||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||
from ...i18n import init_from_config
|
||||
from .common import (
|
||||
BalPlugin,
|
||||
@@ -538,22 +537,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
# users (BASIC and ADVANCED).
|
||||
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
|
||||
|
||||
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
|
||||
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
|
||||
# Ordered low -> high so the user picks the resolution matching their
|
||||
# camera. Visible to all users (BASIC and ADVANCED).
|
||||
qr_size_combo = QComboBox()
|
||||
# Labels marked for translation in common.QR_PRESET_LABELS.
|
||||
qr_size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS])
|
||||
qr_size_combo.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
|
||||
)
|
||||
|
||||
def on_qr_size_change(index):
|
||||
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
|
||||
|
||||
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
|
||||
|
||||
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||
# (not a free-text field) bound to the USER_TYPE config:
|
||||
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
|
||||
@@ -670,10 +653,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
btn.clicked.connect(reset)
|
||||
return btn
|
||||
|
||||
@@ -932,25 +911,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
)
|
||||
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
||||
|
||||
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
|
||||
# size used when exporting a will via QR codes; changeable per export
|
||||
# inside the export dialog itself.
|
||||
lbl_qr_size = QLabel(_("QR Code Size"))
|
||||
help_qr_size = HelpButton(_(
|
||||
"Payload size of a single QR code when exporting a will via QR.\n\n"
|
||||
"Larger QR codes hold more data (fewer shots) but are easier to "
|
||||
"scan with a high-resolution camera; smaller QR codes scan fine "
|
||||
"even with low-resolution cameras but require more shots.\n"
|
||||
"The same selector is available inside the export dialog."
|
||||
))
|
||||
grid.addWidget(lbl_qr_size, 16, 0)
|
||||
grid.addWidget(qr_size_combo, 16, 1)
|
||||
grid.addWidget(help_qr_size, 16, 2)
|
||||
reset_btn_qr_size = _make_reset_btn(
|
||||
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
|
||||
)
|
||||
grid.addWidget(reset_btn_qr_size, 16, 3)
|
||||
|
||||
# ----------------------------------------------------------------- #
|
||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||
# their factory defaults. It only resets the settings exposed by THIS #
|
||||
@@ -984,7 +944,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
(self.HISTORY_LABEL, edit_history_label, "line"),
|
||||
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
|
||||
]
|
||||
for cfg, widget, kind in resets:
|
||||
# Persist the default value back into the Electrum config.
|
||||
@@ -1005,10 +964,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
# Re-sync the history-label field's enabled state after a reset: the
|
||||
# reset restores SAVE_HISTORY to its default, so the field must
|
||||
# follow the (default) checkbox state again.
|
||||
|
||||
@@ -551,7 +551,9 @@ class BalWindow:
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
# Stored history: always English (translated only for display by
|
||||
# format_status_history); never _() here.
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
# ./make-release.sh # read version from bal/manifest.json
|
||||
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||
#
|
||||
# Requires: git, gpg, curl, python3, sha256sum
|
||||
# Requires: git, gpg, curl, python3, sha256sum, Babel for python3 (pip install babel)
|
||||
# Optional: ruff (lint skipped if not installed)
|
||||
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
||||
|
||||
@@ -33,6 +33,12 @@ else
|
||||
NEW_VER=""
|
||||
fi
|
||||
|
||||
# Babel is needed by the i18n tests (step 3) and by build_zip.py, which
|
||||
# compiles the translation catalogs into the zip (step 5). Check it up front
|
||||
# so a release never stops half-way.
|
||||
python3 -c "import babel" 2>/dev/null \
|
||||
|| die "Babel is missing for $(command -v python3): pip install babel"
|
||||
|
||||
echo "=== Release ${TAG} ==="
|
||||
echo "Current manifest version: ${CURRENT_VER}"
|
||||
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
||||
@@ -68,6 +74,8 @@ if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
||||
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
||||
tests/test_group_h_v048.py \
|
||||
tests/test_i18n.py tests/test_translations.py \
|
||||
tests/test_cli_*.py tests/test_import_will_details.py \
|
||||
-q 2>&1; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
|
||||
@@ -6,6 +6,12 @@ target-version = "py312"
|
||||
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# Classify the plugin package explicitly: otherwise ruff decides from where it
|
||||
# is run / how the project root is detected, and the same import block is
|
||||
# "sorted" on one machine and I001 on another.
|
||||
known-first-party = ["bal"]
|
||||
|
||||
[tool.ruff.lint.pep8-naming]
|
||||
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
|
||||
Tests for ``bal.core.animated_qr`` (BBQR interop).
|
||||
|
||||
Validates the self-contained codecs against the published spec vectors
|
||||
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
|
||||
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
|
||||
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
|
||||
fountain solving and malformed-input rejection for all four formats.
|
||||
Validates the self-contained BBQR codec against round trips, out-of-order
|
||||
assembly, compression default/fallback, malformed-input rejection and the
|
||||
format detection / parse_for_detection helpers.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
@@ -22,328 +20,6 @@ import random
|
||||
from bal.core import animated_qr as aq
|
||||
|
||||
|
||||
def _payload(plen: int) -> bytes:
|
||||
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
|
||||
return bytes((i * 7) & 0xFF for i in range(plen))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bc32_official_vectors():
|
||||
cases = [
|
||||
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
|
||||
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
|
||||
(
|
||||
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
|
||||
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
|
||||
),
|
||||
]
|
||||
for payload, encoded in cases:
|
||||
assert aq.bc32_encode(payload) == encoded
|
||||
assert aq.bc32_decode(encoded) == payload
|
||||
|
||||
|
||||
def test_bc32_checksum_rejected():
|
||||
good = aq.bc32_encode(b"Hello, world")
|
||||
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
|
||||
try:
|
||||
aq.bc32_decode(corrupted)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for corrupted BC32")
|
||||
|
||||
|
||||
def test_bc32_bad_char_rejected():
|
||||
try:
|
||||
aq.bc32_decode("1" * 26)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bytewords (BCR-2020-012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bytewords_minimal_roundtrip():
|
||||
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
|
||||
os.urandom(64) for _ in range(4)
|
||||
]
|
||||
for data in samples:
|
||||
words = aq.bytewords_minimal_encode(data)
|
||||
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
|
||||
assert aq.bytewords_minimal_decode(words) == data
|
||||
|
||||
|
||||
def test_bytewords_rejects_corrupted_crc():
|
||||
data = _payload(40)
|
||||
words = aq.bytewords_minimal_encode(data)
|
||||
flip = "a" if words[-1] != "a" else "b"
|
||||
try:
|
||||
aq.bytewords_minimal_decode(words[:-1] + flip)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for corrupted CRC")
|
||||
|
||||
|
||||
def test_bytewords_rejects_odd_length():
|
||||
try:
|
||||
aq.bytewords_minimal_decode("abc")
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2: byte-exact parity with the reference C++ encoder
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Reference frames from the bc-ur C++ fountain encoder
|
||||
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
|
||||
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
|
||||
|
||||
REF_V2_MULTI_59 = [
|
||||
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
|
||||
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
|
||||
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
|
||||
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
|
||||
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
|
||||
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
|
||||
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
|
||||
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
|
||||
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
|
||||
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
|
||||
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
|
||||
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
|
||||
]
|
||||
|
||||
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
|
||||
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
|
||||
|
||||
|
||||
def test_v2_single_part_matches_reference():
|
||||
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
|
||||
assert frames == [REF_V2_SINGLE_12]
|
||||
|
||||
|
||||
def test_v2_reference_frames_decode_and_reencode_exactly():
|
||||
message = REF_V2_MULTI_59_MSG
|
||||
fragment_len = -(-len(message) // 2)
|
||||
for frame in REF_V2_MULTI_59:
|
||||
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
|
||||
assert seq_len == 2
|
||||
assert message_len == len(message)
|
||||
assert checksum == aq.crc32_int(message)
|
||||
assert len(data) == fragment_len
|
||||
# re-encoding the parsed values reproduces the reference line exactly
|
||||
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
|
||||
# our choose_fragments + partition + xor reproduces the reference data
|
||||
indexes = aq.choose_fragments(seq, seq_len, checksum)
|
||||
assert seq_num_indexes_valid(seq, seq_len, indexes)
|
||||
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
|
||||
assert mixed == data
|
||||
|
||||
|
||||
def seq_num_indexes_valid(seq, seq_len, indexes):
|
||||
# pure part for seq <= seq_len contains exactly fragment seq-1
|
||||
if seq <= seq_len:
|
||||
return indexes == {seq - 1}
|
||||
return set(indexes) <= set(range(seq_len)) and bool(indexes)
|
||||
|
||||
|
||||
def test_v2_multipart_encoder_matches_reference_from_seq2():
|
||||
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
|
||||
# reference (which starts at seq 2 due to first_seq_num=1).
|
||||
mine = aq.ur2_frames(_payload(59), 120)
|
||||
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
|
||||
assert mine[1:4] == REF_V2_MULTI_59[:3]
|
||||
|
||||
|
||||
def test_v2_reference_seq7_mix_parity():
|
||||
# Higher-degree mixed parts (seq_len=7) also match: message uses the
|
||||
# reference head 0x58|0x00 for the 256-byte driver payload.
|
||||
message = bytes([0x58, 0x00]) + _payload(256)
|
||||
seq_len = 7
|
||||
fragment_len = -(-len(message) // seq_len)
|
||||
frames = [
|
||||
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
|
||||
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
|
||||
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
|
||||
]
|
||||
for frame in frames:
|
||||
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
|
||||
assert sl == seq_len and mlen == len(message)
|
||||
assert checksum == aq.crc32_int(message)
|
||||
mixed = aq._mix_fragments(
|
||||
aq._partition_message(message, fragment_len),
|
||||
aq.choose_fragments(seq, seq_len, checksum),
|
||||
fragment_len,
|
||||
)
|
||||
assert mixed == data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2: sessions / fountain decoding
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_roundtrip_in_order():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
seq_len = int(frames[0].split("/")[1].split("-")[1])
|
||||
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in frames:
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.received == session.total
|
||||
text, _ = session.resolve()
|
||||
assert text == payload.decode()
|
||||
|
||||
|
||||
def test_v2_out_of_order_and_duplicate():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
order = list(range(len(frames)))
|
||||
random.Random(11).shuffle(order)
|
||||
session = aq.AnimatedQrSession()
|
||||
for i in order:
|
||||
status = session.add_part(frames[i])
|
||||
assert status in ("ok", "dup")
|
||||
session.add_part(frames[0]) # duplicate of an already-received part
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v2_solves_without_a_pure_fragment():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in frames[1:]: # drop the first pure fragment
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v2_single_part_import():
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(REF_V2_SINGLE_12)
|
||||
assert session.done and session.total == 1
|
||||
assert session.resolve()[0] == _payload(12).decode("latin-1")
|
||||
|
||||
|
||||
def test_v2_conflicting_transfer_rejected():
|
||||
payload_a = b"AAAAAAAAAAAAAAAA"
|
||||
payload_b = b"BBBBBBBBBBBBBBBB"
|
||||
fa = aq.ur2_frames(payload_a, 500)[0]
|
||||
fb = aq.ur2_frames(payload_b, 500)[0]
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(fa)
|
||||
try:
|
||||
session.add_part(fb)
|
||||
except aq.TransferConflictError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected TransferConflictError for a different transfer")
|
||||
|
||||
|
||||
def test_v2_corrupt_crc_rejected():
|
||||
frame = list(REF_V2_MULTI_59[0])
|
||||
idx = len(frame) - 1
|
||||
frame[idx] = "a" if frame[idx] != "a" else "b"
|
||||
try:
|
||||
aq.ur2_parse_part("".join(frame))
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
|
||||
|
||||
|
||||
def test_v2_session_cap_rejected():
|
||||
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
|
||||
session = aq._Ur2Session()
|
||||
try:
|
||||
session.add(part)
|
||||
except aq.SessionLimitError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected SessionLimitError for oversized seq_len")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v1
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v1_multipart_roundtrip():
|
||||
payload = ("v1 transfer payload " * 6).encode()
|
||||
frames = aq.ur1_frames(payload, 120)
|
||||
assert len(frames) > 1
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in reversed(frames):
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_single_part_roundtrip():
|
||||
payload = b"hello, bal"
|
||||
frames = aq.ur1_frames(payload, 400)
|
||||
assert len(frames) == 1
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(frames[0])
|
||||
assert session.done and session.total == 1
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_headerless_single_part_import():
|
||||
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
|
||||
payload = b"hello, bal"
|
||||
message = aq.cbor_byte_string(payload)
|
||||
single = "ur:bytes/" + aq.bc32_encode(message)
|
||||
assert aq.detect_format(single) == "ur1"
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(single)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_digest_mismatch_rejected():
|
||||
frame = aq.ur1_frames(b"hello, bal", 400)[0]
|
||||
tampered = frame[:-4] + "abcd"
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(tampered)
|
||||
try:
|
||||
session.resolve()
|
||||
except aq.ChecksumError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ChecksumError for a tampered v1 digest")
|
||||
|
||||
|
||||
def test_v1_part_numbers_validated():
|
||||
for bad in (
|
||||
"ur:bytes/0of1/{}full".format("x" * 51),
|
||||
"ur:bytes/2of1/{}full".format("x" * 51),
|
||||
"ur:bytes/1of0/{}full".format("x" * 51),
|
||||
"ur:bytes/1aof1/{}full".format("x" * 51),
|
||||
):
|
||||
try:
|
||||
aq.ur1_parse_part(bad)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BBQR
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -424,41 +100,22 @@ def test_bbqr_part_number_limits():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_detect_format_recognises_all_formats():
|
||||
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
||||
assert aq.detect_format("BAL1" + "001" + "001" + "0" + "payload") == "balqr"
|
||||
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
|
||||
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
||||
def test_detect_format_recognises_bbqr():
|
||||
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
|
||||
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
|
||||
|
||||
|
||||
def test_detect_format_rejects_garbage():
|
||||
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
|
||||
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
|
||||
assert aq.detect_format(text) is None, text
|
||||
# Lenient prefix probe: a string that merely *starts* with "balqr" is
|
||||
# reported as balqr (the strict parse then rejects it downstream).
|
||||
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
|
||||
|
||||
|
||||
def test_parse_for_detection_keys():
|
||||
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
||||
assert bal == ("balqr", "balqr:3", 3, 2)
|
||||
# Compact v2 frame (fixed 11-char header) is detected too.
|
||||
bal_v2 = aq.parse_for_detection("BAL1" + "007" + "004" + "0" + "payload")
|
||||
assert bal_v2 == ("balqr", "balqr:7", 7, 4)
|
||||
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
|
||||
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
|
||||
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
||||
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
|
||||
bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0])
|
||||
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
|
||||
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
|
||||
|
||||
|
||||
def test_format_names_exist():
|
||||
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
assert aq.format_name(fmt)
|
||||
assert aq.format_name("bbqr")
|
||||
assert aq.format_name("nope") == "nope"
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
Tests for ``bal.core.qrtransfer``.
|
||||
|
||||
Covers the BALQR frame encoding used for will transfer via QR codes /
|
||||
audio modem: encoding, framing, reassembly, malformed input and the preset
|
||||
list (optionally cross-checked against the ``qrcode`` library's EC-M
|
||||
capacity when it is installed).
|
||||
Covers the transfer-string encoding used for will transfer via BBQR QR codes
|
||||
/ audio modem: encoding, decoding and the preset list.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
@@ -18,34 +16,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MIN_CHUNK_SIZE,
|
||||
InconsistentTotalError,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
assemble,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
parse_frame,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
|
||||
|
||||
def _frames(tx_strings, chunk_size, compress=False):
|
||||
"""Split a payload and return (payload, total, {index: payload})."""
|
||||
payload = encode_transfer(tx_strings, compress=compress)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, chunk_size, compressed=compress):
|
||||
t, index, _compressed, p = parse_frame(frame)
|
||||
if total is not None:
|
||||
assert total == t
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
return payload, total, parsed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Round trips
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -67,300 +43,6 @@ def test_empty_list_roundtrip():
|
||||
assert decode_transfer(encode_transfer([]), compressed=False) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Framing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_single_frame():
|
||||
tx_strings = ["11" * 10]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
total, index, compressed, p = parse_frame(frames[0])
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_multiple_frames_reassemble():
|
||||
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
|
||||
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
|
||||
assert total > 1
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=False)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
def test_size_greater_than_payload():
|
||||
tx_strings = ["12" * 5]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 1800)
|
||||
assert len(frames) == 1
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_exact_single_frame_boundary():
|
||||
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
|
||||
# compact header plus payload), so the encoded frame is exactly 150.
|
||||
tx_strings = ["a" * 139]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
assert len(frames[0]) == 150
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_frames_fit_chunk_size():
|
||||
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
|
||||
payload = encode_transfer(tx_strings)
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
for frame in split_frames(payload, size):
|
||||
assert len(frame) <= size, (size, len(frame))
|
||||
|
||||
|
||||
def test_single_tx_larger_than_chunk():
|
||||
# A huge serialized tx must be split over several frames and reassemble
|
||||
# exactly (positional slicing is safe for hex/base64 text).
|
||||
tx_strings = ["7b" * 1000] # 2000 chars
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) > 1
|
||||
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
|
||||
total = parse_frame(frames[0])[0]
|
||||
assert assemble(parsed, total) == payload
|
||||
|
||||
|
||||
def test_compressed_frames_carry_flag():
|
||||
tx_strings = ["ab" * 40]
|
||||
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
|
||||
for frame in frames:
|
||||
_t, _i, compressed, _p = parse_frame(frame)
|
||||
assert compressed is True
|
||||
# Plain frames do not.
|
||||
frames_plain = split_frames(encode_transfer(tx_strings), 150)
|
||||
_t, _i, compressed, _p = parse_frame(frames_plain[0])
|
||||
assert compressed is False
|
||||
|
||||
|
||||
def test_compressed_roundtrip_through_frames():
|
||||
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
|
||||
payload = encode_transfer(tx_strings, compress=True)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, 400, compressed=True):
|
||||
t, index, _c, p = parse_frame(frame)
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=True)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Compact v2 wire format ("BAL1")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_frame_header_structure():
|
||||
frames = split_frames(encode_transfer(["11" * 10]), 150)
|
||||
assert len(frames) == 1
|
||||
frame = frames[0]
|
||||
assert frame.startswith("BAL1")
|
||||
# Fixed 11-char header: magic + 3-char total + 3-char index + 1 flag.
|
||||
assert len(frame) > 11
|
||||
magic, total_s, index_s, flag, payload = (
|
||||
frame[:4],
|
||||
frame[4:7],
|
||||
frame[7:10],
|
||||
frame[10],
|
||||
frame[11:],
|
||||
)
|
||||
assert magic == "BAL1"
|
||||
assert total_s == "001"
|
||||
assert index_s == "001"
|
||||
assert flag == "0"
|
||||
assert payload == "11" * 10
|
||||
total, index, compressed, p = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_v2_compressed_flag_is_z():
|
||||
frames = split_frames(
|
||||
encode_transfer(["11" * 10], compress=True), 150, compressed=True
|
||||
)
|
||||
assert frames[0][10] == "Z"
|
||||
_t, _i, compressed, _p = parse_frame(frames[0])
|
||||
assert compressed is True
|
||||
|
||||
|
||||
def test_v2_header_fixed_width_high_counts():
|
||||
# A long transfer needs multi-digit counts; the v2 header stays exactly
|
||||
# 11 chars no matter how many frames (3-char base36 zero-padded counts).
|
||||
tx_strings = ["ab" * 300] # 600 chars -> several frames at 150
|
||||
frames = split_frames(encode_transfer(tx_strings), 150)
|
||||
assert len(frames) > 1
|
||||
for frame in frames:
|
||||
# magic(4) + total(3) + index(3) + flag(1) = 11 chars, then payload.
|
||||
assert len(frame) - len(frame[11:]) == 11
|
||||
|
||||
|
||||
def test_v2_max_frame_count():
|
||||
# A transfer needing more than 46655 frames must be rejected (3-char
|
||||
# base36 count fields cannot represent larger totals).
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
oversized = "A" * (_MAX_TOTAL * (150 - 11) + 1)
|
||||
try:
|
||||
split_frames(oversized, 150)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError above the frame cap")
|
||||
|
||||
|
||||
def test_v2_boundary_at_max_count():
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
# Exactly at the cap: must still produce (bounded) frames with 3-char
|
||||
# counts "VVV" (46655) for the highest serialised part.
|
||||
payload = "B" * (_MAX_TOTAL * (150 - 11))
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == _MAX_TOTAL
|
||||
total, index, _c, _p = parse_frame(frames[-1])
|
||||
assert total == _MAX_TOTAL
|
||||
assert index == _MAX_TOTAL
|
||||
assert frames[-1][:10] == "BAL1" + "ZZZ" + "ZZZ"
|
||||
|
||||
|
||||
def test_encode_transfer_best():
|
||||
from bal.core.qrtransfer import encode_transfer_best
|
||||
|
||||
# Redundant JSON-ish text compresses -> compressed (and longer source
|
||||
# must round-trip unchanged).
|
||||
txs = ['{"a": "%s"}' % ("x" * 300), '{"b": "%s"}' % ("y" * 300)]
|
||||
transfer, compressed = encode_transfer_best(txs)
|
||||
assert compressed is True
|
||||
assert decode_transfer(transfer, compressed) == txs
|
||||
|
||||
# Already-compact input stays plain (never larger than the source).
|
||||
txs_small = ["ab", "cd"]
|
||||
transfer, compressed = encode_transfer_best(txs_small)
|
||||
assert compressed is False
|
||||
assert decode_transfer(transfer, compressed) == txs_small
|
||||
|
||||
|
||||
def test_v2_malformed_frames():
|
||||
bad = (
|
||||
"BAL1", # header only, no fields
|
||||
"BAL1" + "001", # truncated
|
||||
"BAL1" + "G-1" + "001" + "Z" + "p", # non-base36 total
|
||||
"BAL1" + "001" + "G-1" + "Z" + "p", # non-base36 index
|
||||
"BAL1" + "000" + "001" + "Z" + "p", # total 0
|
||||
"BAL1" + "001" + "000" + "Z" + "p", # index 0
|
||||
"BAL1" + "001" + "002" + "Z" + "p", # index beyond total
|
||||
"BAL1" + "001" + "001" + "Q" + "p", # unknown flag
|
||||
)
|
||||
for frame in bad:
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
continue
|
||||
raise AssertionError("expected QrTransferError for: {!r}".format(frame))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Malformed input
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_bad_magic_and_version():
|
||||
for frame in (
|
||||
"BALQR|1|1||a", # missing version
|
||||
"BALQR2|1|1||a", # unknown version
|
||||
"XXXXX1|1|1||a", # unknown magic
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_arity():
|
||||
for frame in ("BALQR1", "BALQR1|1|1|"):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_pipe_in_payload_is_folded():
|
||||
# maxsplit keeps the tail (including any inner '|') in the payload part.
|
||||
frame = "BALQR1|1|1||a|b|c"
|
||||
total, index, compressed, payload = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert payload == "a|b|c"
|
||||
|
||||
|
||||
def test_parse_bad_numbers():
|
||||
for frame in (
|
||||
"BALQR1|x|1||a",
|
||||
"BALQR1|1|y||a",
|
||||
"BALQR1|0|1||a",
|
||||
"BALQR1|1|0||a",
|
||||
"BALQR1|1|2||a", # index beyond total
|
||||
"BALQR1|-1|1||a",
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_flags():
|
||||
try:
|
||||
parse_frame("BALQR1|1|1|Q|payload")
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for unknown flags")
|
||||
|
||||
|
||||
def test_assemble_missing_frames():
|
||||
try:
|
||||
assemble({1: "a", 3: "c"}, total=3)
|
||||
except MissingFramesError as e:
|
||||
assert e.missing == [2]
|
||||
else:
|
||||
raise AssertionError("expected MissingFramesError")
|
||||
|
||||
|
||||
def test_assemble_index_beyond_total():
|
||||
try:
|
||||
assemble({1: "a", 2: "b"}, total=1)
|
||||
except InconsistentTotalError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected InconsistentTotalError")
|
||||
|
||||
|
||||
def test_assemble_order_and_total_validation():
|
||||
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
|
||||
try:
|
||||
assemble({}, total=0)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constants / presets
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -379,46 +61,6 @@ def test_preset_index_for_chunk_size():
|
||||
assert preset_index_for_chunk_size(1800) == 3
|
||||
|
||||
|
||||
def test_min_chunk_size_guard():
|
||||
try:
|
||||
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for tiny chunk size")
|
||||
|
||||
|
||||
def test_split_frame_headers_consistent():
|
||||
tx_strings = ["ab" * 80]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
totals = {parse_frame(frame)[0] for frame in frames}
|
||||
assert len(totals) == 1
|
||||
assert totals.pop() == len(frames)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optional: cross-check presets against the qrcode library (EC level M)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_presets_fit_qrcode_ec_m():
|
||||
"""Every preset budget must render inside a QR at EC level M."""
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_M
|
||||
except ImportError:
|
||||
print("qrcode not installed - skipping capacity check")
|
||||
return
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
# Worst-case frame: header with the largest plausible total/index plus
|
||||
# a full payload of the preset budget.
|
||||
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
|
||||
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
|
||||
qr.add_data(frame)
|
||||
qr.get_matrix() # raises DataOverflowError if it does not fit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
136
tests/test_core_status_language_neutral.py
Normal file
136
tests/test_core_status_language_neutral.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Stored will data must not depend on the GUI language (rule in bal/i18n.py).
|
||||
|
||||
Covers the two findings of the i18n review:
|
||||
|
||||
* the status history of a new will item is saved as English "New" (not
|
||||
``_("New")``), and histories saved by test builds as "Nuovo"/"Nuevo" are
|
||||
mapped back to "New" on load;
|
||||
* the push helper no longer writes transient fields (``txs``, ``txsids`` and
|
||||
the translated ``broadcast_status``) into the will item's executor record,
|
||||
and such fields saved by older versions are dropped on load.
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_status_language_neutral.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
from bal.core.will import WillItem
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
URL = "https://we.example"
|
||||
|
||||
|
||||
def _item(status="", we=None):
|
||||
return WillItem(
|
||||
{"tx": _VALID_TX_HEX, "heirs": {}, "willexecutor": we, "status": status}
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Status history "New"
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_builders_store_english_new():
|
||||
"""window.py and the CLI controller must not translate the stored status."""
|
||||
for rel in ("bal/gui/qt/window.py", "bal/cli/controller.py"):
|
||||
src = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert re.search(r'tx\["status"\]\s*=\s*"New"', src), rel
|
||||
assert not re.search(r'tx\["status"\]\s*=\s*_\(', src), rel
|
||||
|
||||
|
||||
def test_translated_new_is_mapped_back_on_load():
|
||||
assert _item("Nuovo.Signed.Pushed").status == "New.Signed.Pushed"
|
||||
assert _item("Nuevo").status == "New"
|
||||
assert _item("New.Signed").status == "New.Signed"
|
||||
|
||||
|
||||
def test_only_the_leading_new_is_touched():
|
||||
assert _item(".Nuovo").status == ".Nuovo"
|
||||
assert _item("Signed.Nuovo").status == "Signed.Nuovo"
|
||||
|
||||
|
||||
def test_translated_new_labels_match_the_catalogs():
|
||||
"""Every catalog's translation of "New" must be recognised on load."""
|
||||
found = set()
|
||||
for po in (ROOT / "bal" / "locale").glob("*/LC_MESSAGES/bal.po"):
|
||||
text = po.read_text(encoding="utf-8")
|
||||
m = re.search(r'^msgid "New"\nmsgstr "(.*)"$', text, re.MULTILINE)
|
||||
if m and m.group(1):
|
||||
found.add(m.group(1))
|
||||
assert found, "no catalog translates 'New'"
|
||||
assert found <= set(WillItem.TRANSLATED_NEW_LABELS), found
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transient push fields
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeItem:
|
||||
def __init__(self):
|
||||
self.we = {"url": URL, "selected": True, "address": "a", "base_fee": 1}
|
||||
self.tx = "rawtx"
|
||||
|
||||
def get_status(self, name):
|
||||
return name in ("VALID", "COMPLETE")
|
||||
|
||||
|
||||
def test_push_helper_does_not_modify_the_will_item():
|
||||
item = FakeItem()
|
||||
before = dict(item.we)
|
||||
out = Willexecutors.get_willexecutor_transactions({"t1": item})
|
||||
assert out[URL]["txsids"] == ["t1"]
|
||||
assert "broadcast_status" in out[URL]
|
||||
assert item.we == before
|
||||
assert out[URL] is not item.we
|
||||
|
||||
|
||||
def test_transient_fields_saved_by_older_versions_are_dropped_on_load():
|
||||
we = {
|
||||
"url": URL,
|
||||
"selected": True,
|
||||
"txs": "raw\n",
|
||||
"txsids": ["t1"],
|
||||
"broadcast_status": "Riuscito",
|
||||
}
|
||||
item = _item("New", we)
|
||||
assert item.we == {"url": URL, "selected": True}
|
||||
assert "broadcast_status" not in item.to_dict()["willexecutor"]
|
||||
|
||||
|
||||
def test_clean_executor_record_is_kept_as_is():
|
||||
we = {"url": URL, "selected": True}
|
||||
assert _item("New", we).we is we
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All language-neutral status tests passed")
|
||||
@@ -52,9 +52,13 @@ class _Cfg:
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
def set(self, value, save=True):
|
||||
self._value = value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
def __init__(self):
|
||||
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
@@ -301,6 +305,7 @@ def test_audio_export_page_send():
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||
assert d.audio_send_btn.isEnabled()
|
||||
assert d.transport_audio.isEnabled()
|
||||
assert d.kbps_combo.count() > 0
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
@@ -334,6 +339,7 @@ def test_audio_export_page_plugin_missing():
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.audio_send_btn.isEnabled()
|
||||
assert not d.transport_audio.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||
assert d.file_export_btn.isEnabled()
|
||||
@@ -350,6 +356,7 @@ def test_audio_import_page_build():
|
||||
assert d.kbps_combo.count() > 0
|
||||
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||
assert d.receive_btn.isEnabled()
|
||||
assert d.transport_audio.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
@@ -357,6 +364,7 @@ def test_audio_import_page_plugin_missing():
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.receive_btn.isEnabled()
|
||||
assert not d.transport_audio.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert d.qr_page is not None # QR import still usable
|
||||
d.close()
|
||||
|
||||
@@ -4,9 +4,10 @@ Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
|
||||
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
|
||||
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
|
||||
total mismatch reset, frame assembly -> decode). The wizard and the
|
||||
camera/audio paths need a live wallet/hardware and are exercised only
|
||||
through the shared frame-assembly path here.
|
||||
total mismatch reset, frame assembly -> decode). The QR transport now uses
|
||||
only the BBQR wire format. The wizard and the camera/audio paths need a live
|
||||
wallet/hardware and are exercised only through the shared frame-assembly
|
||||
path here.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||
@@ -14,6 +15,7 @@ Run:
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -23,7 +25,8 @@ from electrum.transaction import Transaction
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
|
||||
from bal.core.animated_qr import bbqr_frames
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
|
||||
from bal.core.will import WillItem
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
@@ -46,6 +49,15 @@ _VALID_TX_HEX = (
|
||||
)
|
||||
|
||||
|
||||
def _rand_hex(n):
|
||||
"""Incompressible lowercase hex string of ``n`` random bytes."""
|
||||
return os.urandom(n).hex()
|
||||
|
||||
|
||||
def _bbqr_frames(transfer, budget):
|
||||
return bbqr_frames(transfer.encode("utf-8"), budget, encoding="Z")
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
@@ -53,10 +65,14 @@ class _Cfg:
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
def set(self, value, save=True):
|
||||
self._value = value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
# Smallest QR preset: long transfers produce several frames.
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
def __init__(self):
|
||||
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
@@ -109,7 +125,7 @@ class StubWillItem:
|
||||
|
||||
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||
if payloads is None:
|
||||
payloads = ["T{}".format(i) * payload_len for i in range(n)]
|
||||
payloads = [_rand_hex(payload_len) for _ in range(n)]
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(p)
|
||||
for i, p in enumerate(payloads)
|
||||
@@ -129,8 +145,9 @@ def test_export_dialog_builds():
|
||||
assert page.tx_strings
|
||||
assert page.frames
|
||||
assert len(page.frames) >= 1
|
||||
# Frame 1 is shown.
|
||||
# Frame 1 is shown, and it is a BBQR frame.
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
assert page.frames[0].startswith("B$")
|
||||
assert "1" in page.progress_label.text()
|
||||
d.close()
|
||||
|
||||
@@ -196,6 +213,7 @@ def test_export_auto_scroll():
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.fps_spin is not None
|
||||
assert len(page.frames) > 1
|
||||
assert not page.auto_timer.isActive()
|
||||
|
||||
page.fps_spin.setValue(2)
|
||||
@@ -222,6 +240,7 @@ def test_export_auto_scroll_loop():
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.loop_check is not None
|
||||
assert len(page.frames) > 1
|
||||
|
||||
# Loop on: reaching the last code wraps back to the first and keeps going.
|
||||
page.loop_check.setChecked(True)
|
||||
@@ -244,9 +263,9 @@ def test_export_auto_scroll_loop():
|
||||
|
||||
def test_export_filter_valid_and_valid_nc():
|
||||
bw = FakeBalWindow()
|
||||
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem("B" * 120, statuses={"VALID": True})
|
||||
c = StubWillItem("C" * 120)
|
||||
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
|
||||
c = StubWillItem(_rand_hex(120))
|
||||
bw.willitems = {"a": a, "b": b, "c": c}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
@@ -263,11 +282,11 @@ def test_export_filter_valid_and_valid_nc():
|
||||
|
||||
# "Valid" filter -> only the valid items (a, b).
|
||||
d._on_filter_change(1)
|
||||
assert sorted(page.tx_strings) == ["A" * 120, "B" * 120]
|
||||
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)])
|
||||
|
||||
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||
d._on_filter_change(2)
|
||||
assert sorted(page.tx_strings) == ["B" * 120]
|
||||
assert sorted(page.tx_strings) == [str(b.tx)]
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
d.close()
|
||||
|
||||
@@ -276,7 +295,7 @@ def test_export_filter_empty_reverts():
|
||||
bw = FakeBalWindow()
|
||||
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||
bw.willitems = {
|
||||
"a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
messages = []
|
||||
@@ -290,17 +309,11 @@ def test_export_filter_empty_reverts():
|
||||
|
||||
|
||||
def test_export_navigation_and_chunk_change():
|
||||
# Low-redundancy serialized transactions resist deflate, so even the
|
||||
# compressed best-of transfer still needs several frames at the default
|
||||
# chunk and navigation across frames is exercised.
|
||||
def noisy(pad):
|
||||
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
|
||||
|
||||
# Incompressible serialized transactions resist deflate, so the BBQR
|
||||
# transfer still needs several frames at the default chunk and navigation
|
||||
# across frames is exercised.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(
|
||||
n=6, payload_len=400,
|
||||
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
|
||||
)
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
first_count = len(page.frames)
|
||||
@@ -322,6 +335,20 @@ def test_export_navigation_and_chunk_change():
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_size_change_is_remembered():
|
||||
# Changing the QR size inside the export dialog persists the choice so the
|
||||
# next export starts from the last selected size.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[0][1]
|
||||
|
||||
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[-1][1]
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -333,8 +360,8 @@ def test_import_frame_flow():
|
||||
assert not page.review_btn.isEnabled()
|
||||
assert not page.slot_area.isVisible()
|
||||
|
||||
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
|
||||
frames = split_frames(transfer, 150)
|
||||
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
|
||||
frames = _bbqr_frames(transfer, 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
for frame in frames:
|
||||
@@ -361,12 +388,14 @@ def test_import_assembles_and_decodes():
|
||||
captured["payload"] = payload
|
||||
|
||||
page._finish_import = fake_finish
|
||||
transfer = encode_transfer(["P" * 130, "Q" * 130])
|
||||
for frame in split_frames(transfer, 150):
|
||||
tx_a = _rand_hex(130)
|
||||
tx_b = _rand_hex(130)
|
||||
transfer = encode_transfer([tx_a, tx_b])
|
||||
for frame in _bbqr_frames(transfer, 150):
|
||||
page._add_frame(frame)
|
||||
page._review_and_sign()
|
||||
# The payload is rejoined into an opaque string for autodetect.
|
||||
assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
|
||||
assert captured["payload"].split("\n") == [tx_a, tx_b]
|
||||
d.close()
|
||||
|
||||
|
||||
@@ -407,7 +436,7 @@ def test_whole_will_qr_roundtrip():
|
||||
transfer = encode_transfer(page.tx_strings)
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
caught = {}
|
||||
@@ -431,8 +460,10 @@ def test_import_total_mismatch_resets():
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
frames_b = _bbqr_frames(
|
||||
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||
)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
@@ -451,7 +482,7 @@ def test_import_manual_entry():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
frames = split_frames(encode_transfer(["M" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150)
|
||||
page.manual_edit.setText(frames[0])
|
||||
page._add_from_manual()
|
||||
assert page.manual_edit.text() == ""
|
||||
@@ -477,48 +508,48 @@ def _fresh_debounce():
|
||||
def test_qr_import_debounce_pending_then_accept():
|
||||
s = _fresh_debounce()
|
||||
# First sighting of a new identity: pending, not yet stored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "pending"
|
||||
assert s["pending_count"] == 1
|
||||
assert s["last_index"] is None
|
||||
# A second stable read of the same identity: accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 1
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 0
|
||||
assert s["last_payload"] == "PAYLOAD1"
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted
|
||||
# The exporter is still showing frame 1: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore"
|
||||
assert s["last_index"] == 1
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
|
||||
# The exporter is still showing frame 0: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
|
||||
assert s["last_index"] == 0
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_transition_pending_resets():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0
|
||||
# A new identity interrupts the pending accumulation.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept"
|
||||
# Same-index duplicate with different payload is treated as new identity.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "ignore"
|
||||
|
||||
|
||||
def test_qr_import_debounce_total_mismatch_resets():
|
||||
s = _fresh_debounce()
|
||||
# In-range frame is accepted even though its declared total is ignored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept"
|
||||
# A frame that belongs to a different transfer.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset"
|
||||
# Once the policy is rebased on the new transfer, frames resume normally
|
||||
# (the widget clears the debounce while wiping the import).
|
||||
s["key"] = None
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending"
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_autofinish():
|
||||
@@ -528,7 +559,7 @@ def test_qr_import_handle_scanned_text_autofinish():
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
# The camera session is running and every frame needs two stable reads.
|
||||
@@ -557,7 +588,7 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
assert len(frames) > 1
|
||||
assert not page._scanning
|
||||
for frame in frames:
|
||||
@@ -570,54 +601,16 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
|
||||
# BBQR import/export round trips
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_format_combo_switches_codecs():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.format == "balqr"
|
||||
assert page.frames[0].startswith("BAL1")
|
||||
assert page.format_combo.count() == 4
|
||||
|
||||
page._on_format_change(1) # BC-UR v1
|
||||
assert page.format == "ur1"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
assert page.index == 0
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
|
||||
page._on_format_change(2) # BC-UR v2
|
||||
assert page.format == "ur2"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
|
||||
page._on_format_change(3) # BBQR
|
||||
assert page.format == "bbqr"
|
||||
assert page.frames[0].startswith("B$")
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_animated_format_frames_fit_budget():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
for index in range(1, 4):
|
||||
page._on_format_change(index)
|
||||
for frame in page.frames:
|
||||
assert len(frame) <= dialogs.CHUNK_PRESETS[0][1]
|
||||
d.close()
|
||||
|
||||
|
||||
def _import_roundtrip_fmt(fmt_index):
|
||||
def test_import_bbqr_roundtrip():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(fmt_index)
|
||||
frames = list(page.frames)
|
||||
assert frames
|
||||
assert frames and all(f.startswith("B$") for f in frames)
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
@@ -634,24 +627,11 @@ def _import_roundtrip_fmt(fmt_index):
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_ur1_roundtrip():
|
||||
_import_roundtrip_fmt(1)
|
||||
|
||||
|
||||
def test_import_ur2_roundtrip():
|
||||
_import_roundtrip_fmt(2)
|
||||
|
||||
|
||||
def test_import_bbqr_roundtrip():
|
||||
_import_roundtrip_fmt(3)
|
||||
|
||||
|
||||
def test_import_animated_scan_debounce_autofinish():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(2) # BC-UR v2 fountain
|
||||
frames = list(page.frames)
|
||||
d.close()
|
||||
|
||||
@@ -664,11 +644,8 @@ def test_import_animated_scan_debounce_autofinish():
|
||||
for _rep in range(2):
|
||||
import_page._handle_scanned_text(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one
|
||||
# redundant mixed wave).
|
||||
assert import_page.total == len(frames) // 2
|
||||
assert import_page.total == len(frames)
|
||||
assert len(import_page.frames) >= import_page.total
|
||||
assert import_page.review_btn.isEnabled()
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
assert not import_page._scanning
|
||||
@@ -679,7 +656,7 @@ def test_import_garbage_scan_is_ignored():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
for garbage in ("hello world", "12345", "B$ZZ", ""):
|
||||
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""):
|
||||
page._handle_scanned_text(garbage)
|
||||
assert not page.frames
|
||||
assert page.total == 0
|
||||
@@ -694,8 +671,10 @@ def test_import_different_animated_transfer_resets():
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
frames_b = _bbqr_frames(
|
||||
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||
)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
|
||||
Reference in New Issue
Block a user