5 Commits

Author SHA1 Message Date
a81443ff67 i18n review fixes (#63)
Truman's i18n review: release gate runs the i18n, CLI and import tests and checks Babel first; ruff known-first-party = bal; no translated text stored in the wallet (executor push records are copies, status history always saved as 'New', old 'Nuovo'/'Nuevo' mapped back on load).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01HxDDvLym1NEbFNYMc1rr7S
2026-10-07 15:03:25 +02:00
827a1d1e54 chore: gitignore the Android companion app 2026-10-05 17:53:20 -04:00
d6fd20ad9c chore: remove the Android companion app from the repo
Keep it only locally (now gitignored); the plugin repo tracks only the
Electrum plugin sources.
2026-10-05 17:45:17 -04:00
5ec2b79d03 core+gui: BBQR-only QR transfer, disable Audio when modem plugin unavailable
- Drop BALQR / BC-UR v1 / v2 wire formats, keep BBQR only (animated_qr,
  qrtransfer, dialogs); remove the settings "QR Code Size" selector and
  persist the export-dialog size choice instead.
- Disable the Audio transport radio button in the import/export dialogs
  when the audio_modem plugin (or its amodem dependency) is unavailable.
- Refresh translation catalogs (bal.pot, es_ES, it_IT).
2026-10-03 13:00:23 -04:00
47b120f526 i18n: add Spanish (es_ES) translation 2026-10-03 11:48:14 -04:00
59 changed files with 3126 additions and 6631 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ tests/karen7
# Compiled translations: build_zip.py rebuilds them from the .po sources # Compiled translations: build_zip.py rebuilds them from the .po sources
*.mo *.mo
# Android companion app: kept locally only, not part of the plugin repo
android/

View File

@@ -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 - `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` - `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth. version there, never invent a new source of truth.

View File

@@ -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`. `tests/test_core_plugin_base.py`, `tests/test_core_input_rules.py`.
**Outcome:** DONE. **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).

View File

@@ -25,22 +25,15 @@ and 4.8.0).
## QR wire-format compatibility ## QR wire-format compatibility
BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's BAL exports/imports wills as QR codes using **BBQR**:
own frame format and is only understood by BAL itself. The export page also
supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
| Format | Wire appearance | Interop target | | 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 | | 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 Import auto-detects the format of each scanned code; out-of-order and duplicate
and (for UR v2) partially-lost fountain frames are handled. Interop is BBQR frames are handled. Interop has not yet been cross-verified against
validation-tested against the reference C++ bc-ur encoder output and the third-party libraries (`bbqr`, Coldcard firmwares).
BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against
third-party libraries (`ur`, `bbqr`, Coldcard firmwares).
## Reporting compatibility issues ## Reporting compatibility issues

View File

@@ -421,50 +421,29 @@ See Section 5 for details.
the audio path — buttons only appear when the `audio_modem` plugin + the audio path — buttons only appear when the `audio_modem` plugin +
`amodem` are installed; see the prerequisites below). `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** - `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
(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**
(Coinkite `B$…` base32/hex/zlib frames), plus one shared (Coinkite `B$…` base32/hex/zlib frames), plus one shared
`AnimatedQrSession` with `detect_format` auto-detection and `AnimatedQrSession` with `detect_format` auto-detection and
`parse_for_detection` frame identity for the GUI debounce. `parse_for_detection` frame identity for the GUI debounce. The previously
- Current status as of this session: reference parity, GUI, and tests done; supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
not yet committed. removed; only BBQR remains.
- **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005 - **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
test vectors (`Hello, world`, `Hello world`, the long seed vector); duplicate drops.
bytewords-minimal round-trips with CRC rejection; UR v2 part encode + - **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
decode is byte-exact against the reference C++ bc-ur encoder for a `TransferConflictError` on a frame from a different transfer,
single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes), `SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
validating CBOR framing, bytewords, alias+ary-threshold sampling, - **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
xoshiro256** and the XOR mix. the importer routes every frame through `detect_format` +
- **Sessions:** UR v2 single-part (no seq header), out-of-order frames, `AnimatedQrSession.add_part` with the shared
duplicate drops, solve with a missing pure fragment (a second redundant `qr_import_accept_frame(state, fmt, session_key, frame_total, index,
mixed wave is emitted by `ur2_frames`), UR v1 single-part payload, stable_reads=2)` debounce (reset on session-key change).
(digest-less `ur:bytes/<bc32>` accepted) and multipart, BBQR full-frame `_review_and_sign` resolves the session to the transfer text and decodes
decode in any order for Z/2/H encodings. parts uniformly.
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`, - **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
`TransferConflictError` on a frame from a different transfer, encoding fallback, zlib-bomb guard, detection) and the extended
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only. `tests/test_gui_qr_transfer.py` pass.
- **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.
**Dev-box audio prerequisites (audio_modem channel):** **Dev-box audio prerequisites (audio_modem channel):**
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the

View File

