Files
bal-electrum-plugin/bal/core/animated_qr.py
svatantrya 5ec2b79d03 core+gui: BBQR-only QR transfer, disable Audio when modem plugin unavailable
- Drop BALQR / BC-UR v1 / v2 wire formats, keep BBQR only (animated_qr,
  qrtransfer, dialogs); remove the settings "QR Code Size" selector and
  persist the export-dialog size choice instead.
- Disable the Audio transport radio button in the import/export dialogs
  when the audio_modem plugin (or its amodem dependency) is unavailable.
- Refresh translation catalogs (bal.pot, es_ES, it_IT).
2026-10-03 13:00:23 -04:00

297 lines
10 KiB
Python

"""
bal.core.animated_qr
====================
GUI-free implementation of the BBQR animated-QR transfer format used to
move BAL will data between devices.
Supported wire format (self-describing and order-independent on receive):
* **BBQR** (Coinkite)::
B$<encoding><type><2 base36 total><2 base36 index><payload>
Equal-length text frames; the payload is uppercase hex, RFC-4648
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
Everything is implemented from scratch on top of the Python standard library
only (``zlib``, ``base64``), so the shipped plugin zip stays a
self-contained bundle with no third-party dependencies (house rule).
This module never imports Qt or any Electrum GUI code (house rule).
"""
from __future__ import annotations
import base64
import zlib
from typing import Dict, List, Optional, Sequence, Tuple
# --------------------------------------------------------------------------- #
# Errors & safety caps
# --------------------------------------------------------------------------- #
class AnimatedQrError(ValueError):
"""Base error for all animated-QR codec failures."""
class FormatNotDetectedError(AnimatedQrError):
"""The scanned text does not look like any known animated-QR format."""
class TransferConflictError(AnimatedQrError):
"""An incoming frame belongs to a different transfer than the open one."""
class SessionLimitError(AnimatedQrError):
"""A receive session exceeded its safety caps."""
# Safety caps for untrusted scanner input.
_MAX_SESSION_PARTS = 20000
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
# --------------------------------------------------------------------------- #
# BBQR (Coinkite)
# --------------------------------------------------------------------------- #
_BBQR_PREFIX = "B$"
def _bbqr_base36(n: int) -> str:
if not 0 <= n <= 1295:
raise AnimatedQrError("BBQR part count out of range")
def digit(x: int) -> str:
return chr(48 + x) if x < 10 else chr(65 + x - 10)
return digit(n // 36) + digit(n % 36)
def _bbqr_base32(data: bytes) -> str:
return base64.b32encode(data).decode("ascii").rstrip("=")
def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]:
"""Return ``(encoding, encoded_text, split_mod)`` honouring the reference."""
if encoding == "H":
return "H", raw.hex().upper(), 2
if encoding == "Z":
compressor = zlib.compressobj(wbits=-10)
compressed = compressor.compress(raw) + compressor.flush()
if len(compressed) < len(raw):
return "Z", _bbqr_base32(compressed), 8
encoding = "2"
if encoding != "2":
raise AnimatedQrError("unknown BBQR encoding")
return "2", _bbqr_base32(raw), 8
def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]:
"""Encode ``payload`` into BBQR frames (``B$<enc><type><N><n>…``)."""
if len(type_code) != 1 or not type_code.isalnum():
raise AnimatedQrError("bad BBQR type code")
encoding, encoded, split_mod = _bbqr_encode(payload, encoding)
chunk = budget_chars - 8
if chunk < split_mod:
raise AnimatedQrError("QR budget too small for a BBQR frame")
chunk -= chunk % split_mod
if chunk < 1:
raise AnimatedQrError("QR budget too small for a BBQR frame")
if len(payload) > _MAX_MESSAGE_BYTES:
raise AnimatedQrError("BBQR payload exceeds the size cap")
total = -(-len(encoded) // chunk)
if total > 1295:
raise AnimatedQrError("BBQR transfer demands too many parts")
header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total)
frames: List[str] = []
pos = 0
for index in range(total):
frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk])
pos += chunk
return frames
def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]:
"""Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``."""
frame_text = frame_text.strip()
if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX):
raise AnimatedQrError("not a BBQR frame")
encoding = frame_text[2]
type_code = frame_text[3]
if encoding not in ("H", "2", "Z"):
raise AnimatedQrError("unknown BBQR encoding")
try:
total = int(frame_text[4:6], 36)
index = int(frame_text[6:8], 36)
except ValueError:
raise AnimatedQrError("bad BBQR part numbers") from None
if total < 1 or not 0 <= index < total:
raise AnimatedQrError("bad BBQR part numbers")
if index >= _MAX_SESSION_PARTS:
raise AnimatedQrError("BBQR part number out of range")
return encoding, type_code, total, index, frame_text[8:]
def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
pieces: List[bytes] = []
for part in encoded_parts:
if encoding == "H":
try:
pieces.append(bytes.fromhex(part))
except ValueError:
raise AnimatedQrError("invalid BBQR hex payload") from None
continue
padding = (8 - (len(part) % 8)) % 8
try:
pieces.append(base64.b32decode(part + "=" * padding))
except (ValueError, TypeError):
raise AnimatedQrError("invalid BBQR base32 payload") from None
raw = b"".join(pieces)
if encoding == "Z":
try:
inflater = zlib.decompressobj(wbits=-10)
out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1)
except zlib.error:
raise AnimatedQrError("invalid BBQR zlib payload") from None
if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail:
raise AnimatedQrError("BBQR payload exceeds the size cap")
return out
return raw
# --------------------------------------------------------------------------- #
# Format detection & per-frame identity for the shared debounce
# --------------------------------------------------------------------------- #
FORMAT_LABELS = {
"bbqr": "BBQR",
}
def format_name(fmt: str) -> str:
"""Human-readable name of a wire format for UI labels."""
return FORMAT_LABELS.get(fmt, fmt)
def detect_format(text: str) -> Optional[str]:
"""Return the wire format of a scanned string, or ``None``."""
text = text.strip()
if not text:
return None
if text.startswith(_BBQR_PREFIX):
return "bbqr"
return None
def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
"""Parse a frame and return ``(format, session_key, frame_total, index)``.
``session_key`` identifies the transfer the frame belongs to and drives
the shared reset/ignore/accept debounce. Raises
:class:`AnimatedQrError` when the text cannot be parsed.
"""
fmt = detect_format(text)
if fmt == "bbqr":
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
raise FormatNotDetectedError("Not a supported QR transfer format")
# --------------------------------------------------------------------------- #
# Receive session (order-independent assembly)
# --------------------------------------------------------------------------- #
class _BbqrSession:
def __init__(self):
self._total = 0
self._encoding = ""
self._type_code = ""
self._parts: Dict[int, str] = {}
@property
def total(self) -> int:
return self._total
@property
def received(self) -> int:
return len(self._parts)
@property
def done(self) -> bool:
return bool(self._total) and len(self._parts) >= self._total
def add(self, text: str) -> str:
encoding, type_code, total, index, payload = bbqr_parse_part(text)
if self._total:
if (encoding, type_code, total) != (self._encoding, self._type_code, self._total):
raise TransferConflictError("BBQR frame header changed")
else:
self._total = total
self._encoding = encoding
self._type_code = type_code
if index in self._parts:
return "dup"
self._parts[index] = payload
return "ok"
def resolve(self) -> Tuple[str, bool]:
ordered = [self._parts[i] for i in range(self._total)]
raw = _bbqr_decode(ordered, self._encoding)
return _transfer_text(raw), False
def _transfer_text(raw: bytes) -> str:
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
raise AnimatedQrError("decoded transfer is not valid UTF-8") from None
class AnimatedQrSession:
"""Facade over the per-format receive sessions used by the QR import page."""
def __init__(self):
self._inner: Optional[object] = None
self._fmt: Optional[str] = None
@property
def format(self) -> Optional[str]:
return self._fmt
def add_part(self, text: str) -> str:
"""Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input."""
fmt = detect_format(text)
if fmt is None:
raise FormatNotDetectedError("Not a supported QR transfer format")
if self._inner is None:
self._fmt = fmt
self._inner = self._make(fmt)
elif fmt != self._fmt:
raise TransferConflictError(
"Switched QR format mid-import ({} -> {})".format(self.format, fmt)
)
return self._inner.add(text) # type: ignore[no-any-return]
@staticmethod
def _make(fmt: str) -> object:
if fmt == "bbqr":
return _BbqrSession()
raise AssertionError("unknown animated-QR format {}".format(fmt))
@property
def total(self) -> int:
return self._inner.total if self._inner is not None else 0
@property
def received(self) -> int:
return self._inner.received if self._inner is not None else 0
@property
def done(self) -> bool:
return bool(self._inner is not None and self._inner.done)
def resolve(self) -> Tuple[str, bool]:
if self._inner is None:
raise AnimatedQrError("no transfer has been received")
return self._inner.resolve() # type: ignore[no-any-return]