Compare commits
2 Commits
deeec042d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
4a33116b66
|
|||
|
fb88d7540c
|
231
.opencode/plans/plan_android_reader.md
Normal file
231
.opencode/plans/plan_android_reader.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# PLAN_ANDROID_READER.md — BAL Reader: an Android QR will reader
|
||||||
|
|
||||||
|
**Date:** 2026-09-09 · **Status:** proposed · **Ties into:** BAL plugin QR export (BALQR / BC-UR v1 / BC-UR v2 / BBQR)
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Ship a minimal single-activity Android app that reads any QR will exported by the BAL
|
||||||
|
Electrum plugin, decodes all four supported wire formats, and lets the user view, copy,
|
||||||
|
share, or save the recovered will data. The app is a **reader only** — it never signs or
|
||||||
|
broadcasts.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Continuous camera capture (CameraX + ML Kit on-device barcode scanning, QR-only).
|
||||||
|
- Auto-detection of the four formats (`detect_format`: `"balqr" / "ur1" / "ur2" / "bbqr"`).
|
||||||
|
- Order-independent frame assembly: duplicates ignored, out-of-order accepted, UR v2
|
||||||
|
XOR-fountain redundancy exploited, session-level reset on transfer switch.
|
||||||
|
- Whole-will JSON **and** tx-hex-list payloads, both decoded to a readable view.
|
||||||
|
- Copy raw transfer text / Share / Save via Storage Access Framework.
|
||||||
|
- Verified decode chain runnable on the dev machine (no Android needed).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Signing, broadcasting, password handling, wallet integration.
|
||||||
|
- Export/authoring QR codes *from* the phone.
|
||||||
|
- Audio-modem transport (unchanged, plugin-only).
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Why Chaquopy (reuse the tested Python codecs)
|
||||||
|
|
||||||
|
`bal/core/animated_qr.py` (1178 lines) and `bal/core/qrtransfer.py` (212 lines) are
|
||||||
|
**pure stdlib** (`base64`, `hashlib`, `zlib`), GUI-free, Python-3.8-clean (verified: no
|
||||||
|
walrus/match/`X|Y`/PEP-585 generics), and `bal/core/__init__.py` is an empty docstring.
|
||||||
|
Chaquopy bundles CPython into the APK, so **the exact, battle-tested codec modules run
|
||||||
|
unchanged on Android** with zero port risk. The Kotlin side is only camera glue + UI.
|
||||||
|
|
||||||
|
### 3.2 Decode chain (mirror of the plugin's import tail)
|
||||||
|
|
||||||
|
The plugin's `_review_and_sign` (dialogs.py:3792) does exactly:
|
||||||
|
|
||||||
|
1. `session.resolve()` -> `(transfer_text, compressed: bool)`
|
||||||
|
(UR v1/v2/BBQR for the first pair; `(text, flag)` for BAL QR).
|
||||||
|
2. `decode_transfer(transfer_text, compressed)` -> list of parts (tx hexes, or the single
|
||||||
|
whole-will JSON).
|
||||||
|
3. `"\n".join(parts)` -> opaque payload.
|
||||||
|
4. `decode_will_payload(payload)` -> `("will", dict)` **or** `("txs", [strings])`.
|
||||||
|
|
||||||
|
The app reuses all of it verbatim through the `AnimatedQrSession` facade (`add_part`
|
||||||
|
auto-detects per frame, dedups, tracks `received/total/done`).
|
||||||
|
|
||||||
|
### 3.3 Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
CameraX ImageAnalysis -> ML Kit BarcodeScanning (QR) -> raw string
|
||||||
|
-> BalDecoder.add(text) [Chaquopy -> AnimatedQrSession.add_part]
|
||||||
|
-> status: format chip + received/total, duplicate-tolerant
|
||||||
|
-> when done: auto-stop -> BalDecoder.finish() (4-step decode)
|
||||||
|
-> ResultActivity: JSON view OR tx-list view + Copy/Share/Save
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Repository layout (new `android/` subfolder)
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
README.md # build (Android Studio), usage, codec-sync rule, MIT note
|
||||||
|
settings.gradle.kts
|
||||||
|
build.gradle.kts # root: plugins (AGP 8.10, Kotlin 2.0.21, Chaquopy 17, apply false)
|
||||||
|
gradle.properties
|
||||||
|
gradlew, gradlew.bat
|
||||||
|
gradle/wrapper/ # gradle-wrapper.properties + gradle-wrapper.jar (fetched; see §7)
|
||||||
|
scripts/
|
||||||
|
sync_codecs.py # copy bal/core/{__init__,animated_qr,qrtransfer}.py -> app python dir; import-check
|
||||||
|
test_chain/
|
||||||
|
verify_chain.py # full decode-chain simulation, runs on dev machine
|
||||||
|
app/
|
||||||
|
build.gradle.kts # com.android.application + com.chaquo.python; CameraX/ML Kit deps
|
||||||
|
src/main/
|
||||||
|
AndroidManifest.xml # CAMERA permission
|
||||||
|
java/life/after/bitcoin/
|
||||||
|
MainActivity.kt # permission + PreviewView + ImageAnalysis + status header
|
||||||
|
BalDecoder.kt # Chaquopy bridge (§5.1)
|
||||||
|
ResultActivity.kt # viewer + copy/share/save (§5.3)
|
||||||
|
res/layout/activity_main.xml, activity_result.xml
|
||||||
|
res/values/strings.xml
|
||||||
|
python/bal/core/ # SYNCED COPIES (committed, deterministic; regenerate with script)
|
||||||
|
__init__.py
|
||||||
|
animated_qr.py
|
||||||
|
qrtransfer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Component specifications
|
||||||
|
|
||||||
|
### 5.1 `BalDecoder` (Kotlin, Chaquopy bridge)
|
||||||
|
|
||||||
|
- Lazy init: `Python.start(AndroidPlatform(context))`, `getModule("bal.core.animated_qr")`.
|
||||||
|
- `fun add(text: String): String` -> `session.add_part(text)`; surfaces `"ok"`/`"dup"`;
|
||||||
|
maps `AnimatedQrError` subclasses to a result the UI can ignore (garbage frames) vs
|
||||||
|
reset (transfer switch -> `TransferConflictError` -> tell user to rescan).
|
||||||
|
- `val format: String?` (`"balqr"/"ur1"/"ur2"/"bbqr"`), `val received: Int`,
|
||||||
|
`val total: Int`, `val done: Boolean` (auto-converted by Chaquopy).
|
||||||
|
- `fun finish(): DecodedResult` - the 4-step chain; returns
|
||||||
|
`data class DecodedResult(kind: "will"|"txs", data: Map<String,Any>|List<String>, rawTransfer: String)`.
|
||||||
|
- `fun reset()` -> new `AnimatedQrSession` (new scan).
|
||||||
|
|
||||||
|
### 5.2 `MainActivity` (camera + scan loop)
|
||||||
|
|
||||||
|
- Launches CameraX via `ProcessCameraProvider`; `PreviewView` fills screen; camera
|
||||||
|
permission via `ActivityResultContracts.RequestPermission`.
|
||||||
|
- `ImageAnalysis` `STRATEGY_KEEP_ONLY_LATEST`; analyzer throttled (~100 ms) calls ML Kit
|
||||||
|
`BarcodeScanning` with `Barcode.FORMAT_QR_CODE`.
|
||||||
|
- Thread-safe feed to `BalDecoder` (analyzer runs on a background executor); UI status
|
||||||
|
via `runOnUiThread`.
|
||||||
|
- Header row: format chip + `received/total`; on `done` -> stop analyzer -> launch
|
||||||
|
`ResultActivity` (results as Parcelable); "New scan" restarts.
|
||||||
|
- Garbage / incomplete frames silently ignored (same policy as the plugin); a
|
||||||
|
`TransferConflictError` mid-scan resets the session and signals the user to rescan.
|
||||||
|
|
||||||
|
### 5.3 `ResultActivity` (viewer)
|
||||||
|
|
||||||
|
- `kind == "will"`: whole-will JSON - each item's `tx` hex shown truncated with full view
|
||||||
|
on demand.
|
||||||
|
- `kind == "txs"`: list of tx hexes.
|
||||||
|
- Action bar: **Copy** (raw transfer text to clipboard), **Share** (ACTION_SEND
|
||||||
|
text/plain), **Save** (SAF `ACTION_CREATE_DOCUMENT` -> `will.json` / `will_tx.txt`).
|
||||||
|
- "Scan another" button -> finish -> back to camera.
|
||||||
|
|
||||||
|
### 5.4 `scripts/sync_codecs.py`
|
||||||
|
|
||||||
|
- Copies the 3 files from `bal/core/` -> `app/src/main/python/bal/core/` (idempotent).
|
||||||
|
- Post-copy check (dev machine): import `bal.core.animated_qr`, run one UR v2 frame +
|
||||||
|
decode cycle to prove the copy imports standalone.
|
||||||
|
- Documented as the rule after any codec change (README).
|
||||||
|
|
||||||
|
### 5.5 `test_chain/verify_chain.py`
|
||||||
|
|
||||||
|
Simulates the exact APK runtime path on the dev machine (no Android). Generates frames
|
||||||
|
precisely as the export page does, then feeds `AnimatedQrSession.add_part` in
|
||||||
|
scrambled/duplicated/dropped order and asserts correct results for:
|
||||||
|
|
||||||
|
- BAL QR multi-frame, plain **and** compressed (`Z` flag): `split_frames(encode_transfer(...))`.
|
||||||
|
- UR v1 single-part (`ur1_frames` headerless) and `1ofN` multipart.
|
||||||
|
- UR v2 single-part and fountain multipart with >=1 frame dropped and >=1 duplicated
|
||||||
|
(exercises the XOR solve).
|
||||||
|
- BBQR `Z`/`H`/`2`: `bbqr_frames(..., encoding=...)`, with the `Z` auto-decompress branch.
|
||||||
|
- payload kinds: whole-will JSON **and** tx-hex list.
|
||||||
|
- transfer-switch: feed a frame of a different transfer mid-session -> expect conflict, as
|
||||||
|
the UI will.
|
||||||
|
|
||||||
|
Runs under the runtime venv:
|
||||||
|
`source .../electrum/env/bin/activate && QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py`
|
||||||
|
|
||||||
|
## 6. Wire-format reference (bundled codecs)
|
||||||
|
|
||||||
|
- **BAL QR**: `BALQR1|<total>|<index>|<flags>|<payload>`, flags `""` or `Z` (zlib+base64).
|
||||||
|
Concatenate payloads 1..total -> transfer string -> `decode_transfer` splits on `\n`.
|
||||||
|
- **BC-UR v1**: multipart `ur:bytes/<seq>of<seq_len>/<sha256-bc32-digest>/<bc32-frag>`;
|
||||||
|
single-part `ur:bytes/<bc32>` (digest-less). BC32 = bech32_bis (XOR `0x3FFFFFFF`)
|
||||||
|
5-bit alphabet.
|
||||||
|
- **BC-UR v2**: multipart `ur:bytes/<seq>-of-<seq_len>/<bytewords-minimal-part>` +
|
||||||
|
single-part headerless; part body = CBOR `[seq, seq_len, msg_len, crc32, data]` +
|
||||||
|
per-part CRC-32, bytewords-minimal; fountain via `choose_fragments` (xoshiro256** +
|
||||||
|
alias/threshold), mixed by XOR - decoder solves from any sufficient subset.
|
||||||
|
- **BBQR**: `B$<encoding><type><base36 total><base36 index><payload>`; encodings `H`
|
||||||
|
(upper hex), `2` (base32nal), `Z` (deflate `wbits=-10` -> base32).
|
||||||
|
|
||||||
|
## 7. Toolchain & versions
|
||||||
|
|
||||||
|
| Item | Version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Chaquopy | 17.0.0 | Python 3.10-3.14, AGP 7.3-9.2, minSdk 24 |
|
||||||
|
| AGP | 8.10.0 | in Chaquopy 17 range |
|
||||||
|
| Gradle wrapper | 8.14 | required by AGP 8.10 |
|
||||||
|
| Kotlin | 2.0.21 | |
|
||||||
|
| compile/target SDK | 35 / min 24 | |
|
||||||
|
| JDK | 17 (machine has OpenJDK 17) | |
|
||||||
|
| CameraX | 1.3.4 | `camera-core`, `camera-camera2`, `camera-lifecycle`, `camera-view` |
|
||||||
|
| ML Kit barcode | 17.3.0 | on-device, no API key |
|
||||||
|
| Python (codec) | 3.12 (Chaquopy) | codecs verified 3.8-clean |
|
||||||
|
|
||||||
|
Dev machine: Android Studio + JDK 17 present; SDK platforms/build-tools/gradle absent ->
|
||||||
|
the **first real APK build happens in Android Studio with network**. `gradle-wrapper.jar`
|
||||||
|
is fetched via `curl` (canonical location) so `./gradlew` works; if network is blocked,
|
||||||
|
README documents Android Studio regenerating it.
|
||||||
|
|
||||||
|
## 8. Build & run outline (for README)
|
||||||
|
|
||||||
|
1. Open `android/` in Android Studio (or `./gradlew assembleDebug`).
|
||||||
|
2. Allow Gradle to fetch wrapper/deps (network).
|
||||||
|
3. Install on device; grant camera permission.
|
||||||
|
4. In the plugin: export dialog -> pick format (start with BAL QR default; also test
|
||||||
|
UR v1/UR v2/BBQR) -> show the animated QR on screen.
|
||||||
|
5. Point camera at screen; watch `received/total`; decoded result appears ->
|
||||||
|
Copy/Share/Save.
|
||||||
|
|
||||||
|
## 9. Verification
|
||||||
|
|
||||||
|
**On this machine (no Android needed)**
|
||||||
|
|
||||||
|
1. `python3 android/scripts/sync_codecs.py` -> copy + import/roundtrip sanity.
|
||||||
|
2. `QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` -> all
|
||||||
|
formats/payload kinds/conflict cases pass.
|
||||||
|
3. `ruff check android/scripts/sync_codecs.py android/test_chain/verify_chain.py` -> clean.
|
||||||
|
4. `pytest tests/test_core_*.py tests/test_gui_*.py` -> still **445 passed**
|
||||||
|
(payload code untouched; only new files).
|
||||||
|
5. `python3 build_zip.py` unaffected (no change under `bal/`).
|
||||||
|
|
||||||
|
**On-device (manual, user)**
|
||||||
|
|
||||||
|
- Walk the 4 formats against the plugin's export page, single- and multi-frame
|
||||||
|
(animated), on a real phone.
|
||||||
|
- Confirm format chip, progress, auto-finish, Copy/Share/Save.
|
||||||
|
|
||||||
|
**Boundary** - no APK is produced by this machine's environment; the artifact is a
|
||||||
|
complete, independently buildable source tree + verified codec path.
|
||||||
|
|
||||||
|
## 10. Risks & notes
|
||||||
|
|
||||||
|
- First Gradle sync needs network (deps + wrapper). Pinned versions are conservative;
|
||||||
|
knobs documented.
|
||||||
|
- Bundled codec copies must be regenerated after any `bal/core/animated_qr.py` /
|
||||||
|
`qrtransfer.py` change - handled by `sync_codecs.py` + README note (copies are
|
||||||
|
committed for deterministic builds).
|
||||||
|
- Animated QR reading depends on the camera catching enough distinct frames (ML Kit
|
||||||
|
analyzer keeps scanning; the session dedups and accepts out-of-order). Slow phone
|
||||||
|
screens / glare may raise time-to-complete - expected, same as the plugin.
|
||||||
|
- App name/package (`life.after.bitcoin`, label "BAL Reader") are placeholders - trivial
|
||||||
|
to change.
|
||||||
|
- MIT: bundled codec files inherit the plugin's MIT license (noted in README).
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
||||||
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
|
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
|
||||||
|
|
||||||
|
Paths below are relative to the repo, or use `$BAL_HOME` (the directory
|
||||||
|
containing this repo and the sibling `electrum/` checkout).
|
||||||
|
|
||||||
## Environments (critical)
|
## Environments (critical)
|
||||||
|
|
||||||
Two separate venvs; using the wrong one is the #1 mistake.
|
Two separate venvs; using the wrong one is the #1 mistake.
|
||||||
|
|||||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -3265,3 +3265,65 @@ as the default.
|
|||||||
`external_zip_test.py` all pass.
|
`external_zip_test.py` all pass.
|
||||||
|
|
||||||
**Outcome:** DONE (uncommitted).
|
**Outcome:** DONE (uncommitted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Balanced-QR wire format v2: compact header + best-of compression
|
||||||
|
|
||||||
|
**Date:** 2026-09-13
|
||||||
|
|
||||||
|
**Goal:** Shrink the native BAL QR wire format to its minimum. The old
|
||||||
|
pipe-separated header (`BALQR1|total|index|flags|`) wasted 12-14 characters on
|
||||||
|
direction marker, separators and decimal count fields, and the export always
|
||||||
|
sent uncompressed hex text. New exports should fit a will in the fewest,
|
||||||
|
densest frames possible.
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/qrtransfer.py`:
|
||||||
|
- New wire format v2: `BAL1<TTT><iii><F><payload>` — fixed 11-char header,
|
||||||
|
no separators. `BAL1` magic, 3-digit **base36** zero-padded totals/index
|
||||||
|
(values `00A`-`ZZZ`, cap 46655 frames), single flag char.
|
||||||
|
- Flags: `0` = plain payload, `Z` = zlib+base64 compressed payload (the importer
|
||||||
|
already decompressed `Z`; the exporter now produces it).
|
||||||
|
- `encode_transfer_best(tx_strings)` returns the shorter of plain vs
|
||||||
|
compressed; the export widget uses it as the default for BAL QR.
|
||||||
|
- `parse_frame` is dual: old `BALQR1|total|index|flags|payload` frames still
|
||||||
|
import unchanged (backwards-compatible receive).
|
||||||
|
- Frame-count overflow (a transfer needing > 46655 frames) raises
|
||||||
|
`QrTransferError` at encode time instead of emitting corrupt headers.
|
||||||
|
- `bal/gui/qt/dialogs.py` (`BalQrExportWidget`): BAL QR export now encodes via
|
||||||
|
`encode_transfer_best`, so plain *or* compressed frames are emitted per
|
||||||
|
transfer; import is untouched (already format-agnostic and flag-driven).
|
||||||
|
- `bal/core/animated_qr.py`: `detect_format` accepts `BAL1` in addition to the
|
||||||
|
legacy `BALQR` prefix; wire-format docstring updated.
|
||||||
|
- `bal/gui/qt/widgets.py` (`WillWidget`): the will detail view now shows each
|
||||||
|
heir's address (or the decoded UTF-8 text of an `OP_RETURN:` heir) and a
|
||||||
|
dedicated Address row for the will-executor.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- `tests/test_core_qr_transfer.py` (new v2 tests: header structure, field width,
|
||||||
|
`Z` flag round-trip, best-of selection, malformed `BAL1` frames, 46655 cap and
|
||||||
|
exact boundary): all pass; legacy `BALQR1` parse tests unchanged and green.
|
||||||
|
- `tests/test_core_animated_qr.py`: `BAL1` detection + `parse_for_detection`;
|
||||||
|
`tests/test_gui_qr_transfer.py`: format-combo + chunk-navigation updated for
|
||||||
|
the compact export.
|
||||||
|
- `pytest tests/test_core_*.py tests/test_import_will_details.py -q`: 345 passed.
|
||||||
|
- `ruff` clean on all touched files except the pre-existing `dialogs.py` I001
|
||||||
|
(present on HEAD); `pyright` 0 errors on the codec modules.
|
||||||
|
- Android Chaquopy bundle re-synced (`sync_codecs.py` + `verify_chain.py`).
|
||||||
|
|
||||||
|
**Notes / caveats:**
|
||||||
|
|
||||||
|
- **Compatibility break (forward):** the new default export (compressed
|
||||||
|
`BAL1…`) is NOT readable by older BAL versions — nor by the previously
|
||||||
|
released Android APK — until those are updated to accept `BAL1`. Imports of
|
||||||
|
legacy `BALQR1…` exports keep working on this version. Existing audio
|
||||||
|
transfers are unaffected (they keep explicit `compress=False`, and the audio
|
||||||
|
format was never flag-driven on receive).
|
||||||
|
- A typical multi-tx will now ships as a single dense frame instead of two
|
||||||
|
sparse ones: header overhead dropped from 12-14 chars to a constant 11, and
|
||||||
|
the base36 count fields are 3 chars regardless of how many frames exist.
|
||||||
|
|
||||||
|
**Outcome:** DONE.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
|
|||||||
|
|
||||||
| Format | Wire appearance | Interop target |
|
| Format | Wire appearance | Interop target |
|
||||||
|-----------|----------------------------|------------------------------------------------------|
|
|-----------|----------------------------|------------------------------------------------------|
|
||||||
| BAL QR | `BALQR1\|total\|index\|…` | Past/other BAL versions (default, always exported) |
|
| 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 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) |
|
| 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 |
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ Two separate venvs — using the wrong one is the #1 mistake:
|
|||||||
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
|
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
|
||||||
import `electrum` or `PyQt6`. Do NOT use it to run tests.
|
import `electrum` or `PyQt6`. Do NOT use it to run tests.
|
||||||
|
|
||||||
Run everything from the repo root.
|
Run everything from the repo root (the checkout directory; set
|
||||||
|
`BAL_HOME` to its parent to use `$BAL_HOME/electrum`).
|
||||||
|
|
||||||
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
|
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
|
||||||
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||||
|
|||||||
@@ -62,9 +62,9 @@
|
|||||||
| # | Decision |
|
| # | Decision |
|
||||||
|---|----------|
|
|---|----------|
|
||||||
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||||
| D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. |
|
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
|
||||||
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||||
| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. |
|
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
|
||||||
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||||
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||||
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||||
@@ -120,19 +120,30 @@ transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
|||||||
|
|
||||||
### 4.2 Frame layout (one frame = content of ONE QR code)
|
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||||
|
|
||||||
|
Wire format v2 (compact, current export):
|
||||||
|
|
||||||
```
|
```
|
||||||
BALQR1|<total>|<index>|<flags>|<payload>
|
BAL1<TTT><iii><F><payload>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Magic+version literal `BALQR1` (reject anything else with a clear message;
|
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||||
keeps the door open for a future `BALQR2`).
|
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||||
- `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`.
|
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||||
- `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed).
|
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
|
||||||
|
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||||
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
||||||
`chunk_size` bytes each (last slice may be shorter).
|
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||||
- Header overhead ≈ 16–20 bytes → effective payload = `chunk_size − overhead`;
|
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||||
the chunker slices the transfer string so that **header+payload ≤ preset
|
chars and the payload starts at offset 11.
|
||||||
size**.
|
- Header overhead is a constant **11 bytes** → effective payload =
|
||||||
|
`chunk_size − 11`; the chunker slices the transfer string so that
|
||||||
|
**header+payload ≤ preset size**.
|
||||||
|
|
||||||
|
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
|
||||||
|
decimal header, pipe-separated) are still **imported** by `parse_frame`
|
||||||
|
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
|
||||||
|
That is the one deliberate compatibility break: **v2 frames are not readable
|
||||||
|
by builds older than this change.**
|
||||||
|
|
||||||
### 4.3 Size presets (D5)
|
### 4.3 Size presets (D5)
|
||||||
|
|
||||||
@@ -173,12 +184,18 @@ def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
|||||||
"""Join -> optional zlib+base64 -> return transfer_string."""
|
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||||
|
|
||||||
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||||
"""Slice into full frames 'BALQR1|N|i|flags|payload'. Raises
|
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
|
||||||
|
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
|
||||||
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||||
|
|
||||||
|
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
|
||||||
|
"""-> (transfer_string, compressed); ships the shorter of plain vs
|
||||||
|
zlib+base64 so the export emits the densest frames."""
|
||||||
|
|
||||||
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||||
"""-> (total, index, compressed, payload); ValueError on bad magic/
|
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||||
version/arity/non-int fields."""
|
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||||
|
version/arity/non-numeric fields."""
|
||||||
|
|
||||||
def assemble(frames: dict[int, str]) -> str
|
def assemble(frames: dict[int, str]) -> str
|
||||||
"""Validate indices form exactly range(1..max_total) (taken from any
|
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||||
@@ -260,7 +277,7 @@ def get_audio_modem_plugin(self): # on BalWindow
|
|||||||
Layout:
|
Layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
[Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)]
|
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||||
[ QR image ] ← BalQrImage (see below)
|
[ QR image ] ← BalQrImage (see below)
|
||||||
«i di N» [◀ Prev] [Next ▶]
|
«i di N» [◀ Prev] [Next ▶]
|
||||||
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||||
@@ -268,7 +285,7 @@ Layout:
|
|||||||
|
|
||||||
Behaviour:
|
Behaviour:
|
||||||
|
|
||||||
- On any control change: rebuild `split_frames(encode_transfer(...))`,
|
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||||
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||||
risoluzione").
|
risoluzione").
|
||||||
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||||
|
|||||||
379
QML_PLAN.md
379
QML_PLAN.md
@@ -1,379 +0,0 @@
|
|||||||
# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support)
|
|
||||||
|
|
||||||
> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is
|
|
||||||
> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will
|
|
||||||
> transactions from an online machine (desktop/another phone), sign them with
|
|
||||||
> the wallet keys held on it, and return the signed transactions — a classic
|
|
||||||
> air-gapped signer workflow. Online features (willexecutor contact,
|
|
||||||
> broadcast, build) are secondary on Android and belong mainly to the online
|
|
||||||
> machine.
|
|
||||||
> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic
|
|
||||||
> through the existing GUI-free `BalController`, exactly like `bal/cli/`
|
|
||||||
> already does. The PyQt6 desktop GUI remains untouched and primary.
|
|
||||||
>
|
|
||||||
> Status: DRAFT for owner review (rule R4 — no code until explicit OK).
|
|
||||||
> Chat language: Italian; this document is in English per rule R1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Verified facts (research done on the local checkouts)
|
|
||||||
|
|
||||||
All items below were verified by reading source, not assumed.
|
|
||||||
|
|
||||||
| # | Fact | Where |
|
|
||||||
|---|------|-------|
|
|
||||||
| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` |
|
|
||||||
| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `<plugin>/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` |
|
|
||||||
| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `<plugins>/<name>/qml/<loader>.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` |
|
|
||||||
| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `$BAL_HOME/electrum470/electrum/gui/{qml,common_qt}` |
|
|
||||||
| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 |
|
|
||||||
| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` |
|
|
||||||
| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` |
|
|
||||||
| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` |
|
|
||||||
| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` |
|
|
||||||
| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 |
|
|
||||||
| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
### In scope (MVP)
|
|
||||||
|
|
||||||
Two usage **profiles** share one codebase:
|
|
||||||
|
|
||||||
- **Offline signer profile** (Android, PRIORITY): works with no network.
|
|
||||||
1. Import a will bundle exported by the online machine (existing JSON will
|
|
||||||
format, see F12) — via file share, paste, or QR.
|
|
||||||
2. Review what is being signed (destinations, amounts, locktimes, fees).
|
|
||||||
3. Sign internally (`wallet.sign_transaction` + password prompt).
|
|
||||||
Partial signatures are combined when the bundle is re-imported
|
|
||||||
(`combine_with_other_psbt`, already supported by `merge_will` logic).
|
|
||||||
4. Export the signed bundle back to the online machine.
|
|
||||||
- **Online manager profile** (desktop QML, secondary):
|
|
||||||
- Will status overview (state, expiry/check-alive date, reminder info).
|
|
||||||
- Heirs list (view/add/edit/remove, addresses or URIs).
|
|
||||||
- Will-executor selection (list, enable/disable, fee display, refresh).
|
|
||||||
- Build will (simplified wizard reusing core validation).
|
|
||||||
- Sign in place (password) or hand off to an offline signer via bundles.
|
|
||||||
- Broadcast / push to will-executors; invalidate will; check-alive refresh.
|
|
||||||
- Basic settings mapped onto `will_settings`.
|
|
||||||
|
|
||||||
The offline signer pages are built first and must function with the network
|
|
||||||
disabled (Electrum runs fine offline; willexecutor refresh simply degrades).
|
|
||||||
|
|
||||||
Also in scope:
|
|
||||||
|
|
||||||
- Android packaging: BAL bundled as internal plugin in the custom APK,
|
|
||||||
**enabled by default** (owner decision D3).
|
|
||||||
- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged.
|
|
||||||
|
|
||||||
### Out of scope (explicitly deferred)
|
|
||||||
|
|
||||||
- Full parity with the PyQt6 GUI (calendar widget, preview list editor,
|
|
||||||
advanced fee controls, themes).
|
|
||||||
- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP
|
|
||||||
builds keep working for desktop exactly as today, without `qml` UI).
|
|
||||||
- iOS, upstream-Electrum (spesmilo) compatibility.
|
|
||||||
- Lightning-related features (irrelevant to BAL).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ bal/core │
|
|
||||||
│ heirs, will, willexecutors, checkalive, │
|
|
||||||
│ reminders, input_rules, plugin_base │
|
|
||||||
└────────────┬──────────────────────────────┘
|
|
||||||
│ (no Qt anywhere)
|
|
||||||
┌─────────────────────┼──────────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW
|
|
||||||
BalWindow etc. BalController qml_plugin.py (BalQmlPlugin)
|
|
||||||
(~9.3k lines) (headless flows) models.py (QObject VMs)
|
|
||||||
so.py (PluginQObject)
|
|
||||||
*.qml (views)
|
|
||||||
▲
|
|
||||||
wraps ONE BalController
|
|
||||||
per loaded wallet
|
|
||||||
```
|
|
||||||
|
|
||||||
Design rules:
|
|
||||||
|
|
||||||
- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject
|
|
||||||
wrappers around one `BalController` instance per wallet. No business logic
|
|
||||||
in QML or in the wrappers beyond formatting.
|
|
||||||
- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`)
|
|
||||||
are registered by `bal/core/plugin_base.py` already; QML reads/writes them
|
|
||||||
through the controller, so a wallet moves between desktop/Android unchanged.
|
|
||||||
- **Threading.** Network operations (willexecutor fetch/push, broadcast)
|
|
||||||
run in worker threads exactly as the CLI does; results marshalled to the UI
|
|
||||||
thread via Qt signals on the wrapper objects. No blocking calls in slots.
|
|
||||||
- **One transfer format.** The airgap round trip reuses the existing JSON
|
|
||||||
will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's
|
|
||||||
`export_json_file`/`import` + `merge_will` flow already produces and
|
|
||||||
consumes — see F12). No new format is invented; export/merge logic gets a
|
|
||||||
single shared home usable by both frontends.
|
|
||||||
- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and
|
|
||||||
defensively; if absent (e.g., odd build), the plugin degrades to core-only
|
|
||||||
behavior instead of crashing the daemon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Work breakdown
|
|
||||||
|
|
||||||
### Phase 0 — Verification spikes (no product code)
|
|
||||||
|
|
||||||
| Task | Description | Exit criterion |
|
|
||||||
|------|-------------|----------------|
|
|
||||||
| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section |
|
|
||||||
| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash |
|
|
||||||
| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` |
|
|
||||||
| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file |
|
|
||||||
|
|
||||||
Deliverable: short findings report appended to this document; go/no-go.
|
|
||||||
|
|
||||||
### Phase 1 — Skeleton integration
|
|
||||||
|
|
||||||
Files (all NEW unless noted):
|
|
||||||
|
|
||||||
```
|
|
||||||
bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py
|
|
||||||
bal/gui/qml/__init__.py package docstring
|
|
||||||
bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase)
|
|
||||||
bal/gui/qml/so.py class BalSignalObject(PluginQObject)
|
|
||||||
bal/manifest.json MODIFIED: available_for += ["qml"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Details:
|
|
||||||
|
|
||||||
- `qml_plugin.py`:
|
|
||||||
- `@hook init_qml(self, app)`: store app ref; create `so` parented to app;
|
|
||||||
for each already-loaded wallet call `_on_wallet_loaded(wallet)`
|
|
||||||
(mirrors labels' pattern, see F3).
|
|
||||||
- `@hook load_wallet(self, wallet)` — **single argument** (F9); creates the
|
|
||||||
per-wallet view-model bundle (Phase 2) keyed by `wallet`.
|
|
||||||
- `@hook unload_wallet(self, wallet)`: drop controllers, close windows.
|
|
||||||
- `so.py`: `BalSignalObject(PluginQObject)` exposing:
|
|
||||||
- `loader` property returning `"BalMain.qml"` (drives F3 auto-create);
|
|
||||||
- signals: `walletChanged`, `willStateChanged`, `heirsChanged`,
|
|
||||||
`willexecutorsChanged`, `busyChanged`;
|
|
||||||
- slots called from QML: open/close window, refresh willexecutors,
|
|
||||||
check-alive now, build/sign/broadcast/invalidate commands.
|
|
||||||
- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched
|
|
||||||
(Electrum filters per running GUI, verified F2).
|
|
||||||
|
|
||||||
Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created
|
|
||||||
(log line from `onPluginLoaded`), no functional UI yet.
|
|
||||||
|
|
||||||
### Phase 2 — View-models (QObject layer)
|
|
||||||
|
|
||||||
New file `bal/gui/qml/models.py`:
|
|
||||||
|
|
||||||
- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only
|
|
||||||
properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`,
|
|
||||||
`sigsHave/sigsRequired` per tx) + notification signals; forwards actions to
|
|
||||||
controller methods (`build_will`, `sign_transactions`, `broadcast_will`,
|
|
||||||
`invalidate_will_headless`, `check_alive`... — names per controller).
|
|
||||||
- **Airgap methods (priority):** `export_will_bundle()` and
|
|
||||||
`import_will_bundle(json_text)` returning summary of what changed. These
|
|
||||||
are small ports of the Qt GUI's `export_json_file` (window.py:1605) and
|
|
||||||
`merge_will` (window.py:1620) semantics. Preferred implementation: move
|
|
||||||
the logic into shared helpers (controller level or `bal/core/will.py`
|
|
||||||
static functions) and make the Qt GUI call the same helpers, so the two
|
|
||||||
frontends cannot diverge; regression-covered by existing core tests plus
|
|
||||||
new round-trip tests.
|
|
||||||
- Offline profile detection: expose an `isOffline` property derived from
|
|
||||||
`wallet.network is None` / config, so QML can hide online-only pages.
|
|
||||||
- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`,
|
|
||||||
`valid`; edit methods delegate to `Heirs` helpers through controller.
|
|
||||||
- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`,
|
|
||||||
`sigsHave`, `sigsRequired`, `isComplete`.
|
|
||||||
- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`,
|
|
||||||
`fee`, `valid`; toggle + async refresh.
|
|
||||||
- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer
|
|
||||||
planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt
|
|
||||||
plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL /
|
|
||||||
pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets;
|
|
||||||
re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR
|
|
||||||
rendering inside the model (QML paints it).
|
|
||||||
- All list mutations happen on the controller state then `beginResetModel/
|
|
||||||
endResetModel` (datasets are small; simplicity over incremental updates).
|
|
||||||
|
|
||||||
Exit criterion: pytest-driven model tests pass offscreen (create models over a
|
|
||||||
regtest/testnet wallet fixture, assert roles after mutations).
|
|
||||||
|
|
||||||
### Phase 3 — QML views
|
|
||||||
|
|
||||||
New directory `bal/gui/qml/components/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
BalMain.qml top-level Window; stack of pages below
|
|
||||||
BalSignPage.qml PRIORITY (offline signer): import bundle
|
|
||||||
(paste / file / QR), review summary of each tx,
|
|
||||||
password-sign, export signed bundle back
|
|
||||||
BalQrExportPage.qml QR export view: drives BalQrTransferModel, one
|
|
||||||
frame at a time (Prev/Next, progress i/N, chunk
|
|
||||||
preset selector), mirror of desktop Qt dialog
|
|
||||||
BalQrImportPage.qml QR import view: slot grid (1..N), camera via
|
|
||||||
Electrum `QRScan` reuse, manual paste fallback,
|
|
||||||
then jump into BalSignPage review/sign
|
|
||||||
BalStatusPage.qml will state, expiry countdown, check-alive button
|
|
||||||
(works offline with last-known data)
|
|
||||||
BalHeirsPage.qml ListView + add/edit dialog [online profile]
|
|
||||||
BalExecutorsPage.qml ListView with switches + refresh [online profile]
|
|
||||||
BalBuildPage.qml simplified build form (threshold selector, fees,
|
|
||||||
executor pick) → runs controller.build_will
|
|
||||||
[online profile]
|
|
||||||
BalSettingsPage.qml maps onto will_settings subset
|
|
||||||
controls/BalButton.qml, BalField.qml minimal styled primitives
|
|
||||||
qmldir module registration
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- **BalSignPage review screen is mandatory**: before signing, the user must
|
|
||||||
see per-transaction heir address, amount, locktime (date), and fee — this
|
|
||||||
device is the security boundary, so no "blind signing".
|
|
||||||
- QR transfer: a full will bundle may exceed one QR's capacity when there are
|
|
||||||
many heirs/transactions. The desktop Qt plugin now resolves this with
|
|
||||||
chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets
|
|
||||||
150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same
|
|
||||||
scheduler via a thin model. Order of preference stays (1) share/save file +
|
|
||||||
paste text; (2) chunked QR streams through `BalQrTransferModel`.
|
|
||||||
- Styling minimal, follow existing QML components' look (reuse
|
|
||||||
`controls/` from Electrum where importable — prefer copying tiny primitives
|
|
||||||
to avoid coupling to upstream churn; decide during implementation).
|
|
||||||
- Every string wrapped with `electrum.i18n._`.
|
|
||||||
|
|
||||||
Exit criterion: full manual walkthrough on desktop QML GUI (offscreen +
|
|
||||||
interactive) performing BOTH: (a) online profile — configure heirs → select
|
|
||||||
executors → build → sign → broadcast → invalidate; and (b) offline signer
|
|
||||||
profile — export unsigned bundle from an online wallet, import into a second
|
|
||||||
offline wallet instance, sign, re-export, import signed bundle back into the
|
|
||||||
first wallet and verify signatures combined/status COMPLETE. Walkthrough (b)
|
|
||||||
must pass with networking disabled.
|
|
||||||
|
|
||||||
### Phase 4 — Android integration
|
|
||||||
|
|
||||||
- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to
|
|
||||||
`contrib/android/buildozer_qml.spec` in the Electrum fork.
|
|
||||||
- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)**
|
|
||||||
and default-enable BAL in the APK build (D3: enabled by default).
|
|
||||||
- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1
|
|
||||||
approved). Fallback if T4 picks auto-open: window opens on wallet load.
|
|
||||||
- Rebuild APK; smoke-test on device/emulator:
|
|
||||||
install → BAL already enabled → open wallet → full MVP walkthrough,
|
|
||||||
including airplane-mode signing round trip (file/QR transfer between an
|
|
||||||
online desktop and the offline device — for emulator testing, "offline" =
|
|
||||||
network disabled via settings).
|
|
||||||
- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators —
|
|
||||||
already house style), background network on mobile (willexecutor timeouts),
|
|
||||||
APK size impact (bal is small; icons only), share-intent/file access
|
|
||||||
permissions for bundle import/export.
|
|
||||||
|
|
||||||
Exit criterion: signed test APK passes the same walkthrough as Phase 3.
|
|
||||||
|
|
||||||
### Phase 5 — Tests & CI hygiene
|
|
||||||
|
|
||||||
- New tests following repo conventions (`def test_*` + `__main__` block):
|
|
||||||
- `tests/test_qml_models.py` — models over fake/controller-backed wallet
|
|
||||||
(offscreen, no network).
|
|
||||||
- `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle
|
|
||||||
from wallet A → import into wallet B (same seed, offline) → sign →
|
|
||||||
export signed → merge back into A; assert COMPLETE status and signature
|
|
||||||
counts. Must pass with no network.
|
|
||||||
- `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed
|
|
||||||
QML app object; `so` wiring correct; load/unload wallet lifecycle.
|
|
||||||
- Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3
|
|
||||||
tests/smoke_test.py electrum.plugins.bal` still green (qt path intact).
|
|
||||||
- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW
|
|
||||||
violations) before any delivery ZIP.
|
|
||||||
- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml
|
|
||||||
offscreen, Android APK] × [4.7.2, 4.8.0] where applicable.
|
|
||||||
|
|
||||||
### Phase 6 — Release plumbing & docs
|
|
||||||
|
|
||||||
- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included
|
|
||||||
in deterministic zip (harmless on desktop; enables future extraction-based
|
|
||||||
loading).
|
|
||||||
- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status,
|
|
||||||
limitations, and how to enable (`-g qml` / APK toggle).
|
|
||||||
- `CHANGELOG.md`: numbered entry at END per house rules.
|
|
||||||
- Version bump + release handled by `make-release.sh` as usual (owner-driven).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Risks & mitigations
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed |
|
|
||||||
| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop |
|
|
||||||
| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting |
|
|
||||||
| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI |
|
|
||||||
| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable |
|
|
||||||
| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 |
|
|
||||||
| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test |
|
|
||||||
| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` |
|
|
||||||
| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) |
|
|
||||||
| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Questions for the owner — ANSWERED (2026-08-25)
|
|
||||||
|
|
||||||
1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's
|
|
||||||
`main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle.
|
|
||||||
2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.**
|
|
||||||
The phone holds the keys and acts as air-gapped signer: import unsigned
|
|
||||||
bundle → review → sign → export signed bundle back to the online machine.
|
|
||||||
The sign/import/export page is therefore the top priority of Phase 3, and
|
|
||||||
the round-trip test is the top priority of Phase 5.
|
|
||||||
3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK**
|
|
||||||
(toggle still available to disable).
|
|
||||||
4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get
|
|
||||||
back later. Not blocking: see risk R10 mitigation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Effort estimate
|
|
||||||
|
|
||||||
| Phase | Rough size |
|
|
||||||
|-------|-----------|
|
|
||||||
| 0 spikes | ~half day (mostly reading + one throwaway branch) |
|
|
||||||
| 1 skeleton | ~300 lines Python |
|
|
||||||
| 2 models (incl. shared export/merge helpers) | ~600–800 lines Python |
|
|
||||||
| 3 views (offline signer page first) | ~900–1300 lines QML |
|
|
||||||
| 4 android | fork-side patches + build iteration (device-dependent) |
|
|
||||||
| 5 tests | ~500 lines |
|
|
||||||
| 6 release/docs | small |
|
|
||||||
|
|
||||||
Overall: comparable to a medium feature, dominated by Phase 3 UI polish and
|
|
||||||
Phase 4 device iteration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Findings log (append-only)
|
|
||||||
|
|
||||||
- **F12** — The airgap round trip already exists in the Qt GUI layer and can
|
|
||||||
be ported almost verbatim: `export_json_file()` (window.py:1605) exports
|
|
||||||
all will items as `{wid: WillItem.to_dict()}` JSON (marking them
|
|
||||||
`EXPORTED`); the import side goes through `merge_will()`
|
|
||||||
(window.py:1620), which carries operational statuses, combines partial
|
|
||||||
signatures via `tx.combine_with_other_psbt()` when txids match, substitutes
|
|
||||||
the tx otherwise, and recomputes validity locally without network.
|
|
||||||
Conclusion: no new transfer format is needed; the plan is to give this
|
|
||||||
logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML
|
|
||||||
all use one implementation.
|
|
||||||
|
|
||||||
### Decisions
|
|
||||||
|
|
||||||
- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by
|
|
||||||
the owner.
|
|
||||||
- **D2** — Android = offline signing device; sign/import/export flow has
|
|
||||||
top priority.
|
|
||||||
- **D3** — BAL enabled by default in the custom APK.
|
|
||||||
- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10).
|
|
||||||
@@ -94,7 +94,9 @@ 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); the import flow reviews and sign each transaction
|
(150–1800 bytes/frame) and ships the default **BAL QR** format already
|
||||||
|
compressed whenever that is smaller (best-of zlib, flag per frame); the import
|
||||||
|
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
|
||||||
|
|||||||
10
android/.gitignore
vendored
Normal file
10
android/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.hprof
|
||||||
153
android/README.md
Normal file
153
android/README.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# 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.
|
||||||
67
android/app/build.gradle.kts
Normal file
67
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("com.chaquo.python")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "life.after.bitcoin"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "life.after.bitcoin"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
// Chaquopy requires explicit ABI filters. Python 3.12 ships only for
|
||||||
|
// 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64).
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chaquopy {
|
||||||
|
defaultConfig {
|
||||||
|
version = "3.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
|
||||||
|
// CameraX
|
||||||
|
implementation("androidx.camera:camera-core:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-view:1.3.4")
|
||||||
|
|
||||||
|
// ML Kit on-device barcode scanning (QR only, no API key)
|
||||||
|
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||||
|
}
|
||||||
5
android/app/proguard-rules.pro
vendored
Normal file
5
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Chaquopy Python runtime.
|
||||||
|
-keep class com.chaquo.python.** { *; }
|
||||||
|
|
||||||
|
# ML Kit barcode scanning.
|
||||||
|
-keep class com.google.mlkit.** { *; }
|
||||||
31
android/app/src/main/AndroidManifest.xml
Normal file
31
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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>
|
||||||
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
209
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
209
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
android/app/src/main/python/bal/core/__init__.py
Normal file
21
android/app/src/main/python/bal/core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
1180
android/app/src/main/python/bal/core/animated_qr.py
Normal file
1180
android/app/src/main/python/bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
304
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
304
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""
|
||||||
|
bal.core.qrtransfer
|
||||||
|
===================
|
||||||
|
|
||||||
|
GUI-free helpers for moving BAL will data between devices via QR codes or
|
||||||
|
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
|
||||||
|
|
||||||
|
Scope
|
||||||
|
-----
|
||||||
|
* converts will transactions into a compact ``transfer_string``
|
||||||
|
(newline-joined serialized transactions, optionally zlib + base64
|
||||||
|
compressed);
|
||||||
|
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||||
|
multi-QR export, and reassembles/validates them on import.
|
||||||
|
|
||||||
|
Wire format (v2, compact)
|
||||||
|
-------------------------
|
||||||
|
A frame is::
|
||||||
|
|
||||||
|
BAL1<TTT><iii><flag><payload>
|
||||||
|
|
||||||
|
* ``BAL1`` - magic + format era (4 chars).
|
||||||
|
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||||
|
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||||
|
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||||
|
* ``payload`` - every other character of the frame; the payloads of all
|
||||||
|
frames, concatenated in index order, rebuild the transfer string.
|
||||||
|
|
||||||
|
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||||
|
(same 5 pieces of information) without any pipe separator, so the whole
|
||||||
|
frame is scan-friendly and the overhead no longer grows with the frame
|
||||||
|
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||||
|
|
||||||
|
The audio-modem channel deliberately bypasses the framing helpers here
|
||||||
|
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||||
|
carries the whole transfer string in a single blob, so callers only use
|
||||||
|
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||||
|
|
||||||
|
This module never imports Qt or any Electrum GUI code (house rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
MAGIC = "BALQR"
|
||||||
|
VERSION = 1
|
||||||
|
FLAG_COMPRESSED = "Z"
|
||||||
|
FLAG_PLAIN = "0"
|
||||||
|
|
||||||
|
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||||
|
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||||
|
CHUNK_PRESETS = (
|
||||||
|
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||||
|
("Medium - ~400 bytes/QR", 400),
|
||||||
|
("Large - ~900 bytes/QR", 900),
|
||||||
|
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Smallest allowed payload budget per frame, below which the frame header
|
||||||
|
# could consume the whole budget.
|
||||||
|
MIN_CHUNK_SIZE = 40
|
||||||
|
|
||||||
|
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||||
|
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||||
|
|
||||||
|
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||||
|
_FRAME_MAGIC_V2 = "BAL1"
|
||||||
|
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
_BASE36_WIDTH = 3
|
||||||
|
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||||
|
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||||
|
|
||||||
|
|
||||||
|
class QrTransferError(ValueError):
|
||||||
|
"""Base error for will QR / audio transfer processing."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingFramesError(QrTransferError):
|
||||||
|
"""Some frame indices of a multi-QR transfer are missing."""
|
||||||
|
|
||||||
|
def __init__(self, missing):
|
||||||
|
self.missing = list(missing)
|
||||||
|
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||||
|
|
||||||
|
|
||||||
|
class InconsistentTotalError(QrTransferError):
|
||||||
|
"""Frames disagree about the advertised frame total."""
|
||||||
|
|
||||||
|
|
||||||
|
def encode_transfer(tx_strings, compress=False):
|
||||||
|
"""Join serialized transaction strings into a transfer string.
|
||||||
|
|
||||||
|
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||||
|
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||||
|
of the frame header lets the importer reverse this automatically.
|
||||||
|
"""
|
||||||
|
return __compress("\n".join(tx_strings), enabled=compress)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_transfer_best(tx_strings):
|
||||||
|
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||||
|
|
||||||
|
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||||
|
when zlib + base64 really is shorter (best-of, never larger).
|
||||||
|
"""
|
||||||
|
joined = "\n".join(tx_strings)
|
||||||
|
plain = joined
|
||||||
|
compressed = __compress(joined, enabled=True)
|
||||||
|
if len(compressed) < len(plain):
|
||||||
|
return compressed, True
|
||||||
|
return plain, False
|
||||||
|
|
||||||
|
|
||||||
|
def decode_transfer(transfer_string, compressed):
|
||||||
|
"""Inverse of :func:`encode_transfer`.
|
||||||
|
|
||||||
|
Returns the list of serialized transaction strings; empty frames are
|
||||||
|
dropped so a trailing newline (or an empty payload) cannot produce an
|
||||||
|
empty trailing element.
|
||||||
|
"""
|
||||||
|
text = __decompress(transfer_string, enabled=compressed)
|
||||||
|
return [part for part in text.split("\n") if part]
|
||||||
|
|
||||||
|
|
||||||
|
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||||
|
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||||
|
|
||||||
|
Every returned frame has the fixed 11-char v2 header followed by its
|
||||||
|
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||||
|
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||||
|
importer knows how to reverse the encoding.
|
||||||
|
|
||||||
|
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||||
|
the header plus any payload, or when the transfer needs more than
|
||||||
|
:data:`_MAX_TOTAL` frames.
|
||||||
|
"""
|
||||||
|
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||||
|
total = __compute_total(len(transfer_string), chunk_size)
|
||||||
|
budget = chunk_size - _HEADER_V2_LEN
|
||||||
|
frames = []
|
||||||
|
pos = 0
|
||||||
|
length = len(transfer_string)
|
||||||
|
for index in range(1, total + 1):
|
||||||
|
end = min(pos + budget, length)
|
||||||
|
frames.append(
|
||||||
|
_FRAME_MAGIC_V2
|
||||||
|
+ _base36(total)
|
||||||
|
+ _base36(index)
|
||||||
|
+ flag
|
||||||
|
+ transfer_string[pos:end]
|
||||||
|
)
|
||||||
|
pos = end
|
||||||
|
if pos < length:
|
||||||
|
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||||
|
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frame(frame):
|
||||||
|
"""Parse a single frame.
|
||||||
|
|
||||||
|
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||||
|
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||||
|
|
||||||
|
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||||
|
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||||
|
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||||
|
"""
|
||||||
|
if frame.startswith(_FRAME_MAGIC_V2):
|
||||||
|
return _parse_v2(frame)
|
||||||
|
return _parse_v1(frame)
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(frames, total):
|
||||||
|
"""Concatenate frame payloads back into a transfer string.
|
||||||
|
|
||||||
|
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||||
|
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||||
|
``total`` (else :class:`InconsistentTotalError`).
|
||||||
|
"""
|
||||||
|
if total < 1:
|
||||||
|
raise QrTransferError("invalid frame total")
|
||||||
|
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||||
|
if missing:
|
||||||
|
raise MissingFramesError(missing)
|
||||||
|
extra = [index for index in frames if index > total]
|
||||||
|
if extra:
|
||||||
|
raise InconsistentTotalError()
|
||||||
|
return "".join(frames[index] for index in range(1, total + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def preset_index_for_chunk_size(chunk_size):
|
||||||
|
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||||
|
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||||
|
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||||
|
diff = abs(chunk_size - budget)
|
||||||
|
if diff < best_diff:
|
||||||
|
best, best_diff = index, diff
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Internals
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def __compress(text, *, enabled):
|
||||||
|
if not enabled:
|
||||||
|
return text
|
||||||
|
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def __decompress(text, *, enabled):
|
||||||
|
if not enabled:
|
||||||
|
return text
|
||||||
|
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _base36(n):
|
||||||
|
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||||
|
if not 0 <= n <= _MAX_TOTAL:
|
||||||
|
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||||
|
chars = []
|
||||||
|
for _ in range(_BASE36_WIDTH):
|
||||||
|
chars.append(_BASE36_DIGITS[n % 36])
|
||||||
|
n //= 36
|
||||||
|
return "".join(reversed(chars))
|
||||||
|
|
||||||
|
|
||||||
|
def _base36_decode(text):
|
||||||
|
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||||
|
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||||
|
raise ValueError(text)
|
||||||
|
n = 0
|
||||||
|
for c in text:
|
||||||
|
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v1(frame):
|
||||||
|
parts = frame.split("|", maxsplit=4)
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||||
|
magic_seen, total_s, index_s, flags, payload = parts
|
||||||
|
if magic_seen != _FRAME_MAGIC_V1:
|
||||||
|
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||||
|
try:
|
||||||
|
total = int(total_s)
|
||||||
|
index = int(index_s)
|
||||||
|
except ValueError as e:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||||
|
if total < 1 or not 1 <= index <= total:
|
||||||
|
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||||
|
if flags not in ("", FLAG_COMPRESSED):
|
||||||
|
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||||
|
return total, index, flags == FLAG_COMPRESSED, payload
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v2(frame):
|
||||||
|
if len(frame) < _HEADER_V2_LEN:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||||
|
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||||
|
# make up the rest of the fixed header.
|
||||||
|
offset = len(_FRAME_MAGIC_V2)
|
||||||
|
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||||
|
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||||
|
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||||
|
try:
|
||||||
|
total = _base36_decode(total_s)
|
||||||
|
index = _base36_decode(index_s)
|
||||||
|
except ValueError:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||||
|
if total < 1 or not 1 <= index <= total:
|
||||||
|
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||||
|
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||||
|
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||||
|
payload = frame[_HEADER_V2_LEN:]
|
||||||
|
return total, index, flag == FLAG_COMPRESSED, payload
|
||||||
|
|
||||||
|
|
||||||
|
def __compute_total(transfer_len, chunk_size):
|
||||||
|
"""Smallest frame count whose budget covers the whole transfer string.
|
||||||
|
|
||||||
|
The v2 header is fixed-width, so the budget is constant and the count is
|
||||||
|
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||||
|
"""
|
||||||
|
if chunk_size < MIN_CHUNK_SIZE:
|
||||||
|
raise QrTransferError(
|
||||||
|
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||||
|
)
|
||||||
|
budget = chunk_size - _HEADER_V2_LEN
|
||||||
|
if budget <= 0:
|
||||||
|
raise QrTransferError(
|
||||||
|
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||||
|
)
|
||||||
|
total = -(-transfer_len // budget)
|
||||||
|
if total < 1:
|
||||||
|
total = 1
|
||||||
|
if total > _MAX_TOTAL:
|
||||||
|
raise QrTransferError(
|
||||||
|
"BAL QR transfer demands too many frames: {}".format(total)
|
||||||
|
)
|
||||||
|
return total
|
||||||
1
android/app/src/main/python/balreader/__init__.py
Normal file
1
android/app/src/main/python/balreader/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Android reader helpers built on the bundled plugin codecs."""
|
||||||
33
android/app/src/main/python/balreader/bridge.py
Normal file
33
android/app/src/main/python/balreader/bridge.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""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": []})
|
||||||
33
android/app/src/main/python/balreader/payload.py
Normal file
33
android/app/src/main/python/balreader/payload.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""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)
|
||||||
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?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>
|
||||||
59
android/app/src/main/res/layout/activity_main.xml
Normal file
59
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?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>
|
||||||
67
android/app/src/main/res/layout/activity_result.xml
Normal file
67
android/app/src/main/res/layout/activity_result.xml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?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>
|
||||||
5
android/app/src/main/res/values/colors.xml
Normal file
5
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="frame_fill">#9BE8C0</color>
|
||||||
|
<color name="frame_track">#3A3A3A</color>
|
||||||
|
</resources>
|
||||||
24
android/app/src/main/res/values/strings.xml
Normal file
24
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<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>
|
||||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||||
|
</resources>
|
||||||
7
android/build.gradle.kts
Normal file
7
android/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
5
android/gradle.properties
Normal file
5
android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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
Executable file
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/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
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@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
|
||||||
161
android/scripts/build_apk.py
Executable file
161
android/scripts/build_apk.py
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
#!/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:]))
|
||||||
99
android/scripts/sync_codecs.py
Normal file
99
android/scripts/sync_codecs.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/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:]))
|
||||||
23
android/settings.gradle.kts
Normal file
23
android/settings.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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")
|
||||||
314
android/test_chain/verify_chain.py
Normal file
314
android/test_chain/verify_chain.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
#!/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())
|
||||||
202
android/tools/emitter.py
Normal file
202
android/tools/emitter.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""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:])
|
||||||
26
android/tools/sample_will.json
Normal file
26
android/tools/sample_will.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ from ..core.checkalive import (
|
|||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
check_alive_expired,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
|
resolve_guard_threshold,
|
||||||
)
|
)
|
||||||
from ..core.heirs import Heirs, is_op_return_address
|
from ..core.heirs import Heirs, is_op_return_address
|
||||||
from ..core.plugin_base import BalConfig, BalPlugin
|
from ..core.plugin_base import BalConfig, BalPlugin
|
||||||
@@ -314,6 +315,28 @@ class BalController:
|
|||||||
executor.
|
executor.
|
||||||
"""
|
"""
|
||||||
will = {}
|
will = {}
|
||||||
|
# Drop stale wallet-LOCAL will placeholders (mirror of the GUI
|
||||||
|
# build_will) so their coins are available to this build.
|
||||||
|
Will.remove_stale_wallet_history(
|
||||||
|
self.wallet, self.plugin.HISTORY_LABEL.get()
|
||||||
|
)
|
||||||
|
# A (re)build may have anticipated the delivery (shorter heir recipes)
|
||||||
|
# while ``date_to_check`` is still anchored to the OLD built will.
|
||||||
|
# Recompute it for the will being built (earliest future delivery among
|
||||||
|
# the CURRENT heirs), mirroring ``BalWindow.build_will``, so the
|
||||||
|
# anticipated dates pass the build filter.
|
||||||
|
_new_locktime = min(
|
||||||
|
(
|
||||||
|
Util.parse_locktime_string(h[2])
|
||||||
|
for h in self.heirs.values()
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
if _new_locktime:
|
||||||
|
self.date_to_check = resolve_date_to_check(
|
||||||
|
self.plugin.is_basic_mode(), self.will_settings,
|
||||||
|
built_locktime=_new_locktime,
|
||||||
|
)
|
||||||
self.willexecutors = Willexecutors.get_willexecutors(
|
self.willexecutors = Willexecutors.get_willexecutors(
|
||||||
self.plugin, update=False, task=False
|
self.plugin, update=False, task=False
|
||||||
)
|
)
|
||||||
@@ -434,7 +457,13 @@ class BalController:
|
|||||||
raise _user_facing(e) from e
|
raise _user_facing(e) from e
|
||||||
|
|
||||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||||
if locktime < date_to_check:
|
threshold_ts = resolve_guard_threshold(
|
||||||
|
self.plugin.is_basic_mode(), self.will_settings
|
||||||
|
)
|
||||||
|
if threshold_ts is not None:
|
||||||
|
if locktime < threshold_ts:
|
||||||
|
raise UserFacingException(_("locktime is lower than threshold"))
|
||||||
|
elif locktime < date_to_check:
|
||||||
raise UserFacingException(_("locktime is lower than threshold"))
|
raise UserFacingException(_("locktime is lower than threshold"))
|
||||||
|
|
||||||
if not self.no_willexecutor:
|
if not self.no_willexecutor:
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ used to move BAL will data between devices.
|
|||||||
Supported wire formats (each self-describing and order-independent on
|
Supported wire formats (each self-describing and order-independent on
|
||||||
receive):
|
receive):
|
||||||
|
|
||||||
* **BALQR** (native, unchanged): ``BALQR1|total|index|flags|payload``.
|
* **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)::
|
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
|
||||||
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
||||||
Fragments partition the BC32 rendering of the CBOR byte string; the
|
Fragments partition the BC32 rendering of the CBOR byte string; the
|
||||||
@@ -790,7 +792,7 @@ def detect_format(text: str) -> Optional[str]:
|
|||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
if lowered.startswith("balqr"):
|
if lowered.startswith(("balqr", "bal1")):
|
||||||
return "balqr"
|
return "balqr"
|
||||||
if text.startswith(_BBQR_PREFIX):
|
if text.startswith(_BBQR_PREFIX):
|
||||||
return "bbqr"
|
return "bbqr"
|
||||||
|
|||||||
@@ -96,6 +96,54 @@ def resolve_date_to_check(
|
|||||||
return threshold.to_timestamp()
|
return threshold.to_timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_guard_threshold(
|
||||||
|
is_basic_mode: bool,
|
||||||
|
will_settings: Any,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""Resolve the "locktime is lower than threshold" guard's reference.
|
||||||
|
|
||||||
|
The guard compares the stored settings on ONE reference frame: the
|
||||||
|
delivery (``locktime``, kept as at the call site) against this threshold.
|
||||||
|
|
||||||
|
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the
|
||||||
|
built will's frozen tx locktime so that an unchanged will never reads as
|
||||||
|
expired -- this helper resolves the threshold from the **stored settings
|
||||||
|
alone**. Otherwise, when the stored relative locktime is shorter than the
|
||||||
|
frozen locktime of an old (still valid) built will (e.g. the delivery was
|
||||||
|
shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh
|
||||||
|
"1y" locktime against the old will's anchored threshold and wrongly fire,
|
||||||
|
even though locktime > threshold by the settings themselves.
|
||||||
|
|
||||||
|
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls
|
||||||
|
back to comparing the locktime against ``date_to_check`` (= now), so its
|
||||||
|
behaviour is unchanged.
|
||||||
|
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
|
||||||
|
as-is.
|
||||||
|
* ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning
|
||||||
|
"N days BEFORE the delivery"): the threshold is anchored to the locktime
|
||||||
|
resolved forward from *now* (the settings' own delivery reading, never a
|
||||||
|
built tx), keeping both sides of the comparison in the same reference
|
||||||
|
frame, as the settings widget displays it.
|
||||||
|
|
||||||
|
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
|
||||||
|
missing stored value).
|
||||||
|
"""
|
||||||
|
if is_basic_mode:
|
||||||
|
return None
|
||||||
|
threshold_raw = will_settings.get("threshold")
|
||||||
|
if threshold_raw is None:
|
||||||
|
return None
|
||||||
|
threshold = BalTimestamp(threshold_raw)
|
||||||
|
if threshold.unit is None:
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
now_dt = (
|
||||||
|
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None
|
||||||
|
)
|
||||||
|
locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt)
|
||||||
|
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
||||||
|
|
||||||
|
|
||||||
def check_alive_expired(
|
def check_alive_expired(
|
||||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -10,9 +10,27 @@ 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);
|
||||||
* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for
|
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||||
multi-QR export, and reassembles/validates them on import.
|
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
|
The audio-modem channel deliberately bypasses the framing helpers here
|
||||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
(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
|
carries the whole transfer string in a single blob, so callers only use
|
||||||
@@ -29,6 +47,7 @@ import zlib
|
|||||||
MAGIC = "BALQR"
|
MAGIC = "BALQR"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
FLAG_COMPRESSED = "Z"
|
FLAG_COMPRESSED = "Z"
|
||||||
|
FLAG_PLAIN = "0"
|
||||||
|
|
||||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
# 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).
|
||||||
@@ -43,7 +62,15 @@ CHUNK_PRESETS = (
|
|||||||
# could consume the whole budget.
|
# could consume the whole budget.
|
||||||
MIN_CHUNK_SIZE = 40
|
MIN_CHUNK_SIZE = 40
|
||||||
|
|
||||||
_FRAME_MAGIC = MAGIC + str(VERSION)
|
# 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):
|
class QrTransferError(ValueError):
|
||||||
@@ -66,12 +93,26 @@ 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 flags
|
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||||
of the frame header let the importer reverse this automatically.
|
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`.
|
||||||
|
|
||||||
@@ -84,28 +125,33 @@ def decode_transfer(transfer_string, compressed):
|
|||||||
|
|
||||||
|
|
||||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||||
"""Split ``transfer_string`` into full ``BALQR`` frames.
|
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||||
|
|
||||||
Every returned frame is at most ``chunk_size`` characters long (header
|
Every returned frame has the fixed 11-char v2 header followed by its
|
||||||
included). ``compressed`` propagates the ``Z`` flag into every frame so
|
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||||
the importer knows how to reverse the encoding.
|
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
|
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||||
the header plus any payload.
|
the header plus any payload, or when the transfer needs more than
|
||||||
|
:data:`_MAX_TOTAL` frames.
|
||||||
"""
|
"""
|
||||||
flags = FLAG_COMPRESSED if compressed else ""
|
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||||
total = __compute_total(len(transfer_string), chunk_size, flags)
|
total = __compute_total(len(transfer_string), chunk_size)
|
||||||
|
budget = chunk_size - _HEADER_V2_LEN
|
||||||
frames = []
|
frames = []
|
||||||
pos = 0
|
pos = 0
|
||||||
length = len(transfer_string)
|
length = len(transfer_string)
|
||||||
for index in range(1, total + 1):
|
for index in range(1, total + 1):
|
||||||
overhead = len(__frame_header(total, index, flags))
|
|
||||||
budget = chunk_size - overhead
|
|
||||||
end = min(pos + budget, length)
|
end = min(pos + budget, length)
|
||||||
frames.append(__build_frame(total, index, flags, transfer_string[pos:end]))
|
frames.append(
|
||||||
|
_FRAME_MAGIC_V2
|
||||||
|
+ _base36(total)
|
||||||
|
+ _base36(index)
|
||||||
|
+ flag
|
||||||
|
+ transfer_string[pos:end]
|
||||||
|
)
|
||||||
pos = end
|
pos = end
|
||||||
if pos >= length:
|
|
||||||
break
|
|
||||||
if pos < length:
|
if pos < length:
|
||||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||||
@@ -115,26 +161,16 @@ def split_frames(transfer_string, chunk_size, compressed=False):
|
|||||||
def parse_frame(frame):
|
def parse_frame(frame):
|
||||||
"""Parse a single 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
|
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||||
"""
|
"""
|
||||||
parts = frame.split("|", maxsplit=4)
|
if frame.startswith(_FRAME_MAGIC_V2):
|
||||||
if len(parts) != 5:
|
return _parse_v2(frame)
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
return _parse_v1(frame)
|
||||||
magic_seen, total_s, index_s, flags, payload = parts
|
|
||||||
if magic_seen != _FRAME_MAGIC:
|
|
||||||
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 assemble(frames, total):
|
def assemble(frames, total):
|
||||||
@@ -181,32 +217,88 @@ def __decompress(text, *, enabled):
|
|||||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def __frame_header(total, index, flags):
|
def _base36(n):
|
||||||
return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags)
|
"""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 __build_frame(total, index, flags, payload):
|
def _base36_decode(text):
|
||||||
return __frame_header(total, index, flags) + payload
|
"""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 __compute_total(transfer_len, chunk_size, flags):
|
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.
|
"""Smallest frame count whose budget covers the whole transfer string.
|
||||||
|
|
||||||
The budget shrinks as ``total`` gains digits (wider header), so the count
|
The v2 header is fixed-width, so the budget is constant and the count is
|
||||||
is recomputed iteratively until it converges.
|
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||||
"""
|
"""
|
||||||
if chunk_size < MIN_CHUNK_SIZE:
|
if chunk_size < MIN_CHUNK_SIZE:
|
||||||
raise QrTransferError(
|
raise QrTransferError(
|
||||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||||
)
|
)
|
||||||
total = 1
|
budget = chunk_size - _HEADER_V2_LEN
|
||||||
while True:
|
if budget <= 0:
|
||||||
overhead = len(__frame_header(total, total, flags))
|
raise QrTransferError(
|
||||||
budget = chunk_size - overhead
|
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||||
if budget <= 0:
|
)
|
||||||
raise QrTransferError(
|
total = -(-transfer_len // budget)
|
||||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
if total < 1:
|
||||||
)
|
total = 1
|
||||||
if transfer_len <= budget * total:
|
if total > _MAX_TOTAL:
|
||||||
return total
|
raise QrTransferError(
|
||||||
total += 1
|
"BAL QR transfer demands too many frames: {}".format(total)
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
from electrum.logging import Logger, get_logger
|
from electrum.logging import Logger, get_logger
|
||||||
from electrum.transaction import (
|
from electrum.transaction import (
|
||||||
@@ -847,6 +848,48 @@ class Will:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"save_valid_transactions_to_history failed: {e}")
|
_logger.error(f"save_valid_transactions_to_history failed: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_stale_wallet_history(wallet, history_label):
|
||||||
|
"""Delete wallet-LOCAL will transactions saved under ``history_label``.
|
||||||
|
|
||||||
|
``save_valid_transactions_to_history`` stores the not-yet-signed
|
||||||
|
inheritance txs into the wallet's local history; those local
|
||||||
|
placeholders nominally spend the coins they reference. When the will is
|
||||||
|
REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the
|
||||||
|
stale placeholders must be removed so the coins become available again
|
||||||
|
to the new build (see ``Util.get_available_utxos``). Only
|
||||||
|
wallet-local/future (non-broadcast) txs whose label matches the history
|
||||||
|
label template are removed; confirmed/broadcast history is never
|
||||||
|
touched. Returns the txids that were removed.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return []
|
||||||
|
removed = []
|
||||||
|
for txid, label in Will._wallet_labels(wallet):
|
||||||
|
if not label or not Util._label_matches_history(label, history_label):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
height = int(wallet.adb.get_tx_height(txid).height())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wallet.adb.remove_transaction(txid)
|
||||||
|
removed.append(txid)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"remove from history failed for {txid}: {e}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wallet.set_label(txid, None)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"set_label failed for {txid}: {e}")
|
||||||
|
try:
|
||||||
|
wallet.save_db()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save_db failed after history purge: {e}")
|
||||||
|
return removed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _add_transaction_to_history(wallet, tx, txid):
|
def _add_transaction_to_history(wallet, tx, txid):
|
||||||
"""Store *tx* into the wallet's local history via ``adb``.
|
"""Store *tx* into the wallet's local history via ``adb``.
|
||||||
@@ -1213,9 +1256,9 @@ class Will:
|
|||||||
|
|
||||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||||
count_heirs += 1
|
count_heirs += 1
|
||||||
if h not in heirs_found:
|
if h not in heirs_found:
|
||||||
_logger.debug(f"heir: {h} not found")
|
_logger.debug(f"heir: {h} not found")
|
||||||
raise HeirNotFoundException(h)
|
raise HeirNotFoundException(h)
|
||||||
if not count_heirs:
|
if not count_heirs:
|
||||||
raise NoHeirsException("there are not valid heirs")
|
raise NoHeirsException("there are not valid heirs")
|
||||||
if self_willexecutor and no_willexecutor == 0:
|
if self_willexecutor and no_willexecutor == 0:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ...core.qrtransfer import (
|
|||||||
QrTransferError,
|
QrTransferError,
|
||||||
decode_transfer,
|
decode_transfer,
|
||||||
encode_transfer,
|
encode_transfer,
|
||||||
|
encode_transfer_best,
|
||||||
preset_index_for_chunk_size,
|
preset_index_for_chunk_size,
|
||||||
split_frames,
|
split_frames,
|
||||||
)
|
)
|
||||||
@@ -1135,13 +1136,14 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
desired behaviour, and that the date shown in the panel/wizard must
|
desired behaviour, and that the date shown in the panel/wizard must
|
||||||
reflect this anticipated date (so the calendar .ics also uses it).
|
reflect this anticipated date (so the calendar .ics also uses it).
|
||||||
|
|
||||||
RELATIVE dates are additionally normalised here: a relative value
|
RELATIVE recipes ("30d"/"1y") are PRESERVED: they are resolved against
|
||||||
("30d"/"1y") is re-parsed against "now" on every check, so it drifts
|
the built will's frozen locktime on every check (via
|
||||||
away from the fixed transaction locktime and the postpone check would
|
``Util.resolve_locktime_against_tx`` for the postpone detection and
|
||||||
wrongly ask to invalidate the will every day. The stored locktime is
|
``resolve_date_to_check(..., built_locktime=...)`` for the reference
|
||||||
therefore frozen to the built transactions' absolute locktime, and a
|
timestamp), so they no longer drift away from the built transactions
|
||||||
relative threshold is frozen to its "N days before the delivery"
|
and never trigger the daily invalidate prompt. Freezing them to an
|
||||||
absolute value.
|
absolute timestamp here would silently erase the user's relative
|
||||||
|
choice from WILL_SETTINGS.
|
||||||
|
|
||||||
We route the update through BalWindow.update_setting_widgets, which is
|
We route the update through BalWindow.update_setting_widgets, which is
|
||||||
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
||||||
@@ -1156,72 +1158,46 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
return
|
return
|
||||||
min_locktime = int(min_locktime)
|
min_locktime = int(min_locktime)
|
||||||
stored_locktime = self.bal_window.will_settings["locktime"]
|
stored_locktime = self.bal_window.will_settings["locktime"]
|
||||||
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
|
# A RELATIVE stored value ("30d"/"1y") is PRESERVED: it is resolved
|
||||||
# against "now" on every check, so it drifts one day per day away from
|
# against the built transactions on every check (the post-build
|
||||||
# the fixed tx locktime and the postpone check would ALWAYS see a
|
# `resolve_date_to_check` anchoring and `resolve_locktime_against_tx`
|
||||||
# postpone -> the plugin asks to invalidate the will every day. It must
|
# in the postpone detection), so it no longer drifts and must not be
|
||||||
# therefore be normalised here to the frozen absolute locktime of the
|
# frozen to an absolute timestamp here. Only an ABSOLUTE stored value
|
||||||
# built transactions, even when it happens to parse to the same moment
|
# is compared with the built transactions (see below).
|
||||||
# today. (Only an absolute stored value is comparable, see below.)
|
|
||||||
is_relative_locktime = (
|
is_relative_locktime = (
|
||||||
isinstance(stored_locktime, str)
|
isinstance(stored_locktime, str)
|
||||||
and stored_locktime[-1:].lower() in ("d", "y")
|
and stored_locktime[-1:].lower() in ("d", "y")
|
||||||
)
|
)
|
||||||
# Current stored delivery date, as a comparable UNIX timestamp.
|
if not is_relative_locktime:
|
||||||
try:
|
# Current stored delivery date, as a comparable UNIX timestamp.
|
||||||
current = int(Util.parse_locktime_string(stored_locktime))
|
|
||||||
except Exception:
|
|
||||||
# If the stored value cannot be parsed, fall back to syncing.
|
|
||||||
current = None
|
|
||||||
# A genuine user-chosen POSTPONE (a later absolute date) is never
|
|
||||||
# overwritten; anything else is synced to the built transactions.
|
|
||||||
was_anticipation = current is not None and min_locktime < current
|
|
||||||
if not is_relative_locktime and current is not None and not was_anticipation:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
_logger.debug(
|
|
||||||
f"sync delivery date to built tx locktime: "
|
|
||||||
f"{current} -> {min_locktime}"
|
|
||||||
)
|
|
||||||
# Remember that we anticipated the date, so the later sign prompt can
|
|
||||||
# explain WHY signing is needed (see on_success_phase1). A pure
|
|
||||||
# relative->absolute normalisation is NOT an anticipation.
|
|
||||||
if was_anticipation:
|
|
||||||
self._date_was_anticipated = True
|
|
||||||
# update_setting_widgets stores the value, persists it and refreshes
|
|
||||||
# the date widgets in all panels/wizard (the .ics calendar too).
|
|
||||||
self.bal_window.update_setting_widgets(
|
|
||||||
min_locktime, "locktime", update_all=True
|
|
||||||
)
|
|
||||||
# Same moving-target problem for a relative "Check Alive" threshold:
|
|
||||||
# it means "N days BEFORE the delivery" (the settings widget resolves it
|
|
||||||
# as real_threshold = locktime - N days), so it is normalised to that
|
|
||||||
# absolute date, referenced against the now-absolute stored locktime.
|
|
||||||
threshold_raw = self.bal_window.will_settings.get("threshold")
|
|
||||||
if (
|
|
||||||
isinstance(threshold_raw, str)
|
|
||||||
and threshold_raw[-1:].lower() in ("d", "y")
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
locktime_ts = int(
|
current = int(Util.parse_locktime_string(stored_locktime))
|
||||||
Util.parse_locktime_string(
|
except Exception:
|
||||||
self.bal_window.will_settings["locktime"]
|
# If the stored value cannot be parsed, fall back to syncing.
|
||||||
)
|
current = None
|
||||||
)
|
# A genuine user-chosen POSTPONE (a later absolute date) is never
|
||||||
real_threshold = int(
|
# overwritten; a genuine automatic ANTICIPATION (built earlier
|
||||||
BalTimestamp(threshold_raw)
|
# than stored) is synced to the built transactions.
|
||||||
.to_date(locktime_ts, reverse=True)
|
was_anticipation = current is not None and min_locktime < current
|
||||||
.timestamp()
|
if was_anticipation:
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"sync threshold to absolute failed: {e}")
|
|
||||||
else:
|
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
|
f"sync delivery date to built tx locktime: "
|
||||||
|
f"{current} -> {min_locktime}"
|
||||||
)
|
)
|
||||||
|
# Remember that we anticipated the date, so the later sign
|
||||||
|
# prompt can explain WHY signing is needed
|
||||||
|
# (see on_success_phase1).
|
||||||
|
self._date_was_anticipated = True
|
||||||
|
# update_setting_widgets stores the value, persists it and
|
||||||
|
# refreshes the date widgets in all panels/wizard (the .ics
|
||||||
|
# calendar too).
|
||||||
self.bal_window.update_setting_widgets(
|
self.bal_window.update_setting_widgets(
|
||||||
real_threshold, "threshold", update_all=True
|
min_locktime, "locktime", update_all=True
|
||||||
)
|
)
|
||||||
|
# A relative "Check Alive" threshold ("N days BEFORE the delivery") is
|
||||||
|
# also PRESERVED: it is anchored on every check by
|
||||||
|
# ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does
|
||||||
|
# not need to be frozen to an absolute date here.
|
||||||
|
|
||||||
def on_accept(self):
|
def on_accept(self):
|
||||||
try:
|
try:
|
||||||
@@ -2824,6 +2800,12 @@ class BalQrExportWidget(QWidget):
|
|||||||
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()
|
||||||
@@ -2835,7 +2817,9 @@ class BalQrExportWidget(QWidget):
|
|||||||
def _refresh_frames(self):
|
def _refresh_frames(self):
|
||||||
if self.format == "balqr":
|
if self.format == "balqr":
|
||||||
self.frames = split_frames(
|
self.frames = split_frames(
|
||||||
self.transfer, self.chunk_size, compressed=False
|
self._balqr_transfer,
|
||||||
|
self.chunk_size,
|
||||||
|
compressed=self._balqr_compressed,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.frames = encode_animated_frames(
|
self.frames = encode_animated_frames(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Contents:
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ...core.heirs import get_op_return_hex, is_op_return_address
|
||||||
from ...core.input_rules import (
|
from ...core.input_rules import (
|
||||||
LockTimeEditor,
|
LockTimeEditor,
|
||||||
normalize_locktime_raw_text,
|
normalize_locktime_raw_text,
|
||||||
@@ -611,9 +612,14 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
|||||||
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
||||||
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
||||||
_dt = BalTimestamp._safe_fromtimestamp(x)
|
_dt = BalTimestamp._safe_fromtimestamp(x)
|
||||||
#if self.alarm != dt:
|
|
||||||
self.setDateTime(_dt)
|
|
||||||
self.alarm = _dt
|
self.alarm = _dt
|
||||||
|
# Store the LOCAL wall-clock time, not the aware-UTC datetime:
|
||||||
|
# QDateTimeEdit keeps the given wall time with a LocalTime spec, so
|
||||||
|
# an aware-UTC datetime would make get_value() read back a timezone-
|
||||||
|
# shifted epoch. That broke the set_value -> get_value roundtrip and
|
||||||
|
# kept the valueEdited -> update_setting_widgets -> set_value cycle
|
||||||
|
# firing forever (infinite RecursionError on wizard "Next").
|
||||||
|
self.setDateTime(_dt.astimezone().replace(tzinfo=None))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1331,14 +1337,28 @@ class WillWidget(QWidget):
|
|||||||
)
|
)
|
||||||
detaillayout.addWidget(QLabel(""))
|
detaillayout.addWidget(QLabel(""))
|
||||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||||
for heir in self.will[w].heirs:
|
for heir_name in self.will[w].heirs:
|
||||||
if 'w!ll3x3c"' not in heir:
|
if 'w!ll3x3c"' in heir_name:
|
||||||
decoded_amount = Util.decode_amount(
|
continue
|
||||||
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
h = self.will[w].heirs[heir_name]
|
||||||
)
|
decoded_amount = Util.decode_amount(
|
||||||
|
h[3], self._bal_parent.decimal_point
|
||||||
|
)
|
||||||
|
if is_op_return_address(h[0]):
|
||||||
|
data_hex = get_op_return_hex(h[0]) or ""
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(data_hex).decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
decoded = h[0]
|
||||||
|
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
|
||||||
|
else:
|
||||||
detaillayout.addWidget(
|
detaillayout.addWidget(
|
||||||
qlabel(
|
qlabel(
|
||||||
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
heir_name,
|
||||||
|
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
||||||
|
f"[{h[0]}]",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.will[w].we:
|
if self.will[w].we:
|
||||||
@@ -1354,6 +1374,10 @@ class WillWidget(QWidget):
|
|||||||
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self.will[w].we.get("address"):
|
||||||
|
detaillayout.addWidget(
|
||||||
|
qlabel(_("Address"), self.will[w].we["address"])
|
||||||
|
)
|
||||||
detaillayout.addStretch()
|
detaillayout.addStretch()
|
||||||
pal = QPalette()
|
pal = QPalette()
|
||||||
pal.setColor(
|
pal.setColor(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ...core.checkalive import (
|
|||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
check_alive_expired,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
|
resolve_guard_threshold,
|
||||||
)
|
)
|
||||||
from .common import (
|
from .common import (
|
||||||
OP_RETURN_PREFIX,
|
OP_RETURN_PREFIX,
|
||||||
@@ -466,6 +467,31 @@ class BalWindow:
|
|||||||
|
|
||||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||||
_logger.debug("building will...")
|
_logger.debug("building will...")
|
||||||
|
# Drop stale wallet-LOCAL will placeholders saved by previous prepares
|
||||||
|
# so their coins are available to this build (see remove_stale...).
|
||||||
|
Will.remove_stale_wallet_history(
|
||||||
|
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||||
|
)
|
||||||
|
# A (re)build may have anticipated the delivery (shorter heir recipes)
|
||||||
|
# while ``date_to_check`` is still anchored to the OLD built will. Using
|
||||||
|
# that stale anchor as the build filter would block every future
|
||||||
|
# delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will
|
||||||
|
# that is being built: its locktime is the earliest future delivery
|
||||||
|
# among the CURRENT heirs. The checks of the EXISTING will keep their
|
||||||
|
# anchored ``date_to_check`` (set in init_class_variables).
|
||||||
|
_new_locktime = min(
|
||||||
|
(
|
||||||
|
Util.parse_locktime_string(h[2])
|
||||||
|
for h in self.heirs.values()
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
if _new_locktime:
|
||||||
|
self.date_to_check = resolve_date_to_check(
|
||||||
|
self.bal_plugin.is_basic_mode(),
|
||||||
|
self.will_settings,
|
||||||
|
built_locktime=_new_locktime,
|
||||||
|
)
|
||||||
will = {}
|
will = {}
|
||||||
# willtodelete = []
|
# willtodelete = []
|
||||||
# willtoappend = {}
|
# willtoappend = {}
|
||||||
@@ -745,6 +771,27 @@ class BalWindow:
|
|||||||
|
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
def is_locktime_below_threshold(self) -> bool:
|
||||||
|
"""True when the stored settings make the delivery earlier than the
|
||||||
|
Check Alive threshold (the "locktime is lower than threshold" guard).
|
||||||
|
|
||||||
|
Compares the delivery against the settings-derived threshold on the
|
||||||
|
SAME reference frame (see ``resolve_guard_threshold``), never against
|
||||||
|
the built-will-anchored ``date_to_check``: anchoring the guard to an
|
||||||
|
old, longer built will would wrongly fire right after the delivery was
|
||||||
|
shortened. The anchored reference still governs the validity and
|
||||||
|
expiry checks, which is where ``date_to_check`` belongs.
|
||||||
|
In BASIC mode there is no threshold, so the locktime is checked against
|
||||||
|
``date_to_check`` (= now) exactly as before.
|
||||||
|
"""
|
||||||
|
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||||
|
threshold_ts = resolve_guard_threshold(
|
||||||
|
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||||
|
)
|
||||||
|
if threshold_ts is not None:
|
||||||
|
return locktime < threshold_ts
|
||||||
|
return self.date_to_check is not None and locktime < self.date_to_check
|
||||||
|
|
||||||
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
||||||
try:
|
try:
|
||||||
_logger.info(
|
_logger.info(
|
||||||
@@ -757,6 +804,11 @@ class BalWindow:
|
|||||||
if not self.heirs:
|
if not self.heirs:
|
||||||
_logger.warning("not heirs {}".format(self.heirs))
|
_logger.warning("not heirs {}".format(self.heirs))
|
||||||
return
|
return
|
||||||
|
# Free the coins locked by stale wallet-LOCAL will placeholders
|
||||||
|
# BEFORE the amount/UTXO checks below (Step 1) see them.
|
||||||
|
Will.remove_stale_wallet_history(
|
||||||
|
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self.init_class_variables()
|
self.init_class_variables()
|
||||||
Will.check_amounts(
|
Will.check_amounts(
|
||||||
@@ -791,8 +843,7 @@ class BalWindow:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
if self.is_locktime_below_threshold():
|
||||||
if locktime < self.date_to_check:
|
|
||||||
self.show_error(_("locktime is lower than threshold"))
|
self.show_error(_("locktime is lower than threshold"))
|
||||||
return
|
return
|
||||||
if not self.no_willexecutor:
|
if not self.no_willexecutor:
|
||||||
|
|||||||
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Shared pytest fixtures.
|
||||||
|
|
||||||
|
Guards every test against cross-file network pollution: several karen7
|
||||||
|
regtest modules historically flipped ``electrum.constants.net`` to regtest at
|
||||||
|
import time, which broke unrelated offline tests (e.g. the CLI controller
|
||||||
|
suite) run in the same pytest process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from electrum import constants
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_network():
|
||||||
|
"""Snapshot ``constants.net`` before each test and restore it after."""
|
||||||
|
prev = constants.net
|
||||||
|
yield
|
||||||
|
constants.net = prev
|
||||||
@@ -18,6 +18,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ from electrum.simple_config import SimpleConfig
|
|||||||
from electrum.util import UserFacingException
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
from bal.cli.controller import BalController
|
from bal.cli.controller import BalController
|
||||||
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||||
|
|
||||||
@@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates():
|
|||||||
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_will_reanchors_date_to_check_to_new_locktime():
|
||||||
|
"""CLI mirror of the GUI regression: ``build_will`` must re-anchor
|
||||||
|
``date_to_check`` to the CURRENT heirs' earliest delivery before building,
|
||||||
|
so an anticipated (shortened) rebuild is not blocked by the old built-will
|
||||||
|
anchor (which would yield NO_FUTURE_DATE in ``get_transactions``).
|
||||||
|
"""
|
||||||
|
with Plugin() as plugin:
|
||||||
|
plugin.USER_TYPE.set("advanced")
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
plugin.ENABLE_MULTIVERSE.set(True)
|
||||||
|
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20})
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.no_willexecutor = True
|
||||||
|
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]
|
||||||
|
|
||||||
|
# Simulate an old built will frozen at 2y: reload keeps its (stale)
|
||||||
|
# anchor, which would reject the anticipated "1y" delivery.
|
||||||
|
c.init_class_variables()
|
||||||
|
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
|
||||||
|
c.date_to_check = stale_anchor
|
||||||
|
assert Util.parse_locktime_string("1y") < c.date_to_check
|
||||||
|
|
||||||
|
with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt:
|
||||||
|
result = c.build_will()
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
# build_will re-anchored date_to_check to the new 1y delivery...
|
||||||
|
expected = Util.parse_locktime_string("1y") - 150 * 86400
|
||||||
|
assert abs(c.date_to_check - expected) < 3600
|
||||||
|
# ...and used THAT anchor as the build filter, not the stale 2y one.
|
||||||
|
assert gt.call_args.args[-1] == c.date_to_check
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# runner
|
# runner
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ def test_bbqr_part_number_limits():
|
|||||||
|
|
||||||
def test_detect_format_recognises_all_formats():
|
def test_detect_format_recognises_all_formats():
|
||||||
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
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.ur1_frames(b"x", 400)[0]) == "ur1"
|
||||||
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
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"
|
||||||
@@ -444,6 +445,9 @@ def test_detect_format_rejects_garbage():
|
|||||||
def test_parse_for_detection_keys():
|
def test_parse_for_detection_keys():
|
||||||
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
||||||
assert bal == ("balqr", "balqr:3", 3, 2)
|
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])
|
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
|
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])
|
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
|||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
check_alive_expired,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
|
resolve_guard_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -148,6 +149,76 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
|||||||
assert abs(result - expected) < 1
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# resolve_guard_threshold
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_basic_mode_returns_none():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now)
|
||||||
|
assert threshold is None
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_locktime(settings, fake_now):
|
||||||
|
"""Reproduce the call-site locktime expression of the guard."""
|
||||||
|
from bal.core.plugin_base import BalTimestamp
|
||||||
|
|
||||||
|
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_absolute():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
locktime = fake_now + 90 * 86400
|
||||||
|
threshold = locktime - 30 * 86400
|
||||||
|
settings = {"locktime": locktime, "threshold": threshold}
|
||||||
|
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_relative_fresh_anchor():
|
||||||
|
"""A relative threshold must be anchored to the FRESH locktime so the
|
||||||
|
guard and the settings always share one reference frame.
|
||||||
|
|
||||||
|
Regression for the false positive where a still-valid built will frozen at
|
||||||
|
a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently
|
||||||
|
stored shorter delivery ("1y"): the old guard compared the fresh "1y"
|
||||||
|
locktime against that anchored threshold and wrongly fired "locktime is
|
||||||
|
lower than threshold", even though the settings themselves are consistent
|
||||||
|
(locktime is 30d AFTER the threshold).
|
||||||
|
"""
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"locktime": "1y", "threshold": "30d"}
|
||||||
|
locktime = _guard_locktime(settings, fake_now)
|
||||||
|
threshold = resolve_guard_threshold(False, settings, now=fake_now)
|
||||||
|
assert threshold is not None
|
||||||
|
assert locktime > threshold # internally consistent: no fire
|
||||||
|
assert threshold > fake_now
|
||||||
|
# The helper takes no built anchor: a frozen "2y" built will must NOT
|
||||||
|
# contaminate the result, although resolve_date_to_check (the expiry
|
||||||
|
# reference) legitimately keeps using it.
|
||||||
|
frozen_two_years = locktime + 365 * 86400
|
||||||
|
anchored = resolve_date_to_check(
|
||||||
|
False, settings, now=fake_now, built_locktime=frozen_two_years
|
||||||
|
)
|
||||||
|
assert anchored > threshold # built anchor pushes date_to_check forward...
|
||||||
|
assert locktime < anchored # ...which is exactly what used to fire the bug
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_relative_locktime_absolute_threshold():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
threshold = fake_now + 200 * 86400
|
||||||
|
settings = {"locktime": "1y", "threshold": threshold}
|
||||||
|
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
|
||||||
|
# "1y" from now is later than the stored absolute threshold: allowed.
|
||||||
|
locktime = _guard_locktime(settings, fake_now)
|
||||||
|
assert locktime > threshold
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_missing_returns_none():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# check_alive_expired
|
# check_alive_expired
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from bal.core.heirs import (
|
|||||||
is_op_return_address,
|
is_op_return_address,
|
||||||
validate_op_return_hex,
|
validate_op_return_hex,
|
||||||
)
|
)
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Constants
|
# Constants
|
||||||
@@ -167,6 +168,32 @@ def test_heirs_amount_to_float():
|
|||||||
assert heirs.amount_to_float("notanumber") == 0.0
|
assert heirs.amount_to_float("notanumber") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs():
|
||||||
|
"""A relative heir must survive the amount filter when the build anchor
|
||||||
|
(``from_locktime``) is recalculated for the anticipated delivery.
|
||||||
|
|
||||||
|
Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD
|
||||||
|
(longer) built will; an "1y" heir resolved before that anchor was excluded
|
||||||
|
by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the
|
||||||
|
anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the
|
||||||
|
"1y" heir is kept.
|
||||||
|
"""
|
||||||
|
wallet = FakeWallet()
|
||||||
|
heirs = Heirs(wallet)
|
||||||
|
heirs["carol"] = ["addr1", "100%", "1y"]
|
||||||
|
|
||||||
|
# Stale anchor (old built 2y will still frozen): "1y" is in the past
|
||||||
|
# relative to it -> excluded from the amount calculation.
|
||||||
|
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
|
||||||
|
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500)
|
||||||
|
assert "carol" not in percent_heirs
|
||||||
|
|
||||||
|
# Recalculated anchor for the new (1y) delivery: the heir is retained.
|
||||||
|
new_anchor = Util.parse_locktime_string("1y") - 150 * 86400
|
||||||
|
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500)
|
||||||
|
assert "carol" in percent_heirs
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Validation (static methods)
|
# Validation (static methods)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ def test_size_greater_than_payload():
|
|||||||
|
|
||||||
|
|
||||||
def test_exact_single_frame_boundary():
|
def test_exact_single_frame_boundary():
|
||||||
# A 138-byte payload exactly fills the 150-byte preset budget (the 12-char
|
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
|
||||||
# empty-flags header plus payload), so the encoded frame is exactly 150.
|
# compact header plus payload), so the encoded frame is exactly 150.
|
||||||
tx_strings = ["a" * 138]
|
tx_strings = ["a" * 139]
|
||||||
payload = encode_transfer(tx_strings)
|
payload = encode_transfer(tx_strings)
|
||||||
frames = split_frames(payload, 150)
|
frames = split_frames(payload, 150)
|
||||||
assert len(frames) == 1
|
assert len(frames) == 1
|
||||||
@@ -157,6 +157,119 @@ def test_compressed_roundtrip_through_frames():
|
|||||||
assert decoded == tx_strings
|
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
|
# Malformed input
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.checkalive import resolve_date_to_check
|
||||||
from bal.core.util import copy_structure
|
from bal.core.util import copy_structure
|
||||||
from bal.core.will import Will, WillItem
|
from bal.core.will import (
|
||||||
|
HeirNotFoundException,
|
||||||
|
NoHeirsException,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
)
|
||||||
|
|
||||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
|
|||||||
assert raised, "adding an heir must raise HeirNotFoundException"
|
assert raised, "adding an heir must raise HeirNotFoundException"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
|
||||||
|
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
|
||||||
|
ADVANCED check window is anchored to the frozen built delivery must trigger
|
||||||
|
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
|
||||||
|
|
||||||
|
Earlier the count gate resolved each current relative recipe from *now*
|
||||||
|
while ``check_date`` was anchored to the (longer) frozen built locktime, so
|
||||||
|
every heir fell below the window and was silently excluded -> NoHeirs even
|
||||||
|
though the will simply needs rebuilding on the new, shorter schedule."""
|
||||||
|
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
|
||||||
|
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
|
||||||
|
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
will = _make_will_with_heirs(will_heirs, lt)
|
||||||
|
will["willid_1"].set_status("COMPLETE", True)
|
||||||
|
check_date = resolve_date_to_check(
|
||||||
|
False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt
|
||||||
|
)
|
||||||
|
assert check_date < lt # the anchored window really precedes the delivery
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
Will.check_willexecutors_and_heirs(
|
||||||
|
will, copy_structure(current_heirs), {}, False, check_date, 100
|
||||||
|
)
|
||||||
|
except HeirNotFoundException:
|
||||||
|
raised = "rebuild"
|
||||||
|
except NoHeirsException:
|
||||||
|
raised = "noheirs"
|
||||||
|
assert raised == "rebuild", (
|
||||||
|
f"shortened recipe on a signed will must rebuild, got {raised!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_heirs_past_check_date_still_noheirs():
|
||||||
|
"""The "no valid heirs" gate is preserved: when every heir is coherent with
|
||||||
|
the built will but its delivery lies before ``check_date``, the check still
|
||||||
|
reports NoHeirsException (there is literally nothing future to inherit)."""
|
||||||
|
lt = 1_900_000_000
|
||||||
|
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||||
|
will = _make_will_with_heirs(will_heirs, lt)
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
Will.check_willexecutors_and_heirs(
|
||||||
|
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
|
||||||
|
)
|
||||||
|
except HeirNotFoundException:
|
||||||
|
raised = "rebuild"
|
||||||
|
except NoHeirsException:
|
||||||
|
raised = "noheirs"
|
||||||
|
assert raised == "noheirs", (
|
||||||
|
f"a fully delivered will must report NoHeirs, got {raised!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_needs_server_check():
|
def test_needs_server_check():
|
||||||
"""Check button selection logic: only a VALID, PUSHED will with a
|
"""Check button selection logic: only a VALID, PUSHED will with a
|
||||||
will-executor that is not yet CHECKED must be queried on the server.
|
will-executor that is not yet CHECKED must be queried on the server.
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ class FakeADB:
|
|||||||
|
|
||||||
def remove_transaction(self, txid):
|
def remove_transaction(self, txid):
|
||||||
self.removed.append(txid)
|
self.removed.append(txid)
|
||||||
|
# Simulate the real adb: dropping a stored tx frees the outputs it spent.
|
||||||
|
for utxos in self.outputs.values():
|
||||||
|
for utxo in utxos.values():
|
||||||
|
if getattr(utxo, "spent_txid", None) == txid:
|
||||||
|
utxo.spent_txid = None
|
||||||
|
utxo.spent_height = None
|
||||||
|
|
||||||
def get_spender(self, outpoint):
|
def get_spender(self, outpoint):
|
||||||
txid = self.spenders.get(outpoint)
|
txid = self.spenders.get(outpoint)
|
||||||
@@ -837,6 +843,73 @@ def test_get_available_utxos_none_locktime_is_raw_view():
|
|||||||
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
|
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.remove_stale_wallet_history (pre-build history purge)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_frees_equal_locktime_spend():
|
||||||
|
# The stale placeholders (saved by a previous prepare) have the SAME
|
||||||
|
# locktime as the will being rebuilt, so get_available_utxos does NOT
|
||||||
|
# restore their coins (see test_...does_not_restore_not_later_locktime).
|
||||||
|
# The pre-build purge deletes them and the coins become available again.
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=1000)
|
||||||
|
spender = "ab" * 32
|
||||||
|
assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == []
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == [spender]
|
||||||
|
assert wallet.adb.removed == [spender]
|
||||||
|
assert spender not in wallet.labels
|
||||||
|
assert [
|
||||||
|
u.prevout.to_str()
|
||||||
|
for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
] == [utxo.prevout.to_str()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_keeps_confirmed_spender():
|
||||||
|
# A broadcast (confirmed) BAL-labelled tx is never purged.
|
||||||
|
addr = "bcrt1qexample"
|
||||||
|
spender = "ab" * 32
|
||||||
|
utxo = _make_utxo(spent_txid=spender, spent_height=100)
|
||||||
|
wallet = FakeWallet(
|
||||||
|
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
|
||||||
|
heights={spender: 100},
|
||||||
|
outputs={addr: {utxo.prevout.to_str(): utxo}},
|
||||||
|
addresses=[addr],
|
||||||
|
)
|
||||||
|
wallet.labels[spender] = _HISTORY_LABEL
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == []
|
||||||
|
assert wallet.adb.removed == []
|
||||||
|
assert wallet.labels[spender] == _HISTORY_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_keeps_unlabeled_local_spender():
|
||||||
|
# Wallet-local BAL-status tx without a matching history label stays.
|
||||||
|
wallet, _ = _wallet_with_local_spend(locktime=1000)
|
||||||
|
spender = "ab" * 32
|
||||||
|
wallet.labels[spender] = "some other label"
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == []
|
||||||
|
assert wallet.adb.removed == []
|
||||||
|
assert wallet.labels[spender] == "some other label"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_noop_without_wallet_or_adb():
|
||||||
|
assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == []
|
||||||
|
wallet = FakeWallet()
|
||||||
|
wallet.adb = None
|
||||||
|
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_never_raises():
|
||||||
|
adb = MagicMock()
|
||||||
|
adb.get_tx_height.side_effect = RuntimeError("boom")
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.adb = adb
|
||||||
|
wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL}
|
||||||
|
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh():
|
|||||||
win.date_to_check = 1_800_000_000
|
win.date_to_check = 1_800_000_000
|
||||||
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
||||||
win.bal_plugin = _CfgBag(
|
win.bal_plugin = _CfgBag(
|
||||||
|
is_basic_mode=lambda: False,
|
||||||
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
||||||
SAVE_HISTORY=_Cfg(True),
|
SAVE_HISTORY=_Cfg(True),
|
||||||
HISTORY_LABEL=_Cfg("LBL"),
|
HISTORY_LABEL=_Cfg("LBL"),
|
||||||
@@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh():
|
|||||||
schedule_mock.assert_called_once_with()
|
schedule_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_purges_stale_wallet_history_before_building():
|
||||||
|
# The rebuild path must drop stale wallet-LOCAL will placeholders (saved by
|
||||||
|
# an earlier prepare) so their coins are available to the new build.
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
win.disable_plugin = False
|
||||||
|
win.heirs = {"h": object()}
|
||||||
|
win.willexecutors = {}
|
||||||
|
win.no_willexecutor = True
|
||||||
|
win.willitems = {}
|
||||||
|
win.will = {}
|
||||||
|
win.date_to_check = 1_800_000_000
|
||||||
|
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
||||||
|
win.bal_plugin = _CfgBag(
|
||||||
|
is_basic_mode=lambda: False,
|
||||||
|
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
||||||
|
SAVE_HISTORY=_Cfg(True),
|
||||||
|
HISTORY_LABEL=_Cfg("LBL"),
|
||||||
|
)
|
||||||
|
win.window = _FakeWindow()
|
||||||
|
win.window.wallet = _Wallet()
|
||||||
|
with (
|
||||||
|
patch.object(Util, "get_available_utxos", return_value=[]),
|
||||||
|
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
|
||||||
|
patch.object(Will, "get_min_locktime", return_value=0),
|
||||||
|
patch.object(Will, "check_amounts"),
|
||||||
|
patch.object(BalWindow, "init_class_variables"),
|
||||||
|
patch.object(BalWindow, "build_will"),
|
||||||
|
patch.object(
|
||||||
|
BalWindow,
|
||||||
|
"check_will",
|
||||||
|
side_effect=[NotCompleteWillException(), None],
|
||||||
|
),
|
||||||
|
patch.object(BalWindow, "update_all"),
|
||||||
|
patch.object(BalWindow, "_schedule_history_refresh"),
|
||||||
|
patch.object(Will, "remove_stale_wallet_history") as purge_mock,
|
||||||
|
):
|
||||||
|
BalWindow.build_inheritance_transaction(win)
|
||||||
|
purge_mock.assert_called_once_with(win.window.wallet, "LBL")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -107,10 +107,12 @@ class StubWillItem:
|
|||||||
return {"tx": str(self.tx), "status": self.statuses}
|
return {"tx": str(self.tx), "status": self.statuses}
|
||||||
|
|
||||||
|
|
||||||
def _make_willitems(n=3, payload_len=120):
|
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||||
|
if payloads is None:
|
||||||
|
payloads = ["T{}".format(i) * payload_len for i in range(n)]
|
||||||
return {
|
return {
|
||||||
"item{}".format(i): StubWillItem("T{}".format(i) * payload_len)
|
"item{}".format(i): StubWillItem(p)
|
||||||
for i in range(n)
|
for i, p in enumerate(payloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -288,8 +290,17 @@ 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
|
||||||
|
# compressed best-of transfer still needs several frames at the default
|
||||||
|
# chunk and navigation across frames is exercised.
|
||||||
|
def noisy(pad):
|
||||||
|
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
|
||||||
|
|
||||||
bw = FakeBalWindow()
|
bw = FakeBalWindow()
|
||||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
bw.willitems = _make_willitems(
|
||||||
|
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)
|
||||||
@@ -568,7 +579,7 @@ def test_export_format_combo_switches_codecs():
|
|||||||
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.format == "balqr"
|
assert page.format == "balqr"
|
||||||
assert page.frames[0].startswith("BALQR1|")
|
assert page.frames[0].startswith("BAL1")
|
||||||
assert page.format_combo.count() == 4
|
assert page.format_combo.count() == 4
|
||||||
|
|
||||||
page._on_format_change(1) # BC-UR v1
|
page._on_format_change(1) # BC-UR v1
|
||||||
|
|||||||
@@ -183,6 +183,22 @@ def test_locktime_raw_edit_get_set_value():
|
|||||||
assert "d" in val
|
assert "d" in val
|
||||||
|
|
||||||
|
|
||||||
|
def test_locktime_date_edit_get_set_value_roundtrip():
|
||||||
|
"""set_value(x) must roundtrip to get_value() == x (same timezone).
|
||||||
|
|
||||||
|
Guards a timezone regression that made the Date editor return the stored
|
||||||
|
wall clock re-read as local time, i.e. ``x + utc_offset``. That broke the
|
||||||
|
set_value/get_value roundtrip and kept the valueEdited ->
|
||||||
|
update_setting_widgets -> set_value signal cycle alive forever, ending in a
|
||||||
|
RecursionError when opening the "Build your will" wizard (Next button).
|
||||||
|
"""
|
||||||
|
from bal.gui.qt.widgets import LockTimeDateEdit
|
||||||
|
edit = LockTimeDateEdit()
|
||||||
|
for ts in (1700000000, 1750000000, 2147483647):
|
||||||
|
edit.set_value(ts)
|
||||||
|
assert edit.get_value() == ts
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# PercAmountEdit
|
# PercAmountEdit
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -390,6 +390,110 @@ def test_insufficient_funds_warns():
|
|||||||
assert not ctl.willitems
|
assert not ctl.willitems
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_not_blocked_by_old_built_will():
|
||||||
|
"""Regression: shortening the delivery in the STORED settings (relative
|
||||||
|
"1y"/"30d") while an old, still-VALID built will is frozen at a longer
|
||||||
|
locktime must NOT fire the "locktime is lower than threshold" guard.
|
||||||
|
|
||||||
|
The old guard compared the fresh settings locktime against ``date_to_check``
|
||||||
|
anchored to the built will (see ``resolve_date_to_check``), so a built-will
|
||||||
|
delivery longer than the settings' one made it fire even though the settings
|
||||||
|
are internally consistent (locktime is 30d AFTER the threshold). The guard
|
||||||
|
must instead compare the stored settings on a single reference frame
|
||||||
|
(``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its
|
||||||
|
built anchor for the expiry/validity checks.
|
||||||
|
"""
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
|
||||||
|
# Freeze the built (VALID) will at a delivery one year longer than the
|
||||||
|
# now-shortened settings: the pre-fix guard would reject the rebuild.
|
||||||
|
item.tx.locktime = item.tx.locktime + 365 * 86400
|
||||||
|
ctl.will_settings = {"locktime": "1y", "threshold": "30d"}
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
|
||||||
|
ctl.init_class_variables()
|
||||||
|
|
||||||
|
# date_to_check is anchored to the built will (long delivery)...
|
||||||
|
assert ctl.date_to_check == item.tx.locktime - 30 * 86400
|
||||||
|
# ...and the OLD guard would have fired here:
|
||||||
|
old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"])
|
||||||
|
assert old_locktime < ctl.date_to_check
|
||||||
|
# but the settings themselves are consistent, so the guard must pass:
|
||||||
|
assert ctl.is_locktime_below_threshold() is False
|
||||||
|
assert not ctl.window.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_anticipated_rebuild_reanchors_date_to_check():
|
||||||
|
"""Regression (karen7): rebuilding a SIGNED will whose delivery was
|
||||||
|
anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must
|
||||||
|
succeed.
|
||||||
|
|
||||||
|
``date_to_check`` stays anchored to the OLD built delivery for the validity
|
||||||
|
checks, but ``build_will`` must re-anchor it to the NEW (earliest current)
|
||||||
|
delivery as its build filter: before the fix the stale 2028 anchor rejected
|
||||||
|
every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build
|
||||||
|
reported ``NO_FUTURE_DATE``. The old signed item is then superseded by
|
||||||
|
``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will
|
||||||
|
is coherent again.
|
||||||
|
"""
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
# Per-heir deliveries require multiverse mode (the only way heirs can
|
||||||
|
# carry a different recipe than the settings locktime).
|
||||||
|
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
|
||||||
|
ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20}
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
ctl.heirs["alice"][2] = "2y"
|
||||||
|
ctl.heirs["bob"][2] = "2y"
|
||||||
|
|
||||||
|
# Build and sign a 2y will (the old, committed delivery).
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, _old_item = _single(ctl)
|
||||||
|
old_locktime = _old_item.tx.locktime
|
||||||
|
signed = ctl.sign_transactions(None)
|
||||||
|
_old_item.tx = Will.get_tx_from_any(str(signed[old_txid]))
|
||||||
|
Will.check_signatures(ctl.willitems, ctl.wallet)
|
||||||
|
assert _old_item.get_status("COMPLETE")
|
||||||
|
|
||||||
|
# Anticipate: shorten every heir to 1y.
|
||||||
|
ctl.heirs["alice"][2] = "1y"
|
||||||
|
ctl.heirs["bob"][2] = "1y"
|
||||||
|
|
||||||
|
ctl.init_class_variables()
|
||||||
|
# date_to_check stays anchored to the OLD built delivery...
|
||||||
|
assert ctl.date_to_check == old_locktime - 150 * 86400
|
||||||
|
# ...and that stale anchor would reject the anticipated "1y" dates.
|
||||||
|
assert Util.parse_locktime_string("1y") < ctl.date_to_check
|
||||||
|
|
||||||
|
# The rebuild must succeed (re-anchored to the new delivery).
|
||||||
|
willitems = ctl.build_inheritance_transaction()
|
||||||
|
|
||||||
|
assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire"
|
||||||
|
new_valid = [
|
||||||
|
it for tid, it in willitems.items()
|
||||||
|
if tid != old_txid and it.get_status("VALID")
|
||||||
|
]
|
||||||
|
assert new_valid, "the anticipated (1y) will must build and stay VALID"
|
||||||
|
new_item = new_valid[0]
|
||||||
|
assert new_item.tx.locktime < old_locktime, "delivery must be anticipated"
|
||||||
|
# date_to_check was re-anchored to the rebuilt delivery (1y minus 150d).
|
||||||
|
assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600
|
||||||
|
|
||||||
|
# The old signed item is kept but superseded (REPLACED -> not VALID).
|
||||||
|
assert _old_item.get_status("REPLACED") is True
|
||||||
|
assert _old_item.get_status("VALID") is False
|
||||||
|
|
||||||
|
# The rebuilt will is coherent (plain rebuild, no on-chain invalidation).
|
||||||
|
assert ctl.check_will() is True
|
||||||
|
assert not any("delivery date" in m for m in ctl.window.messages)
|
||||||
|
assert not ctl.window.errors
|
||||||
|
|
||||||
|
|
||||||
def _run_all():
|
def _run_all():
|
||||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
for fn in tests:
|
for fn in tests:
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here:
|
|||||||
never read as EXPIRED because the check window drifts past the frozen
|
never read as EXPIRED because the check window drifts past the frozen
|
||||||
tx locktime.
|
tx locktime.
|
||||||
|
|
||||||
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
|
The reported state (reproduced hermetically here — the original live wallet
|
||||||
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
|
dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is:
|
||||||
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
|
heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is
|
||||||
|
2027-08-05 (built 2026-08-05), and will_settings
|
||||||
``{"locktime": "2y", "threshold": "150d"}``.
|
``{"locktime": "2y", "threshold": "150d"}``.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
@@ -28,16 +29,14 @@ Run:
|
|||||||
python3 tests/test_heir_relative_anchor.py
|
python3 tests/test_heir_relative_anchor.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
import pytest # noqa: E402 (path insert above)
|
||||||
from electrum import constants # noqa: E402 (path insert above)
|
from electrum import constants # noqa: E402 (path insert above)
|
||||||
|
|
||||||
constants.net = constants.BitcoinRegtest
|
|
||||||
|
|
||||||
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
||||||
from bal.core.util import copy_structure # noqa: E402
|
from bal.core.util import copy_structure # noqa: E402
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
@@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402
|
|||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _regtest_net():
|
||||||
|
"""Run these regtest-focused tests with BitcoinRegtest, restoring mainnet
|
||||||
|
afterwards so sibling test modules are unaffected by the net switch."""
|
||||||
|
constants.net = constants.BitcoinRegtest
|
||||||
|
yield
|
||||||
|
constants.net = constants.BitcoinMainnet
|
||||||
|
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
|
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
|
||||||
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected():
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# karen7 wallet regression (real fixture)
|
# karen7 regression (hermetic, no live wallet fixture)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
# karen7's reported state, reproduced hermetically: heirs "1y", a signed item
|
||||||
def _load_karen7():
|
# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a
|
||||||
path = os.path.join(os.path.dirname(__file__), "karen7")
|
# relative "150d" delivery window and a "2y" promised locktime.
|
||||||
with open(path) as f:
|
_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"}
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def test_karen7_frozen_delivery_not_expired():
|
def test_karen7_frozen_delivery_not_expired():
|
||||||
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
|
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
|
||||||
window opens BEFORE the delivery, so the will is never read as expired."""
|
window opens BEFORE the delivery, so the will is never read as expired."""
|
||||||
data = _load_karen7()
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
will_settings = data["will_settings"]
|
item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True)
|
||||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
will = {"willid_1": item}
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
built_locktime = Will.get_min_locktime(will)
|
||||||
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
|
||||||
assert built_locktime is not None
|
assert built_locktime is not None
|
||||||
assert built_locktime == int(wi.tx.locktime)
|
assert built_locktime == int(item.tx.locktime)
|
||||||
|
|
||||||
date_to_check = resolve_date_to_check(
|
date_to_check = resolve_date_to_check(
|
||||||
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=built_locktime
|
||||||
)
|
)
|
||||||
assert int(date_to_check) < built_locktime
|
assert int(date_to_check) < built_locktime
|
||||||
# Re-evaluated 10 days later the window is identical (no daily drift).
|
# Re-evaluated 10 days later the window is identical (no daily drift).
|
||||||
later = resolve_date_to_check(
|
later = resolve_date_to_check(
|
||||||
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
|
False, _WILL_SETTINGS, now=1_800_000_000.0 + 10 * 86400,
|
||||||
built_locktime=built_locktime,
|
built_locktime=built_locktime,
|
||||||
)
|
)
|
||||||
assert date_to_check == later
|
assert date_to_check == later
|
||||||
|
|
||||||
|
|
||||||
def test_karen7_unchanged_heirs_are_coherent():
|
def test_karen7_unchanged_heirs_are_coherent():
|
||||||
"""The karen7 heirs (unchanged relative "2d") are coherent with the frozen
|
"""Unchanged relative "1y" heirs are coherent with the frozen signed tx:
|
||||||
signed tx: the plugin must NOT ask to invalidate the will."""
|
the plugin must NOT ask to invalidate the will."""
|
||||||
data = _load_karen7()
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
|
||||||
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
|
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
|
||||||
# the UTC anchoring code.
|
# the UTC anchoring code.
|
||||||
frozen_locktime = _FROZEN
|
frozen_locktime = _FROZEN
|
||||||
date_to_check = resolve_date_to_check(
|
date_to_check = resolve_date_to_check(
|
||||||
False, data["will_settings"],
|
False, _WILL_SETTINGS,
|
||||||
now=1_800_000_000.0,
|
now=1_800_000_000.0,
|
||||||
built_locktime=frozen_locktime,
|
built_locktime=frozen_locktime,
|
||||||
)
|
)
|
||||||
outcome = _run_heir_check(
|
outcome = _run_heir_check(
|
||||||
data["will"][valid_wid]["heirs"],
|
copy_structure(heirs),
|
||||||
data["heirs"],
|
copy_structure(heirs),
|
||||||
frozen_locktime,
|
frozen_locktime,
|
||||||
status_complete=True,
|
status_complete=True,
|
||||||
)
|
)
|
||||||
@@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
constants.net = constants.BitcoinRegtest
|
||||||
for name in sorted(dir()):
|
for name in sorted(dir()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
|
|||||||
@@ -171,6 +171,50 @@ def test_will_widget_explicit_will():
|
|||||||
assert w2.will is live
|
assert w2.will is live
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillWidget shows heir/willexecutor addresses and decodes OP_RETURN
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_will_widget_shows_addresses_and_decodes_opreturn():
|
||||||
|
from PyQt6.QtWidgets import QLabel
|
||||||
|
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
from bal.gui.qt.widgets import WillWidget
|
||||||
|
|
||||||
|
op_hex = "68656c6c6f" # "hello" in hex
|
||||||
|
heirs = {
|
||||||
|
"bob": ["bc1qtestaddr", 1_000_000, 1000, 500_000],
|
||||||
|
"msg": [f"OP_RETURN:{op_hex}", 0, 1000, 0],
|
||||||
|
}
|
||||||
|
wi = WillItem(
|
||||||
|
_make_willitem_dict(
|
||||||
|
heirs=heirs,
|
||||||
|
willexecutor={
|
||||||
|
"url": "https://exec.example",
|
||||||
|
"address": "bc1qexecaddr",
|
||||||
|
"base_fee": 200_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wi._id = "w0"
|
||||||
|
fake_parent = SimpleNamespace(
|
||||||
|
decimal_point=8,
|
||||||
|
base_unit_name="BTC",
|
||||||
|
bal_window=SimpleNamespace(
|
||||||
|
willitems={},
|
||||||
|
bal_plugin=SimpleNamespace(
|
||||||
|
_hide_replaced=False, _hide_invalidated=False
|
||||||
|
),
|
||||||
|
show_transaction=lambda *a, **k: None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
w = WillWidget(parent=fake_parent, will={"w0": wi})
|
||||||
|
texts = [lbl.text() for lbl in w.findChildren(QLabel)]
|
||||||
|
assert any("bc1qtestaddr" in t for t in texts), texts
|
||||||
|
assert any("OP_RETURN: hello" in t for t in texts), texts
|
||||||
|
assert any("bc1qexecaddr" in t for t in texts), texts
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# WillDetailDialog external-will mode
|
# WillDetailDialog external-will mode
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
||||||
|
|
||||||
This is the post-build sync that keeps the plugin's stored delivery date
|
This is the post-build sync that keeps the plugin's stored delivery date
|
||||||
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
|
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed
|
||||||
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
|
locktime when the core AUTOMATICALLY anticipates it (one day earlier than
|
||||||
|
stored).
|
||||||
|
|
||||||
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
|
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
|
||||||
asks to invalidate the will EVERY DAY. The relative value is re-parsed
|
that once forced freezing them to absolute timestamps is solved at the root by
|
||||||
against "now" on every check, so it drifts one day per day away from the
|
anchoring every relative recipe against the built transactions
|
||||||
fixed tx locktime and the postpone check always sees a "postpone".
|
(``Util.resolve_locktime_against_tx`` for the postpone detection,
|
||||||
|
``resolve_date_to_check(..., built_locktime=...)`` for the reference
|
||||||
|
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
|
||||||
|
moves the stored value.
|
||||||
|
|
||||||
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
||||||
Electrum wallet) by calling it as an unbound method.
|
Electrum wallet) by calling it as an unbound method.
|
||||||
@@ -24,7 +28,6 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
|
|
||||||
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
|
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,34 +71,30 @@ def _call_sync(will_settings, tx_locktimes, recorded):
|
|||||||
# Tests
|
# Tests
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_relative_locktime_normalized_to_absolute():
|
def test_relative_locktime_preserved():
|
||||||
"""The reported bug: a relative stored locktime is frozen to the absolute
|
"""A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild:
|
||||||
value of the built transaction, even when it parses to the same moment."""
|
it is anchored against the built transactions on every check, so it must
|
||||||
|
not be frozen to an absolute timestamp in WILL_SETTINGS."""
|
||||||
tx_locktime = 1_800_000_000
|
tx_locktime = 1_800_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
assert fake.bal_window.will_settings["locktime"] == "90d"
|
||||||
assert fake.bal_window.will_settings["locktime"] != "90d"
|
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||||
# A pure relative->absolute normalisation is NOT an anticipation: the sign
|
assert recorded == [], "a relative recipe must never be rewritten"
|
||||||
# prompt must not claim the date was anticipated.
|
|
||||||
assert fake._date_was_anticipated is False
|
assert fake._date_was_anticipated is False
|
||||||
|
|
||||||
|
|
||||||
def test_relative_threshold_frozen_to_absolute():
|
def test_relative_threshold_preserved():
|
||||||
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
|
"""Same for the relative "Check Alive" threshold: it stays relative."""
|
||||||
same absolute value the settings widget computes (real_threshold)."""
|
|
||||||
tx_locktime = 1_800_000_000
|
tx_locktime = 1_800_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
)
|
)
|
||||||
expected = int(
|
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||||
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
|
assert recorded == []
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["threshold"] == expected
|
|
||||||
assert ("threshold", expected, True) in recorded
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_locktime_unchanged_on_equal():
|
def test_absolute_locktime_unchanged_on_equal():
|
||||||
@@ -113,8 +112,8 @@ def test_absolute_locktime_unchanged_on_equal():
|
|||||||
|
|
||||||
|
|
||||||
def test_anticipation_sets_flag_and_moves_earlier():
|
def test_anticipation_sets_flag_and_moves_earlier():
|
||||||
"""A real anticipation (built locktime earlier than the stored absolute
|
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
|
||||||
one) still moves the date earlier and flags the sign prompt."""
|
than stored) still moves the date earlier and flags the sign prompt."""
|
||||||
tx_locktime = 1_700_000_000
|
tx_locktime = 1_700_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
@@ -129,7 +128,7 @@ def test_anticipation_sets_flag_and_moves_earlier():
|
|||||||
def test_stored_earlier_than_built_never_moved_later():
|
def test_stored_earlier_than_built_never_moved_later():
|
||||||
"""A stored absolute date that is already EARLIER than the built txs (the
|
"""A stored absolute date that is already EARLIER than the built txs (the
|
||||||
user moved the delivery later) is never pulled back up on rebuild: only
|
user moved the delivery later) is never pulled back up on rebuild: only
|
||||||
anticipation (built < stored) and relative normalisation move the value."""
|
anticipation (built < stored) moves the value."""
|
||||||
stored = 1_800_000_000
|
stored = 1_800_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
@@ -142,39 +141,39 @@ def test_stored_earlier_than_built_never_moved_later():
|
|||||||
|
|
||||||
|
|
||||||
def test_multiple_txs_uses_minimum_locktime():
|
def test_multiple_txs_uses_minimum_locktime():
|
||||||
"""When several transactions carry different locktimes, the minimum is used
|
"""When several ABSOLUTE transactions carry different locktimes, the minimum
|
||||||
(owner-confirmed behaviour for the delivery date shown in the UI)."""
|
is used for a genuine automatic anticipation (owner-confirmed behaviour for
|
||||||
|
the delivery date shown in the UI)."""
|
||||||
min_locktime = 1_750_000_000
|
min_locktime = 1_750_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
{"locktime": "90d", "threshold": "30d"},
|
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
|
||||||
[min_locktime, min_locktime + 86_400],
|
[min_locktime, min_locktime + 86_400],
|
||||||
recorded,
|
recorded,
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
||||||
|
|
||||||
|
|
||||||
def test_relative_locktime_stops_daily_postpone():
|
def test_relative_locktime_stays_coherent_via_anchor():
|
||||||
"""End-to-end guard for the reported bug: after the sync, re-parsing the
|
"""Daily-drift guard: an UNCHANGED relative recipe is resolved against the
|
||||||
stored (now absolute) locktime on later days always equals the built
|
tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT
|
||||||
tx locktime, so the postpone check never fires again."""
|
being frozen to an absolute value it still reads as COHERENT (== tx
|
||||||
from datetime import datetime, timedelta
|
locktime) on later days - the postpone check never fires again."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from bal.core.util import Util
|
from bal.core.util import Util
|
||||||
|
|
||||||
tx_locktime = 1_800_000_000
|
# resolve_locktime_against_tx normalises to UTC midnight before anchoring,
|
||||||
recorded = []
|
# so use a midnight-UTC frozen tx locktime (the timestamp the engine itself
|
||||||
fake = _call_sync(
|
# stores after building).
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp())
|
||||||
)
|
built = "90d" # recipe frozen at build time
|
||||||
stored = fake.bal_window.will_settings["locktime"]
|
current = "90d" # unchanged recipe today
|
||||||
for _day in range(0, 7):
|
for _day in range(0, 7):
|
||||||
# Simulate the check on later days: parse the STORED value (which is
|
resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime)
|
||||||
# now the absolute tx locktime) and compare with the fixed tx locktime.
|
assert resolved == tx_locktime # no POSTPONE / drift
|
||||||
new_locktime = Util.parse_locktime_string(stored)
|
# Sanity: a naive forward-from-now re-parse would have drifted past it
|
||||||
assert new_locktime == tx_locktime
|
# (the bug the anchor fixes).
|
||||||
assert new_locktime <= tx_locktime # no POSTPONE / drift
|
|
||||||
# Sanity: a RELATIVE value would have drifted past it (the bug).
|
|
||||||
drifted = int(
|
drifted = int(
|
||||||
(
|
(
|
||||||
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
||||||
|
|||||||
Reference in New Issue
Block a user