@@ -17,7 +17,7 @@
**In scope** **In scope**
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`). - 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) **Out of scope** (owner's decisions, or by design)
- **BAL Easy Heirs**: excluded by the owner. - **BAL Easy Heirs**: excluded by the owner.

View File

@@ -26,8 +26,8 @@ bal/ the installable Electrum plugin package
│ ├── willexecutors.py │ ├── willexecutors.py
│ ├── checkalive.py │ ├── checkalive.py
│ ├── reminders.py │ ├── reminders.py
│ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler │ ├── qrtransfer.py transfer-string codec / QR size presets
│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (stdlib-only) │ ├── animated_qr.py BBQR codec (stdlib-only)
│ └── input_rules.py │ └── input_rules.py
├── cli/ headless command-line layer (no Qt) ├── cli/ headless command-line layer (no Qt)
│ ├── commands.py bal_* daemon commands (@plugin_command) │ ├── 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 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 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 export offers All / Valid / Valid-NC filters plus a QR size preset
(150–1800 bytes/frame) and ships the default **BAL QR** format already (150–1800 bytes/frame) and ships the **BBQR** format; the import
compressed whenever that is smaller (best-of zlib, flag per frame); the import
flow reviews and sign each transaction flow reviews and sign each transaction
one at a time, then proposes exporting the signed transactions. When one at a time, then proposes exporting the signed transactions. When
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` + Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
PortAudio) Send/Receive audio buttons complement the QR channel. See 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 The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard)
also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2** animated-QR sequences. The importer auto-detects each code it sees, so a BAL
(`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by export can be imported by any tool that understands BBQR. BBQR frames may
BitKit) animated-QR sequences. The importer auto-detects the format of each arrive in any order, and duplicates are ignored. The 32 MB message limit
code it sees, so any of the four formats can be imported on a BAL device, and (zlib-bomb guard) bounds untrusted scanner input.
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.
## Command-line / headless usage ## Command-line / headless usage

10
android/.gitignore vendored
View File

@@ -1,10 +0,0 @@
.gradle/
build/
local.properties
.idea/
*.apk
*.aab
captures/
.externalNativeBuild/
.cxx/
*.hprof

View File

@@ -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.

View File

@@ -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")
}

View File

@@ -1,5 +0,0 @@
# Chaquopy Python runtime.
-keep class com.chaquo.python.** { *; }
# ML Kit barcode scanning.
-keep class com.google.mlkit.** { *; }

View File

@@ -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>

View File

@@ -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())
}
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -1 +0,0 @@
"""Android reader helpers built on the bundled plugin codecs."""

View File

@@ -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": []})

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
<resources>
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>

View File

@@ -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
}

View File

@@ -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

Binary file not shown.

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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:]))

View File

@@ -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:]))

View File

@@ -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")

View File

@@ -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())

View File

@@ -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:])

View File

@@ -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"
}
}

View File

@@ -385,7 +385,9 @@ class BalController:
tx["heirsvalue"] = txs[txid].heirsvalue tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor) 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["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs) tx["heirs"] = copy_structure(txs[txid].heirs)

View File

@@ -2,33 +2,18 @@
bal.core.animated_qr bal.core.animated_qr
==================== ====================
GUI-free implementation of the interoperable animated-QR transfer formats GUI-free implementation of the BBQR animated-QR transfer format used to
used to move BAL will data between devices. move BAL will data between devices.
Supported wire formats (each self-describing and order-independent on Supported wire format (self-describing and order-independent on receive):
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):: * **BBQR** (Coinkite)::
B$<encoding><type><2 base36 total><2 base36 index><payload> B$<encoding><type><2 base36 total><2 base36 index><payload>
Equal-length text frames; the payload is uppercase hex, RFC-4648 Equal-length text frames; the payload is uppercase hex, RFC-4648
base32, or raw-deflate (``wbits=-10``) zlib plus base32. base32, or raw-deflate (``wbits=-10``) zlib plus base32.
Everything is implemented from scratch on top of the Python standard library 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). self-contained bundle with no third-party dependencies (house rule).
This module never imports Qt or any Electrum GUI code (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 from __future__ import annotations
import base64 import base64
import hashlib
import zlib import zlib
from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple from typing import Dict, List, Optional, Sequence, Tuple
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Errors & safety caps # Errors & safety caps
@@ -62,604 +46,10 @@ class SessionLimitError(AnimatedQrError):
"""A receive session exceeded its safety caps.""" """A receive session exceeded its safety caps."""
class ChecksumError(AnimatedQrError):
"""A part failed its checksum / digest validation."""
# Safety caps for untrusted scanner input. # Safety caps for untrusted scanner input.
_MAX_SESSION_PARTS = 20000 _MAX_SESSION_PARTS = 20000
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 _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) # BBQR (Coinkite)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -774,9 +164,6 @@ def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
FORMAT_LABELS = { FORMAT_LABELS = {
"balqr": "BAL QR",
"ur1": "BC-UR v1",
"ur2": "BC-UR v2",
"bbqr": "BBQR", "bbqr": "BBQR",
} }
@@ -791,34 +178,8 @@ def detect_format(text: str) -> Optional[str]:
text = text.strip() text = text.strip()
if not text: if not text:
return None return None
lowered = text.lower()
if lowered.startswith(("balqr", "bal1")):
return "balqr"
if text.startswith(_BBQR_PREFIX): if text.startswith(_BBQR_PREFIX):
return "bbqr" 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 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. :class:`AnimatedQrError` when the text cannot be parsed.
""" """
fmt = detect_format(text) 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": if fmt == "bbqr":
encoding, type_code, total, index, _payload = bbqr_parse_part(text) encoding, type_code, total, index, _payload = bbqr_parse_part(text)
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
raise FormatNotDetectedError("Not a supported QR transfer format") 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: class _BbqrSession:
def __init__(self): def __init__(self):
self._total = 0 self._total = 0
@@ -1126,8 +250,6 @@ def _transfer_text(raw: bytes) -> str:
class AnimatedQrSession: class AnimatedQrSession:
"""Facade over the per-format receive sessions used by the QR import page.""" """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): def __init__(self):
self._inner: Optional[object] = None self._inner: Optional[object] = None
self._fmt: Optional[str] = None self._fmt: Optional[str] = None
@@ -1152,12 +274,6 @@ class AnimatedQrSession:
@staticmethod @staticmethod
def _make(fmt: str) -> object: def _make(fmt: str) -> object:
if fmt == "balqr":
return _BalQrSession()
if fmt == "ur1":
return _Ur1Session()
if fmt == "ur2":
return _Ur2Session()
if fmt == "bbqr": if fmt == "bbqr":
return _BbqrSession() return _BbqrSession()
raise AssertionError("unknown animated-QR format {}".format(fmt)) raise AssertionError("unknown animated-QR format {}".format(fmt))

View File

@@ -305,11 +305,10 @@ class BalPlugin(BasePlugin):
# stay display-only outside the wizard unless the user opts in. # stay display-only outside the wizard unless the user opts in.
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used # QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
# per QR frame when exporting/importing a will through the QR channel. # used per QR frame when exporting/importing a will through the QR
# The settings dialog offers the 4 standard presets of # channel. The export dialog seeds its own size selector from this
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget. # value (default 150, small QR, low-resolution cameras).
# Default 150 (small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the

View File

@@ -9,31 +9,11 @@ Scope
----- -----
* converts will transactions into a compact ``transfer_string`` * converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64 (newline-joined serialized transactions, optionally zlib + base64
compressed); compressed). The transfer string is the payload carried by BBQR QR frames
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for (``bal.core.animated_qr``).
multi-QR export, and reassembles/validates them on import.
Wire format (v2, compact) The audio-modem channel carries the whole transfer string in a single blob
------------------------- (PLAN_QR_TRANSFER.md section 4.4), so callers only use
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`. :func:`encode_transfer` / :func:`decode_transfer`.
This module never imports Qt or any Electrum GUI code (house rule). This module never imports Qt or any Electrum GUI code (house rule).
@@ -44,12 +24,7 @@ from __future__ import annotations
import base64 import base64
import zlib import zlib
MAGIC = "BALQR" # 4 standard presets (label, payload budget per QR). Ordered from
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). # low-resolution cameras to high-resolution cameras (owner decision D5).
CHUNK_PRESETS = ( CHUNK_PRESETS = (
("Small - ~150 bytes/QR (low-res cameras)", 150), ("Small - ~150 bytes/QR (low-res cameras)", 150),
@@ -58,61 +33,16 @@ CHUNK_PRESETS = (
("XL - ~1800 bytes/QR (high-res cameras)", 1800), ("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): def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string. """Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so ``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned. The optional flag the whole bundle shrinks before being printed/scanned.
of the frame header lets the importer reverse this automatically.
""" """
return __compress("\n".join(tx_strings), enabled=compress) 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): def decode_transfer(transfer_string, compressed):
"""Inverse of :func:`encode_transfer`. """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] 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): def preset_index_for_chunk_size(chunk_size):
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size.""" """Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1]) best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
@@ -215,90 +78,3 @@ def __decompress(text, *, enabled):
if not enabled: if not enabled:
return text return text
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") 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

View File

@@ -1364,6 +1364,37 @@ class WillItem(Logger):
# format_status_history() still translates them. # format_status_history() still translates them.
LEGACY_STATUS_LABELS = (N_("New"),) 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): def set_status(self, status, value=True):
"""Set a status flag and apply the related side effects. """Set a status flag and apply the related side effects.
@@ -1445,8 +1476,10 @@ class WillItem(Logger):
w = data w = data
self.tx = Will.get_tx_from_any(w["tx"]) self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None) self.heirs = w.get("heirs", None)
self.we = w.get("willexecutor", None) self.we = WillItem._without_transient_push_fields(
self.status = w.get("status") or "" w.get("willexecutor", None)
)
self.status = WillItem._untranslate_new_status(w.get("status") or "")
self.description = w.get("description", None) self.description = w.get("description", None)
self.time = w.get("time", None) self.time = w.get("time", None)
self.change = w.get("change", None) self.change = w.get("change", None)

View File

@@ -231,6 +231,13 @@ class Willexecutors:
@staticmethod @staticmethod
def get_willexecutor_transactions(will, force=False): 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 = {} willexecutors = {}
for wid, willitem in will.items(): for wid, willitem in will.items():
if willitem.get_status("VALID"): if willitem.get_status("VALID"):
@@ -240,10 +247,18 @@ class Willexecutors:
url = willexecutor["url"] url = willexecutor["url"]
if willexecutor and Willexecutors.is_selected(willexecutor): if willexecutor and Willexecutors.is_selected(willexecutor):
if url not in willexecutors: if url not in willexecutors:
willexecutor["txs"] = "" # Work on a COPY of the record: the item's
willexecutor["txsids"] = [] # own ``we`` dict is saved in the wallet, so
willexecutor["broadcast_status"] = _("Waiting...") # the transient push fields below (raw txs,
willexecutors[url] = willexecutor # 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]["txs"] += str(willitem.tx) + "\n"
willexecutors[url]["txsids"].append(wid) willexecutors[url]["txsids"].append(wid)

View File

@@ -43,8 +43,6 @@ from ...core.animated_qr import (
bbqr_frames, bbqr_frames,
format_name, format_name,
parse_for_detection, parse_for_detection,
ur1_frames,
ur2_frames,
) )
from ...core.checkalive import ( from ...core.checkalive import (
CheckAliveAfterDeliveryError, CheckAliveAfterDeliveryError,
@@ -54,13 +52,9 @@ from ...core.checkalive import (
) )
from ...core.qrtransfer import ( from ...core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MissingFramesError,
QrTransferError,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
encode_transfer_best,
preset_index_for_chunk_size, preset_index_for_chunk_size,
split_frames,
) )
from ...core.reminders import build_ics_reminders from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton from .calendar import BalCalendarButton
@@ -147,29 +141,6 @@ if TYPE_CHECKING:
# imported lazily where needed to avoid a dialogs<->lists import cycle. # 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): class BalDialog(QDialog,MessageBoxMixin):
_stopping = False _stopping = False
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): 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`. 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) QWidget.__init__(self, parent)
self.chunk_size = chunk_size self.chunk_size = chunk_size
self.format = "balqr" self._on_size_change = on_size_change
self.tx_strings = [] self.tx_strings = []
self.transfer = "" self.transfer = ""
self.frames = [] self.frames = []
@@ -2833,36 +2804,11 @@ class BalQrExportWidget(QWidget):
size_row.addStretch(1) size_row.addStretch(1)
vbox.addLayout(size_row) 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): def set_tx_strings(self, tx_strings):
"""Rebuild the transfer frames from the given serialized transactions.""" """Rebuild the transfer frames from the given serialized transactions."""
self.tx_strings = list(tx_strings) self.tx_strings = list(tx_strings)
self._stop_auto() self._stop_auto()
self.transfer = encode_transfer(self.tx_strings, compress=False) 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._refresh_frames()
self._update_intro() self._update_intro()
self._render() self._render()
@@ -2872,30 +2818,18 @@ class BalQrExportWidget(QWidget):
return len(self.frames) return len(self.frames)
def _refresh_frames(self): def _refresh_frames(self):
if self.format == "balqr": self.frames = bbqr_frames(
self.frames = split_frames( self.transfer.encode("utf-8"), self.chunk_size, encoding="Z"
self._balqr_transfer, )
self.chunk_size,
compressed=self._balqr_compressed,
)
else:
self.frames = encode_animated_frames(
self.transfer, self.format, self.chunk_size
)
self.index = 0 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): def _on_chunk_change(self, index):
self._stop_auto() self._stop_auto()
self.chunk_size = CHUNK_PRESETS[index][1] self.chunk_size = CHUNK_PRESETS[index][1]
self._refresh_frames() self._refresh_frames()
self._render() self._render()
if self._on_size_change is not None:
self._on_size_change(self.chunk_size)
def _toggle_auto(self): def _toggle_auto(self):
"""Start/stop the automatic QR slideshow.""" """Start/stop the automatic QR slideshow."""
@@ -2927,32 +2861,13 @@ class BalQrExportWidget(QWidget):
self._next() self._next()
def _update_intro(self): def _update_intro(self):
self.format_hint.setText( self.intro_label.setText(
{ _(
"balqr": _( "Scan the QR codes below with the will-opening device.\n"
"Proprietary format; every other BAL wallet understands it." "{} codes carry the whole transfer (any order works, "
), "duplicates are ignored)."
"ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older " ).format(self.total_frames)
"Blockchain Commons tools."),
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
}[self.format]
) )
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): def _prev(self):
if self.index > 0: if self.index > 0:
@@ -2994,6 +2909,7 @@ class WillExportDialog(BalDialog):
def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"): def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"):
BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will")) BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will"))
self.bal_window = bal_window self.bal_window = bal_window
self.bal_plugin = bal_plugin
self._source = will if will is not None else bal_window.willitems self._source = will if will is not None else bal_window.willitems
self._filters = export_filter_options() self._filters = export_filter_options()
self._filter_index = 0 self._filter_index = 0
@@ -3047,7 +2963,10 @@ class WillExportDialog(BalDialog):
self.stacked = QStackedWidget() self.stacked = QStackedWidget()
self.file_page = self._build_file_page() self.file_page = self._build_file_page()
self.stacked.addWidget(self.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.stacked.addWidget(self.qr_page)
self.audio_page = self._build_audio_page() self.audio_page = self._build_audio_page()
self.stacked.addWidget(self.audio_page) self.stacked.addWidget(self.audio_page)
@@ -3057,12 +2976,19 @@ class WillExportDialog(BalDialog):
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) 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 = { mode_ids = {
"file": self.MODE_FILE, "file": self.MODE_FILE,
"qr": self.MODE_QR, "qr": self.MODE_QR,
"audio": self.MODE_AUDIO, "audio": self.MODE_AUDIO,
} }
mode = mode_ids.get(initial_mode, self.MODE_FILE) 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)[ (self.transport_file, self.transport_qr, self.transport_audio)[
mode mode
].setChecked(True) ].setChecked(True)
@@ -3097,6 +3023,7 @@ class WillExportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin() self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates() self.bitrates = audio_bitrates()
available = self._audio_plugin is not None available = self._audio_plugin is not None
self._audio_available = available
if available: if available:
self.audio_warn_label.hide() self.audio_warn_label.hide()
else: else:
@@ -3125,6 +3052,14 @@ class WillExportDialog(BalDialog):
def _selected_items(self): def _selected_items(self):
return filter_willitems(self._source, self._filters, self._filter_index) 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): def _payload_strings(self):
"""The data handed to the QR page, honouring the content scope. """The data handed to the QR page, honouring the content scope.
@@ -3296,6 +3231,10 @@ class WillImportDialog(BalDialog):
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) 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.transport_file.setChecked(True)
self._on_mode_clicked(self.MODE_FILE) 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._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates() self.bitrates = audio_bitrates()
available = self._audio_plugin is not None available = self._audio_plugin is not None
self._audio_available = available
if available: if available:
self.audio_warn_label.hide() self.audio_warn_label.hide()
else: else:
@@ -3563,9 +3503,9 @@ def qr_import_accept_frame(
seen ``stable_reads`` times in a row -- this mirrors the export-side seen ``stable_reads`` times in a row -- this mirrors the export-side
slideshow pacing and swallows transition artifacts; slideshow pacing and swallows transition artifacts;
* re-reading the currently accepted frame is ``ignore``d; * re-reading the currently accepted frame is ``ignore``d;
* a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or * a frame whose ``session_key`` (transfer identity, e.g. ``"bbqr:Z:B:3"``)
``"ur2:2-31-3804692811"``) contradicts the transfer already being built contradicts the transfer already being built is a ``reset`` (a
is a ``reset`` (a different transfer was presented). different transfer was presented).
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``, ``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``. ``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
@@ -3751,8 +3691,8 @@ class BalQrImportWidget(QWidget):
self._key = key self._key = key
self._init_slots() self._init_slots()
if status == "ok": if status == "ok":
# Slot grid is always 1-based even for 0-based wire formats. # Slot grid is always 1-based even for the 0-based BBQR indexes.
slot = index + 1 if fmt == "bbqr" else index slot = index + 1
self.frames[slot] = frame_text self.frames[slot] = frame_text
self._update_slots() self._update_slots()
self._update_status() self._update_status()
@@ -4008,7 +3948,7 @@ class BalQrImportWidget(QWidget):
try: try:
transfer, compressed = session.resolve() transfer, compressed = session.resolve()
parts = decode_transfer(transfer, compressed) parts = decode_transfer(transfer, compressed)
except (MissingFramesError, QrTransferError, AnimatedQrError) as e: except AnimatedQrError as e:
self.show_error(str(e)) self.show_error(str(e))
self._reset_all() self._reset_all()
return return

View File

@@ -19,7 +19,6 @@ from electrum.plugin import hook
from electrum.util import EventListener, event_listener from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout from PyQt6.QtWidgets import QLayout
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from ...i18n import init_from_config from ...i18n import init_from_config
from .common import ( from .common import (
BalPlugin, BalPlugin,
@@ -538,22 +537,6 @@ class Plugin(BalPlugin, EventListener):
# users (BASIC and ADVANCED). # users (BASIC and ADVANCED).
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD) 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 # USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
# (not a free-text field) bound to the USER_TYPE config: # (not a free-text field) bound to the USER_TYPE config:
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT) # index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
@@ -670,10 +653,6 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 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) btn.clicked.connect(reset)
return btn return btn
@@ -932,25 +911,6 @@ class Plugin(BalPlugin, EventListener):
) )
grid.addWidget(reset_btn_auto_rebuild, 15, 3) 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 # # Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS # # 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.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"), (self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"), (self.AUTO_REBUILD, heir_auto_rebuild, "check"),
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
] ]
for cfg, widget, kind in resets: for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config. # Persist the default value back into the Electrum config.
@@ -1005,10 +964,6 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 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 # Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must # reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again. # follow the (default) checkbox state again.

View File

@@ -551,7 +551,9 @@ class BalWindow:
tx["heirsvalue"] = txs[txid].heirsvalue tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor) 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["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs) tx["heirs"] = copy_structure(txs[txid].heirs)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
# ./make-release.sh # read version from bal/manifest.json # ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release # ./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) # Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars # Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
@@ -33,6 +33,12 @@ else
NEW_VER="" NEW_VER=""
fi 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 "=== Release ${TAG} ==="
echo "Current manifest version: ${CURRENT_VER}" echo "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_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_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_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.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 -q 2>&1; then
echo "All tests passed." echo "All tests passed."
else else

View File

@@ -6,6 +6,12 @@ target-version = "py312"
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py) select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
ignore = ["E501"] 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] [tool.ruff.lint.pep8-naming]
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls

View File

@@ -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 Validates the self-contained BBQR codec against round trips, out-of-order
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact assembly, compression default/fallback, malformed-input rejection and the
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/ format detection / parse_for_detection helpers.
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
fountain solving and malformed-input rejection for all four formats.
Run: Run:
source electrum/env/bin/activate source electrum/env/bin/activate
@@ -22,328 +20,6 @@ import random
from bal.core import animated_qr as aq 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 # BBQR
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -424,41 +100,22 @@ def test_bbqr_part_number_limits():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_detect_format_recognises_all_formats(): def test_detect_format_recognises_bbqr():
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"
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "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(): 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 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(): def test_parse_for_detection_keys():
bal = aq.parse_for_detection("BALQR1|3|2||payload") bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
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])
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2] assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
def test_format_names_exist(): def test_format_names_exist():
for fmt in ("balqr", "ur1", "ur2", "bbqr"): assert aq.format_name("bbqr")
assert aq.format_name(fmt)
assert aq.format_name("nope") == "nope" assert aq.format_name("nope") == "nope"

View File

@@ -1,10 +1,8 @@
""" """
Tests for ``bal.core.qrtransfer``. Tests for ``bal.core.qrtransfer``.
Covers the BALQR frame encoding used for will transfer via QR codes / Covers the transfer-string encoding used for will transfer via BBQR QR codes
audio modem: encoding, framing, reassembly, malformed input and the preset / audio modem: encoding, decoding and the preset list.
list (optionally cross-checked against the ``qrcode`` library's EC-M
capacity when it is installed).
Run: Run:
source electrum/env/bin/activate 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 ( from bal.core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MIN_CHUNK_SIZE,
InconsistentTotalError,
MissingFramesError,
QrTransferError,
assemble,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
parse_frame,
preset_index_for_chunk_size, 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 # Round trips
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -67,300 +43,6 @@ def test_empty_list_roundtrip():
assert decode_transfer(encode_transfer([]), compressed=False) == [] 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 # Constants / presets
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -379,46 +61,6 @@ def test_preset_index_for_chunk_size():
assert preset_index_for_chunk_size(1800) == 3 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__": if __name__ == "__main__":
import traceback import traceback

View 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")

View File

@@ -52,9 +52,13 @@ class _Cfg:
def get(self): def get(self):
return self._value return self._value
def set(self, value, save=True):
self._value = value
class FakePlugin: 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): def read_file(self, path):
return _PNG_BYTES return _PNG_BYTES
@@ -301,6 +305,7 @@ def test_audio_export_page_send():
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.audio_send_btn.text() == dialogs._("Send") assert d.audio_send_btn.text() == dialogs._("Send")
assert d.audio_send_btn.isEnabled() assert d.audio_send_btn.isEnabled()
assert d.transport_audio.isEnabled()
assert d.kbps_combo.count() > 0 assert d.kbps_combo.count() > 0
kbps = int(d.kbps_combo.currentText()) kbps = int(d.kbps_combo.currentText())
@@ -334,6 +339,7 @@ def test_audio_export_page_plugin_missing():
bw.willitems = _make_willitems(2) bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.audio_send_btn.isEnabled() assert not d.audio_send_btn.isEnabled()
assert not d.transport_audio.isEnabled()
assert "not available" in d.audio_warn_label.text() assert "not available" in d.audio_warn_label.text()
assert len(d.qr_page.frames) >= 1 # QR still usable assert len(d.qr_page.frames) >= 1 # QR still usable
assert d.file_export_btn.isEnabled() assert d.file_export_btn.isEnabled()
@@ -350,6 +356,7 @@ def test_audio_import_page_build():
assert d.kbps_combo.count() > 0 assert d.kbps_combo.count() > 0
assert d.receive_btn.text() == dialogs._("Receive by audio…") assert d.receive_btn.text() == dialogs._("Receive by audio…")
assert d.receive_btn.isEnabled() assert d.receive_btn.isEnabled()
assert d.transport_audio.isEnabled()
d.close() d.close()
@@ -357,6 +364,7 @@ def test_audio_import_page_plugin_missing():
bw = FakeBalWindow(audio_plugin=None) bw = FakeBalWindow(audio_plugin=None)
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.receive_btn.isEnabled() assert not d.receive_btn.isEnabled()
assert not d.transport_audio.isEnabled()
assert "not available" in d.audio_warn_label.text() assert "not available" in d.audio_warn_label.text()
assert d.qr_page is not None # QR import still usable assert d.qr_page is not None # QR import still usable
d.close() d.close()

View File

@@ -4,9 +4,10 @@ Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
Covers the unified ``WillExportDialog`` (transport radios, stacked pages, Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
QR build/navigation, chunk-size / autoplay, filter revert) and the unified QR build/navigation, chunk-size / autoplay, filter revert) and the unified
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling, ``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
total mismatch reset, frame assembly -> decode). The wizard and the total mismatch reset, frame assembly -> decode). The QR transport now uses
camera/audio paths need a live wallet/hardware and are exercised only only the BBQR wire format. The wizard and the camera/audio paths need a live
through the shared frame-assembly path here. wallet/hardware and are exercised only through the shared frame-assembly
path here.
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
@@ -14,6 +15,7 @@ Run:
import base64 import base64
import json import json
import os
import sys import sys
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -23,7 +25,8 @@ from electrum.transaction import Transaction
from PyQt6.QtWidgets import QApplication, QMainWindow from PyQt6.QtWidgets import QApplication, QMainWindow
import bal.gui.qt.dialogs as dialogs 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 from bal.core.will import WillItem
_app = QApplication.instance() or QApplication(sys.argv) _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: class _Cfg:
def __init__(self, value): def __init__(self, value):
self._value = value self._value = value
@@ -53,10 +65,14 @@ class _Cfg:
def get(self): def get(self):
return self._value return self._value
def set(self, value, save=True):
self._value = value
class FakePlugin: class FakePlugin:
# Smallest QR preset: long transfers produce several frames. # 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): def read_file(self, path):
return _PNG_BYTES return _PNG_BYTES
@@ -109,7 +125,7 @@ class StubWillItem:
def _make_willitems(n=3, payload_len=120, payloads=None): def _make_willitems(n=3, payload_len=120, payloads=None):
if payloads is 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 { return {
"item{}".format(i): StubWillItem(p) "item{}".format(i): StubWillItem(p)
for i, p in enumerate(payloads) for i, p in enumerate(payloads)
@@ -129,8 +145,9 @@ def test_export_dialog_builds():
assert page.tx_strings assert page.tx_strings
assert page.frames assert page.frames
assert len(page.frames) >= 1 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.qr_view.text == page.frames[0]
assert page.frames[0].startswith("B$")
assert "1" in page.progress_label.text() assert "1" in page.progress_label.text()
d.close() d.close()
@@ -196,6 +213,7 @@ def test_export_auto_scroll():
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
assert page.fps_spin is not None assert page.fps_spin is not None
assert len(page.frames) > 1
assert not page.auto_timer.isActive() assert not page.auto_timer.isActive()
page.fps_spin.setValue(2) 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") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
assert page.loop_check is not None 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. # Loop on: reaching the last code wraps back to the first and keeps going.
page.loop_check.setChecked(True) page.loop_check.setChecked(True)
@@ -244,9 +263,9 @@ def test_export_auto_scroll_loop():
def test_export_filter_valid_and_valid_nc(): def test_export_filter_valid_and_valid_nc():
bw = FakeBalWindow() bw = FakeBalWindow()
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
b = StubWillItem("B" * 120, statuses={"VALID": True}) b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
c = StubWillItem("C" * 120) c = StubWillItem(_rand_hex(120))
bw.willitems = {"a": a, "b": b, "c": c} bw.willitems = {"a": a, "b": b, "c": c}
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
@@ -263,11 +282,11 @@ def test_export_filter_valid_and_valid_nc():
# "Valid" filter -> only the valid items (a, b). # "Valid" filter -> only the valid items (a, b).
d._on_filter_change(1) 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). # "Valid NC" filter -> only the valid, not-complete item (b).
d._on_filter_change(2) 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] assert page.qr_view.text == page.frames[0]
d.close() d.close()
@@ -276,7 +295,7 @@ def test_export_filter_empty_reverts():
bw = FakeBalWindow() bw = FakeBalWindow()
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert. # Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
bw.willitems = { 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") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
messages = [] messages = []
@@ -290,17 +309,11 @@ def test_export_filter_empty_reverts():
def test_export_navigation_and_chunk_change(): def test_export_navigation_and_chunk_change():
# Low-redundancy serialized transactions resist deflate, so even the # Incompressible serialized transactions resist deflate, so the BBQR
# compressed best-of transfer still needs several frames at the default # transfer still needs several frames at the default chunk and navigation
# chunk and navigation across frames is exercised. # across frames is exercised.
def noisy(pad):
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
bw = FakeBalWindow() bw = FakeBalWindow()
bw.willitems = _make_willitems( bw.willitems = _make_willitems(n=6, payload_len=400)
n=6, payload_len=400,
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
first_count = len(page.frames) first_count = len(page.frames)
@@ -322,6 +335,20 @@ def test_export_navigation_and_chunk_change():
d.close() 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) # 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.review_btn.isEnabled()
assert not page.slot_area.isVisible() assert not page.slot_area.isVisible()
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120]) transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
frames = split_frames(transfer, 150) frames = _bbqr_frames(transfer, 150)
assert len(frames) > 1 assert len(frames) > 1
for frame in frames: for frame in frames:
@@ -361,12 +388,14 @@ def test_import_assembles_and_decodes():
captured["payload"] = payload captured["payload"] = payload
page._finish_import = fake_finish page._finish_import = fake_finish
transfer = encode_transfer(["P" * 130, "Q" * 130]) tx_a = _rand_hex(130)
for frame in split_frames(transfer, 150): tx_b = _rand_hex(130)
transfer = encode_transfer([tx_a, tx_b])
for frame in _bbqr_frames(transfer, 150):
page._add_frame(frame) page._add_frame(frame)
page._review_and_sign() page._review_and_sign()
# The payload is rejoined into an opaque string for autodetect. # 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() d.close()
@@ -407,7 +436,7 @@ def test_whole_will_qr_roundtrip():
transfer = encode_transfer(page.tx_strings) transfer = encode_transfer(page.tx_strings)
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page 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) import_page._add_frame(frame)
assert import_page.review_btn.isEnabled() assert import_page.review_btn.isEnabled()
caught = {} caught = {}
@@ -431,8 +460,10 @@ def test_import_total_mismatch_resets():
warnings = [] warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment] page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150) frames_b = _bbqr_frames(
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
)
for frame in frames_a: for frame in frames_a:
page._add_frame(frame) page._add_frame(frame)
assert page.total == len(frames_a) assert page.total == len(frames_a)
@@ -451,7 +482,7 @@ def test_import_manual_entry():
bw = FakeBalWindow() bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page 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.manual_edit.setText(frames[0])
page._add_from_manual() page._add_from_manual()
assert page.manual_edit.text() == "" assert page.manual_edit.text() == ""
@@ -477,48 +508,48 @@ def _fresh_debounce():
def test_qr_import_debounce_pending_then_accept(): def test_qr_import_debounce_pending_then_accept():
s = _fresh_debounce() s = _fresh_debounce()
# First sighting of a new identity: pending, not yet stored. # 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["pending_count"] == 1
assert s["last_index"] is None assert s["last_index"] is None
# A second stable read of the same identity: accepted. # A second stable read of the same identity: accepted.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept" assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
assert s["last_index"] == 1 assert s["last_index"] == 0
assert s["last_payload"] == "PAYLOAD1" assert s["last_payload"] == "PAYLOAD1"
assert s["pending_count"] == 0 assert s["pending_count"] == 0
def test_qr_import_debounce_re_reading_last_is_ignored(): def test_qr_import_debounce_re_reading_last_is_ignored():
s = _fresh_debounce() s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
# The exporter is still showing frame 1: must be ignored, not accepted. # The exporter is still showing frame 0: must be ignored, not accepted.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore" assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
assert s["last_index"] == 1 assert s["last_index"] == 0
assert s["pending_count"] == 0 assert s["pending_count"] == 0
def test_qr_import_debounce_transition_pending_resets(): def test_qr_import_debounce_transition_pending_resets():
s = _fresh_debounce() s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "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") # accept frame 0
# A new identity interrupts the pending accumulation. # 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, "bbqr", "bbqr:ZB:2", 2, 1, "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") == "accept"
# Same-index duplicate with different payload is treated as new identity. # 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(): def test_qr_import_debounce_total_mismatch_resets():
s = _fresh_debounce() s = _fresh_debounce()
# In-range frame is accepted even though its declared total is ignored. # 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, "bbqr", "bbqr:ZB:3", 3, 1, "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") == "accept"
# A frame that belongs to a different transfer. # 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 # Once the policy is rebased on the new transfer, frames resume normally
# (the widget clears the debounce while wiping the import). # (the widget clears the debounce while wiping the import).
s["key"] = None 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(): def test_qr_import_handle_scanned_text_autofinish():
@@ -528,7 +559,7 @@ def test_qr_import_handle_scanned_text_autofinish():
reviewed = [] reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] 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 len(frames) > 1
# The camera session is running and every frame needs two stable reads. # 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 = [] reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] 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 len(frames) > 1
assert not page._scanning assert not page._scanning
for frame in frames: 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(): def test_import_bbqr_roundtrip():
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):
bw = FakeBalWindow() bw = FakeBalWindow()
bw.willitems = _make_willitems(2) bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
page._on_format_change(fmt_index)
frames = list(page.frames) frames = list(page.frames)
assert frames assert frames and all(f.startswith("B$") for f in frames)
d.close() d.close()
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
@@ -634,24 +627,11 @@ def _import_roundtrip_fmt(fmt_index):
impl.close() 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(): def test_import_animated_scan_debounce_autofinish():
bw = FakeBalWindow() bw = FakeBalWindow()
bw.willitems = _make_willitems(2) bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
page._on_format_change(2) # BC-UR v2 fountain
frames = list(page.frames) frames = list(page.frames)
d.close() d.close()
@@ -664,11 +644,8 @@ def test_import_animated_scan_debounce_autofinish():
for _rep in range(2): for _rep in range(2):
import_page._handle_scanned_text(frame) import_page._handle_scanned_text(frame)
assert import_page.review_btn.isEnabled() assert import_page.review_btn.isEnabled()
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one assert import_page.total == len(frames)
# redundant mixed wave).
assert import_page.total == len(frames) // 2
assert len(import_page.frames) >= import_page.total assert len(import_page.frames) >= import_page.total
assert import_page.review_btn.isEnabled()
_app.processEvents() _app.processEvents()
assert reviewed == [True] assert reviewed == [True]
assert not import_page._scanning assert not import_page._scanning
@@ -679,7 +656,7 @@ def test_import_garbage_scan_is_ignored():
bw = FakeBalWindow() bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page 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) page._handle_scanned_text(garbage)
assert not page.frames assert not page.frames
assert page.total == 0 assert page.total == 0
@@ -694,8 +671,10 @@ def test_import_different_animated_transfer_resets():
warnings = [] warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment] page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150) frames_b = _bbqr_frames(
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
)
for frame in frames_a: for frame in frames_a:
page._add_frame(frame) page._add_frame(frame)
assert page.total == len(frames_a) assert page.total == len(frames_a)