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).
This commit is contained in:
@@ -2,33 +2,18 @@
|
||||
bal.core.animated_qr
|
||||
====================
|
||||
|
||||
GUI-free implementation of the interoperable animated-QR transfer formats
|
||||
used to move BAL will data between devices.
|
||||
GUI-free implementation of the BBQR animated-QR transfer format used to
|
||||
move BAL will data between devices.
|
||||
|
||||
Supported wire formats (each self-describing and order-independent on
|
||||
receive):
|
||||
Supported wire format (self-describing and order-independent on receive):
|
||||
|
||||
* **BALQR** (native): ``BAL1<TTT><iii><flag><payload>`` compact fixed-width
|
||||
header (11 chars, 3-digit base36 count fields, no separators); legacy
|
||||
``BALQR1|total|index|flags|payload`` still imported.
|
||||
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
|
||||
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
||||
Fragments partition the BC32 rendering of the CBOR byte string; the
|
||||
SHA-256 digest of the wrapped payload ties the parts together.
|
||||
* **BC-UR v2** (BCR-2020-005 rev 2 / BCR-2020-012)::
|
||||
ur:bytes/2-9/<bytewords-minimal fragment>
|
||||
Fountain-coded parts; each part is a CBOR array
|
||||
``[seq_num, seq_len, message_len, checksum, data]`` whose CBOR bytes are
|
||||
bytewords-minimal encoded with a trailing per-part CRC-32. The
|
||||
``checksum`` field holds the CRC-32 of the whole wrapped message, so the
|
||||
parts are mixable and order-independent.
|
||||
* **BBQR** (Coinkite)::
|
||||
B$<encoding><type><2 base36 total><2 base36 index><payload>
|
||||
Equal-length text frames; the payload is uppercase hex, RFC-4648
|
||||
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
|
||||
|
||||
Everything is implemented from scratch on top of the Python standard library
|
||||
only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a
|
||||
only (``zlib``, ``base64``), so the shipped plugin zip stays a
|
||||
self-contained bundle with no third-party dependencies (house rule).
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -37,9 +22,8 @@ This module never imports Qt or any Electrum GUI code (house rule).
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import zlib
|
||||
from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Errors & safety caps
|
||||
@@ -62,604 +46,10 @@ class SessionLimitError(AnimatedQrError):
|
||||
"""A receive session exceeded its safety caps."""
|
||||
|
||||
|
||||
class ChecksumError(AnimatedQrError):
|
||||
"""A part failed its checksum / digest validation."""
|
||||
|
||||
|
||||
# Safety caps for untrusted scanner input.
|
||||
_MAX_SESSION_PARTS = 20000
|
||||
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CBOR minimals (byte-string envelope + the fountain part header)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BYTE_STR_RES = 0x40 # byte string, length < 24
|
||||
_BYTE_STR_1 = 0x58 # byte string, 1-byte length
|
||||
_BYTE_STR_2 = 0x59 # byte string, 2-byte length
|
||||
_BYTE_STR_4 = 0x60 # byte string, 4-byte length
|
||||
_ARRAY_RES = 0x80
|
||||
_UNSIGNED_RES = 0x00
|
||||
|
||||
|
||||
def cbor_byte_string(data: bytes) -> bytes:
|
||||
"""Wrap ``data`` in the minimal CBOR byte-string envelope (0x40..0x60)."""
|
||||
n = len(data)
|
||||
if n < 24:
|
||||
head = bytes([_BYTE_STR_RES + n])
|
||||
elif n <= 0xFF:
|
||||
head = bytes([_BYTE_STR_1, n])
|
||||
elif n <= 0xFFFF:
|
||||
head = bytes([_BYTE_STR_2]) + n.to_bytes(2, "big")
|
||||
elif n <= 0xFFFFFFFF:
|
||||
head = bytes([_BYTE_STR_4]) + n.to_bytes(4, "big")
|
||||
else:
|
||||
raise AnimatedQrError("payload too large for the UR byte-string envelope")
|
||||
return head + data
|
||||
|
||||
|
||||
def unwrap_ur_cbor(message: bytes) -> bytes:
|
||||
"""Strip the CBOR byte-string envelope, falling back to the raw bytes.
|
||||
|
||||
Receivers keep working even when the emitter embedded the payload without
|
||||
any CBOR wrapping (some third-party ``ur:bytes`` emitters do).
|
||||
"""
|
||||
if not message:
|
||||
raise AnimatedQrError("empty decoded message")
|
||||
b0 = message[0]
|
||||
if _BYTE_STR_RES <= b0 <= 0x57:
|
||||
header_len, n = 1, b0 - _BYTE_STR_RES
|
||||
elif b0 == _BYTE_STR_1 and len(message) >= 2:
|
||||
header_len, n = 2, message[1]
|
||||
elif b0 == _BYTE_STR_2 and len(message) >= 3:
|
||||
header_len, n = 3, int.from_bytes(message[1:3], "big")
|
||||
elif b0 == _BYTE_STR_4 and len(message) >= 5:
|
||||
header_len, n = 5, int.from_bytes(message[1:5], "big")
|
||||
else:
|
||||
return message
|
||||
if header_len + n != len(message):
|
||||
raise AnimatedQrError("decoded message has an inconsistent CBOR length")
|
||||
return message[header_len:]
|
||||
|
||||
|
||||
def _cbor_unsigned(value: int) -> bytes:
|
||||
if value < 24:
|
||||
return bytes([_UNSIGNED_RES + value])
|
||||
if value <= 0xFF:
|
||||
return bytes([0x18, value])
|
||||
if value <= 0xFFFF:
|
||||
return bytes([0x19]) + value.to_bytes(2, "big")
|
||||
if value <= 0xFFFFFFFF:
|
||||
return bytes([0x1A]) + value.to_bytes(4, "big")
|
||||
return bytes([0x1B]) + value.to_bytes(8, "big")
|
||||
|
||||
|
||||
def cbor_part(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> bytes:
|
||||
"""The CBOR body of a BC-UR v2 fountain part (``[seq, seq_len, message_len, checksum, data]``)."""
|
||||
out = bytearray([_ARRAY_RES + 5])
|
||||
out += _cbor_unsigned(seq_num)
|
||||
out += _cbor_unsigned(seq_len)
|
||||
out += _cbor_unsigned(message_len)
|
||||
out += _cbor_unsigned(checksum)
|
||||
out += cbor_byte_string(data)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _need(buf: bytes, pos: int, count: int) -> None:
|
||||
if pos + count > len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
|
||||
|
||||
def _cbor_read_unsigned(buf: bytes, pos: int) -> Tuple[int, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
if octet & 0xE0 != _UNSIGNED_RES:
|
||||
raise AnimatedQrError("unexpected CBOR type in part header")
|
||||
pos += 1
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
return additional, pos
|
||||
if additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
return buf[pos], pos + 1
|
||||
if additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
if additional == 26:
|
||||
_need(buf, pos, 4)
|
||||
return int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
|
||||
if additional == 27:
|
||||
_need(buf, pos, 8)
|
||||
return int.from_bytes(buf[pos : pos + 8], "big"), pos + 8
|
||||
raise AnimatedQrError("unsupported CBOR integer width in part header")
|
||||
|
||||
|
||||
def _cbor_read_bytes(buf: bytes, pos: int) -> Tuple[bytes, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
pos += 1
|
||||
if octet & 0xE0 != _BYTE_STR_RES:
|
||||
raise AnimatedQrError("expected a CBOR byte string in part header")
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
n = additional
|
||||
elif additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
n, pos = buf[pos], pos + 1
|
||||
elif additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
n, pos = int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
elif additional == 26:
|
||||
_need(buf, pos, 4)
|
||||
n, pos = int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
|
||||
else:
|
||||
raise AnimatedQrError("unsupported CBOR byte-string width in part header")
|
||||
_need(buf, pos, n)
|
||||
return buf[pos : pos + n], pos + n
|
||||
|
||||
|
||||
def _cbor_read_array(buf: bytes, pos: int) -> Tuple[int, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
pos += 1
|
||||
if octet & 0xE0 != _ARRAY_RES:
|
||||
raise AnimatedQrError("expected a CBOR array in part header")
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
return additional, pos
|
||||
if additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
return buf[pos], pos + 1
|
||||
if additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
raise AnimatedQrError("unsupported CBOR array header in part")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRC-32 (same polynomial as ``zlib.crc32``, network byte order)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def crc32_int(data: bytes) -> int:
|
||||
"""CRC-32 over ``data`` as an unsigned 32-bit integer."""
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def crc32_bytes(data: bytes) -> bytes:
|
||||
"""CRC-32 over ``data`` as 4 network-order (big-endian) bytes."""
|
||||
return crc32_int(data).to_bytes(4, "big")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bytewords (BCR-2020-012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BYTEWORDS = (
|
||||
"ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbr"
|
||||
"ewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadays"
|
||||
"delidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfa"
|
||||
"ctfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgift"
|
||||
"girlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhu"
|
||||
"tsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjury"
|
||||
"keepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlo"
|
||||
"goloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneed"
|
||||
"newsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolpo"
|
||||
"sepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesaga"
|
||||
"scarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoilto"
|
||||
"mbtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswall"
|
||||
"wandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzi"
|
||||
"nczonezoom"
|
||||
)
|
||||
|
||||
_WORDS = [_BYTEWORDS[i : i + 4] for i in range(0, 1024, 4)]
|
||||
_DIM = 26
|
||||
_WORD_LOOKUP: Optional[List[int]] = None
|
||||
|
||||
|
||||
def _word_lookup() -> List[int]:
|
||||
"""First/last-letter lookup table (built lazily, mirrors Bytewords)."""
|
||||
global _WORD_LOOKUP
|
||||
if _WORD_LOOKUP is None:
|
||||
table = [-1] * (_DIM * _DIM)
|
||||
for i, word in enumerate(_WORDS):
|
||||
x = ord(word[0]) - ord("a")
|
||||
y = ord(word[3]) - ord("a")
|
||||
table[y * _DIM + x] = i
|
||||
_WORD_LOOKUP = table
|
||||
return _WORD_LOOKUP
|
||||
|
||||
|
||||
def _decode_word(word: str, word_len: int) -> int:
|
||||
if len(word) != word_len:
|
||||
raise AnimatedQrError("invalid bytewords word length")
|
||||
x = ord(word[0]) - ord("a")
|
||||
y = ord(word[3] if word_len == 4 else word[1]) - ord("a")
|
||||
if not (0 <= x < _DIM and 0 <= y < _DIM):
|
||||
raise AnimatedQrError("invalid bytewords characters")
|
||||
value = _word_lookup()[y * _DIM + x]
|
||||
if value == -1:
|
||||
raise AnimatedQrError("invalid bytewords first/last pair")
|
||||
if word_len == 4:
|
||||
full = _WORDS[value]
|
||||
if word[1] != full[1] or word[2] != full[2]:
|
||||
raise AnimatedQrError("invalid bytewords middle letters")
|
||||
return value
|
||||
|
||||
|
||||
def bytewords_minimal_encode(data: bytes) -> str:
|
||||
"""BCR-2020-012 bytewords-minimal: one two-letter word per byte, then CRC."""
|
||||
crc = data + crc32_bytes(data)
|
||||
return "".join(_WORDS[b][0] + _WORDS[b][3] for b in crc)
|
||||
|
||||
|
||||
def bytewords_minimal_decode(text: str) -> bytes:
|
||||
"""Inverse of :func:`bytewords_minimal_encode` (validates the CRC-32)."""
|
||||
if len(text) % 2:
|
||||
raise AnimatedQrError("invalid bytewords length (odd)")
|
||||
values = [_decode_word(text[i : i + 2], 2) for i in range(0, len(text), 2)]
|
||||
payload = bytes(values)
|
||||
if len(payload) < 5:
|
||||
raise AnimatedQrError("bytewords payload too short")
|
||||
body, checksum = payload[:-4], payload[-4:]
|
||||
if crc32_bytes(body) != checksum:
|
||||
raise AnimatedQrError("bytewords CRC-32 mismatch")
|
||||
return body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC32 (the deprecated bech32-derived codec used by BC-UR v1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BC32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BC32_REV = {ch: i for i, ch in enumerate(_BC32_ALPHABET)}
|
||||
_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
|
||||
|
||||
def bech32_polymod(values: Sequence[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bc32_checksum(values: List[int]) -> List[int]:
|
||||
polymod = bech32_polymod([0] + values + [0] * 6) ^ 0x3FFFFFFF
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bc32_verify(values: List[int]) -> bool:
|
||||
return bech32_polymod([0] + values) == 0x3FFFFFFF
|
||||
|
||||
|
||||
def bc32_encode(data: bytes) -> str:
|
||||
"""BCR-2020-005 BC32: bech32 without the human-readable part and divider."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
values: List[int] = []
|
||||
for byte in data:
|
||||
acc = (acc << 8) | byte
|
||||
bits += 8
|
||||
while bits >= 5:
|
||||
bits -= 5
|
||||
values.append((acc >> bits) & 31)
|
||||
if bits:
|
||||
values.append((acc << (5 - bits)) & 31)
|
||||
values += _bc32_checksum(values)
|
||||
return "".join(_BC32_ALPHABET[v] for v in values)
|
||||
|
||||
|
||||
def bc32_decode(text: str) -> bytes:
|
||||
"""Inverse of :func:`bc32_encode` (validates the 6-char checksum)."""
|
||||
lowered = text.lower()
|
||||
try:
|
||||
values = [_BC32_REV[ch] for ch in lowered]
|
||||
except KeyError:
|
||||
raise AnimatedQrError("invalid BC-UR v1 character") from None
|
||||
if len(values) < 6 or not _bc32_verify(values):
|
||||
raise AnimatedQrError("invalid BC-UR v1 checksum")
|
||||
data = values[:-6]
|
||||
acc = 0
|
||||
bits = 0
|
||||
out = bytearray()
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
if bits >= 8:
|
||||
bits -= 8
|
||||
out.append((acc >> bits) & 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# xoshiro256** + alias sampler (exact ports of the reference RNG chain)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_MASK64 = (1 << 64) - 1
|
||||
|
||||
|
||||
class _Xoshiro256:
|
||||
"""xoshiro256** 1.0, seeded via SHA-256 of a byte sequence."""
|
||||
|
||||
def __init__(self, seed: bytes):
|
||||
digest = hashlib.sha256(seed).digest()
|
||||
self._s = [
|
||||
int.from_bytes(digest[offset : offset + 8], "big")
|
||||
for offset in range(0, 32, 8)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _rotl(x: int, k: int) -> int:
|
||||
return ((x << k) | (x >> (64 - k))) & _MASK64
|
||||
|
||||
def next(self) -> int:
|
||||
result = (self._rotl((self._s[1] * 5) & _MASK64, 7) * 9) & _MASK64
|
||||
t = (self._s[1] << 17) & _MASK64
|
||||
s = self._s
|
||||
s[2] ^= s[0]
|
||||
s[3] ^= s[1]
|
||||
s[1] ^= s[2]
|
||||
s[0] ^= s[3]
|
||||
s[2] ^= t
|
||||
s[3] = self._rotl(s[3], 45)
|
||||
return result
|
||||
|
||||
def next_double(self) -> float:
|
||||
return self.next() / float(1 << 64)
|
||||
|
||||
def next_int(self, low: int, high: int) -> int:
|
||||
return int(self.next_double() * (high - low + 1)) + low
|
||||
|
||||
|
||||
class _RandomAliasSampler:
|
||||
"""Vose's alias method, built in the exact order of the reference code."""
|
||||
|
||||
def __init__(self, probs: Sequence[float]):
|
||||
total = sum(probs)
|
||||
assert total > 0
|
||||
n = len(probs)
|
||||
normalized = [p * float(n) / total for p in probs]
|
||||
|
||||
small: List[int] = []
|
||||
large: List[int] = []
|
||||
for i in range(n - 1, -1, -1):
|
||||
(small if normalized[i] < 1 else large).append(i)
|
||||
|
||||
self._probs = [0] * n
|
||||
self._aliases = [0] * n
|
||||
while small and large:
|
||||
a = small.pop()
|
||||
g = large.pop()
|
||||
self._probs[a] = normalized[a]
|
||||
self._aliases[a] = g
|
||||
normalized[g] += normalized[a] - 1
|
||||
(small if normalized[g] < 1 else large).append(g)
|
||||
|
||||
while large:
|
||||
self._probs[large.pop()] = 1
|
||||
while small:
|
||||
self._probs[small.pop()] = 1
|
||||
|
||||
def next(self, rng: _Xoshiro256) -> int:
|
||||
r1 = rng.next_double()
|
||||
r2 = rng.next_double()
|
||||
n = len(self._probs)
|
||||
i = int(float(n) * r1)
|
||||
return i if r2 < self._probs[i] else self._aliases[i]
|
||||
|
||||
|
||||
def choose_fragments(seq_num: int, seq_len: int, checksum: int) -> Set[int]:
|
||||
"""The fragments mixed into a BC-UR v2 fountain part (reference seed math).
|
||||
|
||||
Sequence numbers ``1..seq_len`` emit the pure fragment ``{seq_num - 1}``;
|
||||
every larger sequence number deterministically mixes a pseudo-random
|
||||
subset of fragments seeded by ``SHA256(seq ‖ checksum)``.
|
||||
"""
|
||||
if seq_num <= seq_len:
|
||||
return {seq_num - 1}
|
||||
seed = seq_num.to_bytes(4, "big") + checksum.to_bytes(4, "big")
|
||||
rng = _Xoshiro256(seed)
|
||||
probs: List[float] = [1.0 / i for i in range(1, seq_len + 1)]
|
||||
degree = _RandomAliasSampler(probs).next(rng) + 1
|
||||
remaining = list(range(seq_len))
|
||||
shuffled: List[int] = []
|
||||
while remaining:
|
||||
index = rng.next_int(0, len(remaining) - 1)
|
||||
shuffled.append(remaining.pop(index))
|
||||
return set(shuffled[:degree])
|
||||
|
||||
|
||||
def _partition_message(message: bytes, fragment_len: int) -> List[bytes]:
|
||||
fragments: List[bytes] = []
|
||||
for offset in range(0, len(message), fragment_len):
|
||||
fragment = message[offset : offset + fragment_len]
|
||||
if len(fragment) < fragment_len:
|
||||
fragment += b"\x00" * (fragment_len - len(fragment))
|
||||
fragments.append(fragment)
|
||||
return fragments
|
||||
|
||||
|
||||
def _mix_fragments(fragments: Sequence[bytes], indexes: Set[int], fragment_len: int) -> bytes:
|
||||
result = bytearray(fragment_len)
|
||||
for index in indexes:
|
||||
for i, byte in enumerate(fragments[index]):
|
||||
result[i] ^= byte
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2 (bytewords-minimal + fountain)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ur2_header(seq_num: int, seq_len: int) -> str:
|
||||
return "ur:bytes/{}-{}/".format(seq_num, seq_len)
|
||||
|
||||
|
||||
def _ur2_part_string(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> str:
|
||||
body = cbor_part(seq_num, seq_len, message_len, checksum, data)
|
||||
return _ur2_header(seq_num, seq_len) + bytewords_minimal_encode(body)
|
||||
|
||||
|
||||
def _ur2_part_cost(seq_num: int, seq_len: int, message_len: int, checksum: int, data_len: int) -> int:
|
||||
body_len = len(cbor_part(seq_num, seq_len, message_len, checksum, b"\x00" * data_len))
|
||||
# bytewords_minimal_encode appends a 4-byte CRC over the body.
|
||||
return len(_ur2_header(seq_num, seq_len)) + 2 * (body_len + 4)
|
||||
|
||||
|
||||
def ur2_frames(payload: bytes, budget_chars: int) -> List[str]:
|
||||
"""Encode ``payload`` into BC-UR v2 fountain frames.
|
||||
|
||||
``budget_chars`` is the largest frame string the carrying QR code may
|
||||
hold. The first ``seq_len`` frames are pure (one fragment each); a second
|
||||
wave of ``seq_len`` mixed (fountain) frames follows so the receiver can
|
||||
recover with a few parts still missing.
|
||||
"""
|
||||
message = cbor_byte_string(payload)
|
||||
message_len = len(message)
|
||||
checksum = crc32_int(message)
|
||||
single_cost = len("ur:bytes/") + len(bytewords_minimal_encode(message))
|
||||
if single_cost <= budget_chars:
|
||||
return ["ur:bytes/" + bytewords_minimal_encode(message)]
|
||||
|
||||
fragment_len = message_len
|
||||
fragment_count = 1
|
||||
while True:
|
||||
seq_len = fragment_count
|
||||
worst_seq = 2 * seq_len # the export loop emits up to 2*seq_len parts
|
||||
cost = _ur2_part_cost(worst_seq, seq_len, message_len, checksum, fragment_len)
|
||||
if cost <= budget_chars:
|
||||
break
|
||||
fragment_count += 1
|
||||
fragment_len = -(-message_len // fragment_count)
|
||||
if fragment_count > message_len:
|
||||
raise AnimatedQrError("QR budget too small for a BC-UR v2 part")
|
||||
|
||||
fragments = _partition_message(message, fragment_len)
|
||||
frames: List[str] = []
|
||||
for seq_num in range(1, seq_len + 1):
|
||||
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, fragments[seq_num - 1]))
|
||||
for seq_num in range(seq_len + 1, 2 * seq_len + 1):
|
||||
data = _mix_fragments(fragments, choose_fragments(seq_num, seq_len, checksum), fragment_len)
|
||||
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, data))
|
||||
return frames
|
||||
|
||||
|
||||
def ur2_parse_part(frame_text: str) -> Tuple[int, int, int, int, bytes]:
|
||||
"""Parse a BC-UR v2 part into ``(seq, seq_len, message_len, checksum, data)``."""
|
||||
frame_text = frame_text.strip().lower()
|
||||
prefix = "ur:bytes/"
|
||||
if not frame_text.startswith(prefix):
|
||||
raise AnimatedQrError("not a BC-UR v2 part")
|
||||
tail = frame_text[len(prefix) :]
|
||||
if "/" not in tail:
|
||||
body = bytewords_minimal_decode(tail)
|
||||
return 1, 1, len(body), crc32_int(body), body
|
||||
seq_head, words = tail.split("/", 1)
|
||||
try:
|
||||
seq_num_s, seq_len_s = seq_head.split("-", 1)
|
||||
seq_num, seq_len = int(seq_num_s), int(seq_len_s)
|
||||
except ValueError:
|
||||
raise AnimatedQrError("bad BC-UR v2 sequence header") from None
|
||||
if seq_len < 1 or not 1 <= seq_num <= 2**32 - 1:
|
||||
raise AnimatedQrError("bad BC-UR v2 sequence numbers")
|
||||
body = bytewords_minimal_decode(words)
|
||||
arr, pos = _cbor_read_array(body, 0)
|
||||
if arr != 5:
|
||||
raise AnimatedQrError("bad BC-UR v2 part header arity")
|
||||
seq_again, pos = _cbor_read_unsigned(body, pos)
|
||||
seq_len_again, pos = _cbor_read_unsigned(body, pos)
|
||||
message_len, pos = _cbor_read_unsigned(body, pos)
|
||||
checksum, pos = _cbor_read_unsigned(body, pos)
|
||||
data, pos = _cbor_read_bytes(body, pos)
|
||||
if pos != len(body):
|
||||
raise AnimatedQrError("trailing garbage in BC-UR v2 part header")
|
||||
if seq_again != seq_num or seq_len_again != seq_len:
|
||||
raise AnimatedQrError("BC-UR v2 part header mismatch")
|
||||
return seq_num, seq_len, message_len, checksum, bytes(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v1 (BCR-2020-005 rev1: BC32 fragments + SHA-256 digest)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ur1_digest(message: bytes) -> str:
|
||||
return bc32_encode(hashlib.sha256(message).digest())
|
||||
|
||||
|
||||
def _ur1_prefix(index: int, total: int, digest: str) -> str:
|
||||
return "ur:bytes/{}{}/{}/".format(
|
||||
index, "of{}".format(total), digest
|
||||
)
|
||||
|
||||
|
||||
def ur1_frames(payload: bytes, budget_chars: int) -> List[str]:
|
||||
"""Encode ``payload`` into BC-UR v1 fragments (``NofM`` + BC32 + digest)."""
|
||||
message = cbor_byte_string(payload)
|
||||
digest = _ur1_digest(message)
|
||||
full = bc32_encode(message)
|
||||
|
||||
total = 1
|
||||
while True:
|
||||
longest = _ur1_prefix(total, total, digest)
|
||||
capacity = budget_chars - len(longest)
|
||||
if capacity < 1:
|
||||
raise AnimatedQrError("QR budget too small for BC-UR v1")
|
||||
if len(full) <= capacity * total:
|
||||
break
|
||||
total += 1
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise AnimatedQrError("BC-UR v1 transfer demands too many parts")
|
||||
|
||||
frames: List[str] = []
|
||||
pos = 0
|
||||
for index in range(1, total + 1):
|
||||
prefix = _ur1_prefix(index, total, digest)
|
||||
capacity = budget_chars - len(prefix)
|
||||
frames.append(prefix + full[pos : pos + capacity])
|
||||
pos += capacity
|
||||
return frames
|
||||
|
||||
|
||||
def ur1_parse_part(frame_text: str) -> Tuple[int, int, str, str]:
|
||||
"""Parse a BC-UR v1 part into ``(index, total, digest, fragment)``.
|
||||
|
||||
Accepts both the multipart form (``ur:bytes/NofM/<digest>/<frag>``) and
|
||||
the single-part form (``ur:bytes/<frag>``, no sequence header or digest).
|
||||
"""
|
||||
frame_text = frame_text.strip().lower()
|
||||
prefix = "ur:bytes/"
|
||||
if not frame_text.startswith(prefix):
|
||||
raise AnimatedQrError("not a BC-UR v1 part")
|
||||
tail = frame_text[len(prefix) :]
|
||||
parts = tail.split("/")
|
||||
if len(parts) == 1:
|
||||
return 1, 1, "", parts[0]
|
||||
if len(parts) != 3:
|
||||
raise AnimatedQrError("bad BC-UR v1 part structure")
|
||||
seq_head, digest, fragment = parts
|
||||
if "of" not in seq_head:
|
||||
raise AnimatedQrError("BC-UR v1 part misses the sequence header")
|
||||
try:
|
||||
index_s, total_s = seq_head.split("of", 1)
|
||||
index, total = int(index_s), int(total_s)
|
||||
except ValueError:
|
||||
raise AnimatedQrError("bad BC-UR v1 sequence header") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise AnimatedQrError("bad BC-UR v1 sequence numbers")
|
||||
if len(digest) != 58:
|
||||
raise AnimatedQrError("bad BC-UR v1 digest")
|
||||
return index, total, digest, fragment
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BBQR (Coinkite)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -774,9 +164,6 @@ def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
FORMAT_LABELS = {
|
||||
"balqr": "BAL QR",
|
||||
"ur1": "BC-UR v1",
|
||||
"ur2": "BC-UR v2",
|
||||
"bbqr": "BBQR",
|
||||
}
|
||||
|
||||
@@ -791,34 +178,8 @@ def detect_format(text: str) -> Optional[str]:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
lowered = text.lower()
|
||||
if lowered.startswith(("balqr", "bal1")):
|
||||
return "balqr"
|
||||
if text.startswith(_BBQR_PREFIX):
|
||||
return "bbqr"
|
||||
if not lowered.startswith("ur:"):
|
||||
return None
|
||||
if lowered.startswith("ur:bytes/"):
|
||||
remainder = lowered[len("ur:bytes/") :]
|
||||
first = remainder.split("/", 1)[0]
|
||||
if "of" in first:
|
||||
return "ur1"
|
||||
if "-" in first:
|
||||
return "ur2"
|
||||
# Single-part: the whole remainder is the body. Prefer a bytewords v2
|
||||
# body (CBOR byte-string head 0x40..0x60), then BC32 v1.
|
||||
try:
|
||||
body = bytewords_minimal_decode(remainder)
|
||||
except AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
if body and 0x40 <= body[0] <= 0x60:
|
||||
return "ur2"
|
||||
try:
|
||||
bc32_decode(remainder)
|
||||
except AnimatedQrError:
|
||||
return None
|
||||
return "ur1"
|
||||
return None
|
||||
|
||||
|
||||
@@ -830,253 +191,16 @@ def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
|
||||
:class:`AnimatedQrError` when the text cannot be parsed.
|
||||
"""
|
||||
fmt = detect_format(text)
|
||||
if fmt == "balqr":
|
||||
total, index, _compressed, _payload = _parse_balqr(text)
|
||||
return "balqr", "balqr:{}".format(total), total, index
|
||||
if fmt == "ur1":
|
||||
index, total, digest, _frag = ur1_parse_part(text)
|
||||
return "ur1", "ur1:{}".format(digest), total, index
|
||||
if fmt == "ur2":
|
||||
seq, seq_len, message_len, checksum, _data = ur2_parse_part(text)
|
||||
return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq
|
||||
if fmt == "bbqr":
|
||||
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
|
||||
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
|
||||
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||
|
||||
|
||||
def _parse_balqr(text: str) -> Tuple[int, int, bool, str]:
|
||||
from bal.core.qrtransfer import parse_frame
|
||||
|
||||
return parse_frame(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Receive sessions (order-independent assembly per format)
|
||||
# Receive session (order-independent assembly)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class _BalQrSession:
|
||||
def __init__(self):
|
||||
self._frames: Dict[int, str] = {}
|
||||
self._total = 0
|
||||
self._compressed = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._frames) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
total, index, compressed, payload = _parse_balqr(text)
|
||||
if self._total and total != self._total:
|
||||
raise TransferConflictError("BAL QR transfer total changed")
|
||||
if len(self._frames) >= _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("too many BAL QR frames")
|
||||
if not self._total:
|
||||
self._total = total
|
||||
self._compressed = compressed
|
||||
if index in self._frames:
|
||||
return "dup"
|
||||
self._frames[index] = payload
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
from bal.core.qrtransfer import assemble
|
||||
|
||||
text = assemble(self._frames, self._total)
|
||||
return text, self._compressed
|
||||
|
||||
|
||||
class _Ur1Session:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
self._digest = ""
|
||||
self._fragments: Dict[int, str] = {}
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._fragments)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._fragments) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
index, total, digest, fragment = ur1_parse_part(text)
|
||||
if self._total:
|
||||
if total != self._total or digest != self._digest:
|
||||
raise TransferConflictError("BC-UR v1 transfer digest changed")
|
||||
else:
|
||||
self._total = total
|
||||
self._digest = digest
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("BC-UR v1 demands too many parts")
|
||||
if index in self._fragments:
|
||||
return "dup"
|
||||
self._fragments[index] = fragment
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
full = "".join(self._fragments[i] for i in range(1, self._total + 1))
|
||||
try:
|
||||
message = bc32_decode(full)
|
||||
except AnimatedQrError:
|
||||
raise ChecksumError("BC-UR v1 checksum mismatch") from None
|
||||
if self._digest and _ur1_digest(message) != self._digest:
|
||||
raise ChecksumError("BC-UR v1 digest mismatch")
|
||||
return _transfer_text(unwrap_ur_cbor(message)), False
|
||||
|
||||
|
||||
class _Ur2Session:
|
||||
"""Fountain decoder mirroring the reference (C++/python) semantics."""
|
||||
|
||||
def __init__(self):
|
||||
self._seq_len = 0
|
||||
self._message_len = 0
|
||||
self._checksum = 0
|
||||
self._fragment_len = 0
|
||||
self._received: Set[int] = set()
|
||||
self._simple: Dict[FrozenSet[int], bytes] = {}
|
||||
self._mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
self._queue: List[Tuple[FrozenSet[int], bytes]] = []
|
||||
self._processed = 0
|
||||
self._result: Optional[bytes] = None
|
||||
self._bad = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._seq_len
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return self._processed
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self._result is not None
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
seq, seq_len, message_len, checksum, data = ur2_parse_part(text)
|
||||
if self._seq_len:
|
||||
if not self._validate(seq_len, message_len, checksum, len(data)):
|
||||
raise TransferConflictError("BC-UR v2 transfer header changed")
|
||||
else:
|
||||
self._seq_len = seq_len
|
||||
self._message_len = message_len
|
||||
self._checksum = checksum
|
||||
self._fragment_len = len(data)
|
||||
if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES:
|
||||
raise SessionLimitError("BC-UR v2 session exceeds safety caps")
|
||||
indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum))
|
||||
self._receive(indexes, bytes(data))
|
||||
return "ok"
|
||||
|
||||
def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool:
|
||||
return (
|
||||
seq_len == self._seq_len
|
||||
and message_len == self._message_len
|
||||
and checksum == self._checksum
|
||||
and data_len == self._fragment_len
|
||||
)
|
||||
|
||||
def _receive(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if self._result is not None or self._bad:
|
||||
return
|
||||
self._queue.append((indexes, data))
|
||||
while self._result is None and not self._bad and self._queue:
|
||||
self._process(self._queue.pop(0))
|
||||
self._processed += 1
|
||||
|
||||
def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None:
|
||||
indexes, data = item
|
||||
if len(indexes) == 1:
|
||||
self._process_simple(indexes, data)
|
||||
else:
|
||||
self._process_mixed(indexes, data)
|
||||
|
||||
def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
fragment_index = next(iter(indexes))
|
||||
if fragment_index in self._received:
|
||||
return
|
||||
self._simple[indexes] = data
|
||||
self._received.add(fragment_index)
|
||||
if self._received == set(range(self._seq_len)):
|
||||
self._finish()
|
||||
return
|
||||
self._reduce_mixed_by(indexes, data)
|
||||
|
||||
def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
new_mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
for other_indexes, other_data in self._mixed.items():
|
||||
reduced = self._reduce_part(other_indexes, other_data, indexes, data)
|
||||
if len(reduced[0]) == 1:
|
||||
self._queue.append(reduced)
|
||||
else:
|
||||
new_mixed[reduced[0]] = reduced[1]
|
||||
self._mixed = new_mixed
|
||||
|
||||
def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if indexes in self._mixed:
|
||||
return
|
||||
reduced_indexes, reduced_data = indexes, data
|
||||
for simple_indexes, simple_data in self._simple.items():
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, simple_indexes, simple_data
|
||||
)
|
||||
for other_indexes, other_data in list(self._mixed.items()):
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, other_indexes, other_data
|
||||
)
|
||||
if len(reduced_indexes) == 1:
|
||||
self._queue.append((reduced_indexes, reduced_data))
|
||||
else:
|
||||
self._reduce_mixed_by(reduced_indexes, reduced_data)
|
||||
if reduced_indexes not in self._mixed:
|
||||
self._mixed[reduced_indexes] = reduced_data
|
||||
|
||||
@staticmethod
|
||||
def _reduce_part(
|
||||
a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes
|
||||
) -> Tuple[FrozenSet[int], bytes]:
|
||||
if b_indexes == a_indexes or not b_indexes.issubset(a_indexes):
|
||||
return a_indexes, a_data
|
||||
new_indexes = a_indexes - b_indexes
|
||||
new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True))
|
||||
return new_indexes, new_data
|
||||
|
||||
def _finish(self) -> None:
|
||||
fragments = []
|
||||
for index in range(self._seq_len):
|
||||
key = frozenset([index])
|
||||
if key not in self._simple:
|
||||
self._bad = True
|
||||
return
|
||||
fragments.append(self._simple[key])
|
||||
message = b"".join(fragments)[: self._message_len]
|
||||
if crc32_int(message) != self._checksum:
|
||||
self._bad = True
|
||||
return
|
||||
self._result = message
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
if self._bad:
|
||||
raise ChecksumError("BC-UR v2 message checksum mismatch")
|
||||
if self._result is None:
|
||||
raise AnimatedQrError("BC-UR v2 session is not complete")
|
||||
return _transfer_text(unwrap_ur_cbor(self._result)), False
|
||||
|
||||
|
||||
class _BbqrSession:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
@@ -1126,8 +250,6 @@ def _transfer_text(raw: bytes) -> str:
|
||||
class AnimatedQrSession:
|
||||
"""Facade over the per-format receive sessions used by the QR import page."""
|
||||
|
||||
_DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession"))
|
||||
|
||||
def __init__(self):
|
||||
self._inner: Optional[object] = None
|
||||
self._fmt: Optional[str] = None
|
||||
@@ -1152,12 +274,6 @@ class AnimatedQrSession:
|
||||
|
||||
@staticmethod
|
||||
def _make(fmt: str) -> object:
|
||||
if fmt == "balqr":
|
||||
return _BalQrSession()
|
||||
if fmt == "ur1":
|
||||
return _Ur1Session()
|
||||
if fmt == "ur2":
|
||||
return _Ur2Session()
|
||||
if fmt == "bbqr":
|
||||
return _BbqrSession()
|
||||
raise AssertionError("unknown animated-QR format {}".format(fmt))
|
||||
|
||||
@@ -305,11 +305,10 @@ class BalPlugin(BasePlugin):
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
|
||||
# per QR frame when exporting/importing a will through the QR channel.
|
||||
# The settings dialog offers the 4 standard presets of
|
||||
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
|
||||
# Default 150 (small QR, low-resolution cameras).
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
|
||||
# used per QR frame when exporting/importing a will through the QR
|
||||
# channel. The export dialog seeds its own size selector from this
|
||||
# value (default 150, small QR, low-resolution cameras).
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
|
||||
@@ -9,31 +9,11 @@ Scope
|
||||
-----
|
||||
* converts will transactions into a compact ``transfer_string``
|
||||
(newline-joined serialized transactions, optionally zlib + base64
|
||||
compressed);
|
||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
compressed). The transfer string is the payload carried by BBQR QR frames
|
||||
(``bal.core.animated_qr``).
|
||||
|
||||
Wire format (v2, compact)
|
||||
-------------------------
|
||||
A frame is::
|
||||
|
||||
BAL1<TTT><iii><flag><payload>
|
||||
|
||||
* ``BAL1`` - magic + format era (4 chars).
|
||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||
* ``payload`` - every other character of the frame; the payloads of all
|
||||
frames, concatenated in index order, rebuild the transfer string.
|
||||
|
||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||
(same 5 pieces of information) without any pipe separator, so the whole
|
||||
frame is scan-friendly and the overhead no longer grows with the frame
|
||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||
|
||||
The audio-modem channel deliberately bypasses the framing helpers here
|
||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||
carries the whole transfer string in a single blob, so callers only use
|
||||
The audio-modem channel carries the whole transfer string in a single blob
|
||||
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -44,12 +24,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
FLAG_PLAIN = "0"
|
||||
|
||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||
# 4 standard presets (label, payload budget per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
@@ -58,61 +33,16 @@ CHUNK_PRESETS = (
|
||||
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||
)
|
||||
|
||||
# Smallest allowed payload budget per frame, below which the frame header
|
||||
# could consume the whole budget.
|
||||
MIN_CHUNK_SIZE = 40
|
||||
|
||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||
|
||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||
_FRAME_MAGIC_V2 = "BAL1"
|
||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
_BASE36_WIDTH = 3
|
||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||
|
||||
|
||||
class QrTransferError(ValueError):
|
||||
"""Base error for will QR / audio transfer processing."""
|
||||
|
||||
|
||||
class MissingFramesError(QrTransferError):
|
||||
"""Some frame indices of a multi-QR transfer are missing."""
|
||||
|
||||
def __init__(self, missing):
|
||||
self.missing = list(missing)
|
||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||
|
||||
|
||||
class InconsistentTotalError(QrTransferError):
|
||||
"""Frames disagree about the advertised frame total."""
|
||||
|
||||
|
||||
def encode_transfer(tx_strings, compress=False):
|
||||
"""Join serialized transaction strings into a transfer string.
|
||||
|
||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||
of the frame header lets the importer reverse this automatically.
|
||||
the whole bundle shrinks before being printed/scanned.
|
||||
"""
|
||||
return __compress("\n".join(tx_strings), enabled=compress)
|
||||
|
||||
|
||||
def encode_transfer_best(tx_strings):
|
||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||
|
||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||
when zlib + base64 really is shorter (best-of, never larger).
|
||||
"""
|
||||
joined = "\n".join(tx_strings)
|
||||
plain = joined
|
||||
compressed = __compress(joined, enabled=True)
|
||||
if len(compressed) < len(plain):
|
||||
return compressed, True
|
||||
return plain, False
|
||||
|
||||
|
||||
def decode_transfer(transfer_string, compressed):
|
||||
"""Inverse of :func:`encode_transfer`.
|
||||
|
||||
@@ -124,73 +54,6 @@ def decode_transfer(transfer_string, compressed):
|
||||
return [part for part in text.split("\n") if part]
|
||||
|
||||
|
||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||
|
||||
Every returned frame has the fixed 11-char v2 header followed by its
|
||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||
importer knows how to reverse the encoding.
|
||||
|
||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||
the header plus any payload, or when the transfer needs more than
|
||||
:data:`_MAX_TOTAL` frames.
|
||||
"""
|
||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||
total = __compute_total(len(transfer_string), chunk_size)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
frames = []
|
||||
pos = 0
|
||||
length = len(transfer_string)
|
||||
for index in range(1, total + 1):
|
||||
end = min(pos + budget, length)
|
||||
frames.append(
|
||||
_FRAME_MAGIC_V2
|
||||
+ _base36(total)
|
||||
+ _base36(index)
|
||||
+ flag
|
||||
+ transfer_string[pos:end]
|
||||
)
|
||||
pos = end
|
||||
if pos < length:
|
||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_frame(frame):
|
||||
"""Parse a single frame.
|
||||
|
||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||
|
||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||
"""
|
||||
if frame.startswith(_FRAME_MAGIC_V2):
|
||||
return _parse_v2(frame)
|
||||
return _parse_v1(frame)
|
||||
|
||||
|
||||
def assemble(frames, total):
|
||||
"""Concatenate frame payloads back into a transfer string.
|
||||
|
||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||
``total`` (else :class:`InconsistentTotalError`).
|
||||
"""
|
||||
if total < 1:
|
||||
raise QrTransferError("invalid frame total")
|
||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||
if missing:
|
||||
raise MissingFramesError(missing)
|
||||
extra = [index for index in frames if index > total]
|
||||
if extra:
|
||||
raise InconsistentTotalError()
|
||||
return "".join(frames[index] for index in range(1, total + 1))
|
||||
|
||||
|
||||
def preset_index_for_chunk_size(chunk_size):
|
||||
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||
@@ -215,90 +78,3 @@ def __decompress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||
|
||||
|
||||
def _base36(n):
|
||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||
if not 0 <= n <= _MAX_TOTAL:
|
||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||
chars = []
|
||||
for _ in range(_BASE36_WIDTH):
|
||||
chars.append(_BASE36_DIGITS[n % 36])
|
||||
n //= 36
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _base36_decode(text):
|
||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||
raise ValueError(text)
|
||||
n = 0
|
||||
for c in text:
|
||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||
return n
|
||||
|
||||
|
||||
def _parse_v1(frame):
|
||||
parts = frame.split("|", maxsplit=4)
|
||||
if len(parts) != 5:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
magic_seen, total_s, index_s, flags, payload = parts
|
||||
if magic_seen != _FRAME_MAGIC_V1:
|
||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||
try:
|
||||
total = int(total_s)
|
||||
index = int(index_s)
|
||||
except ValueError as e:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flags not in ("", FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
return total, index, flags == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def _parse_v2(frame):
|
||||
if len(frame) < _HEADER_V2_LEN:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||
# make up the rest of the fixed header.
|
||||
offset = len(_FRAME_MAGIC_V2)
|
||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||
try:
|
||||
total = _base36_decode(total_s)
|
||||
index = _base36_decode(index_s)
|
||||
except ValueError:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
payload = frame[_HEADER_V2_LEN:]
|
||||
return total, index, flag == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def __compute_total(transfer_len, chunk_size):
|
||||
"""Smallest frame count whose budget covers the whole transfer string.
|
||||
|
||||
The v2 header is fixed-width, so the budget is constant and the count is
|
||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||
"""
|
||||
if chunk_size < MIN_CHUNK_SIZE:
|
||||
raise QrTransferError(
|
||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||
)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
total = -(-transfer_len // budget)
|
||||
if total < 1:
|
||||
total = 1
|
||||
if total > _MAX_TOTAL:
|
||||
raise QrTransferError(
|
||||
"BAL QR transfer demands too many frames: {}".format(total)
|
||||
)
|
||||
return total
|
||||
|
||||
@@ -43,8 +43,6 @@ from ...core.animated_qr import (
|
||||
bbqr_frames,
|
||||
format_name,
|
||||
parse_for_detection,
|
||||
ur1_frames,
|
||||
ur2_frames,
|
||||
)
|
||||
from ...core.checkalive import (
|
||||
CheckAliveAfterDeliveryError,
|
||||
@@ -54,13 +52,9 @@ from ...core.checkalive import (
|
||||
)
|
||||
from ...core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
encode_transfer_best,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
from ...core.reminders import build_ics_reminders
|
||||
from .calendar import BalCalendarButton
|
||||
@@ -147,29 +141,6 @@ if TYPE_CHECKING:
|
||||
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
||||
|
||||
|
||||
# Animated-QR formats beyond the legacy BAL QR. Combined with the format
|
||||
# selector in :class:`BalQrExportWidget` they give the export page
|
||||
# interoperable BC-UR v1/v2 and BBQR output while keeping BAL QR as the
|
||||
# default (and the only format understood by older plugin versions).
|
||||
ANIMATED_QR_FORMATS = ("ur1", "ur2", "bbqr")
|
||||
|
||||
|
||||
def encode_animated_frames(transfer, fmt, budget_chars):
|
||||
"""Encode the transfer text into the given animated-QR format.
|
||||
|
||||
``transfer`` is the BAL QR transfer text (str). ``budget_chars`` is the
|
||||
maximum length of one frame (the exporter's "QR code size" preset).
|
||||
"""
|
||||
payload = transfer.encode("utf-8")
|
||||
if fmt == "ur1":
|
||||
return ur1_frames(payload, budget_chars)
|
||||
if fmt == "ur2":
|
||||
return ur2_frames(payload, budget_chars)
|
||||
if fmt == "bbqr":
|
||||
return bbqr_frames(payload, budget_chars, encoding="Z")
|
||||
raise QrTransferError("unknown animated QR format: {}".format(fmt))
|
||||
|
||||
|
||||
class BalDialog(QDialog,MessageBoxMixin):
|
||||
_stopping = False
|
||||
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
|
||||
@@ -2764,10 +2735,10 @@ class BalQrExportWidget(QWidget):
|
||||
The page (re)displays itself on :meth:`set_tx_strings`.
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None):
|
||||
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None, on_size_change=None):
|
||||
QWidget.__init__(self, parent)
|
||||
self.chunk_size = chunk_size
|
||||
self.format = "balqr"
|
||||
self._on_size_change = on_size_change
|
||||
self.tx_strings = []
|
||||
self.transfer = ""
|
||||
self.frames = []
|
||||
@@ -2833,36 +2804,11 @@ class BalQrExportWidget(QWidget):
|
||||
size_row.addStretch(1)
|
||||
vbox.addLayout(size_row)
|
||||
|
||||
format_row = QHBoxLayout()
|
||||
format_row.addWidget(QLabel(_("Format:")))
|
||||
self.format_combo = QComboBox()
|
||||
self._format_options = ["balqr"] + list(ANIMATED_QR_FORMATS)
|
||||
self.format_combo.addItems(
|
||||
[
|
||||
format_name(fmt)
|
||||
+ (" {}".format(_("(default)")) if fmt == "balqr" else "")
|
||||
for fmt in self._format_options
|
||||
]
|
||||
)
|
||||
self.format_combo.setCurrentIndex(0)
|
||||
self.format_combo.currentIndexChanged.connect(self._on_format_change)
|
||||
format_row.addWidget(self.format_combo)
|
||||
self.format_hint = QLabel()
|
||||
self.format_hint.setWordWrap(True)
|
||||
format_row.addWidget(self.format_hint, 1)
|
||||
vbox.addLayout(format_row)
|
||||
|
||||
def set_tx_strings(self, tx_strings):
|
||||
"""Rebuild the transfer frames from the given serialized transactions."""
|
||||
self.tx_strings = list(tx_strings)
|
||||
self._stop_auto()
|
||||
self.transfer = encode_transfer(self.tx_strings, compress=False)
|
||||
# BAL QR now ships compact (best-of) compressed by default: smaller
|
||||
# frames, and the importer reverses it via the per-frame flag. The
|
||||
# other formats keep the raw transfer (they compress internally).
|
||||
self._balqr_transfer, self._balqr_compressed = encode_transfer_best(
|
||||
self.tx_strings
|
||||
)
|
||||
self._refresh_frames()
|
||||
self._update_intro()
|
||||
self._render()
|
||||
@@ -2872,30 +2818,18 @@ class BalQrExportWidget(QWidget):
|
||||
return len(self.frames)
|
||||
|
||||
def _refresh_frames(self):
|
||||
if self.format == "balqr":
|
||||
self.frames = split_frames(
|
||||
self._balqr_transfer,
|
||||
self.chunk_size,
|
||||
compressed=self._balqr_compressed,
|
||||
)
|
||||
else:
|
||||
self.frames = encode_animated_frames(
|
||||
self.transfer, self.format, self.chunk_size
|
||||
)
|
||||
self.frames = bbqr_frames(
|
||||
self.transfer.encode("utf-8"), self.chunk_size, encoding="Z"
|
||||
)
|
||||
self.index = 0
|
||||
|
||||
def _on_format_change(self, index):
|
||||
self._stop_auto()
|
||||
self.format = self._format_options[index]
|
||||
self._refresh_frames()
|
||||
self._render()
|
||||
self._update_intro()
|
||||
|
||||
def _on_chunk_change(self, index):
|
||||
self._stop_auto()
|
||||
self.chunk_size = CHUNK_PRESETS[index][1]
|
||||
self._refresh_frames()
|
||||
self._render()
|
||||
if self._on_size_change is not None:
|
||||
self._on_size_change(self.chunk_size)
|
||||
|
||||
def _toggle_auto(self):
|
||||
"""Start/stop the automatic QR slideshow."""
|
||||
@@ -2927,32 +2861,13 @@ class BalQrExportWidget(QWidget):
|
||||
self._next()
|
||||
|
||||
def _update_intro(self):
|
||||
self.format_hint.setText(
|
||||
{
|
||||
"balqr": _(
|
||||
"Proprietary format; every other BAL wallet understands it."
|
||||
),
|
||||
"ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older "
|
||||
"Blockchain Commons tools."),
|
||||
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
|
||||
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
|
||||
}[self.format]
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below with the will-opening device.\n"
|
||||
"{} codes carry the whole transfer (any order works, "
|
||||
"duplicates are ignored)."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
if self.format == "balqr":
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below, in order, with the will-opening "
|
||||
"device.\nFrame 1 of {} carries the total number of codes."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
else:
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below with the will-opening device.\n"
|
||||
"{} codes carry the whole transfer (any order works, "
|
||||
"duplicates are ignored)."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
|
||||
def _prev(self):
|
||||
if self.index > 0:
|
||||
@@ -2994,6 +2909,7 @@ class WillExportDialog(BalDialog):
|
||||
def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"):
|
||||
BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will"))
|
||||
self.bal_window = bal_window
|
||||
self.bal_plugin = bal_plugin
|
||||
self._source = will if will is not None else bal_window.willitems
|
||||
self._filters = export_filter_options()
|
||||
self._filter_index = 0
|
||||
@@ -3047,7 +2963,10 @@ class WillExportDialog(BalDialog):
|
||||
self.stacked = QStackedWidget()
|
||||
self.file_page = self._build_file_page()
|
||||
self.stacked.addWidget(self.file_page)
|
||||
self.qr_page = BalQrExportWidget(chunk_size=chunk)
|
||||
self.qr_page = BalQrExportWidget(
|
||||
chunk_size=chunk,
|
||||
on_size_change=self._remember_qr_size,
|
||||
)
|
||||
self.stacked.addWidget(self.qr_page)
|
||||
self.audio_page = self._build_audio_page()
|
||||
self.stacked.addWidget(self.audio_page)
|
||||
@@ -3057,12 +2976,19 @@ class WillExportDialog(BalDialog):
|
||||
close_btn.clicked.connect(self.close)
|
||||
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# The audio transport is only offered when the audio_modem plugin is
|
||||
# available; otherwise its radio button is disabled (file and QR stay
|
||||
# usable).
|
||||
self.transport_audio.setEnabled(self._audio_available)
|
||||
|
||||
mode_ids = {
|
||||
"file": self.MODE_FILE,
|
||||
"qr": self.MODE_QR,
|
||||
"audio": self.MODE_AUDIO,
|
||||
}
|
||||
mode = mode_ids.get(initial_mode, self.MODE_FILE)
|
||||
if mode == self.MODE_AUDIO and not self._audio_available:
|
||||
mode = self.MODE_FILE
|
||||
(self.transport_file, self.transport_qr, self.transport_audio)[
|
||||
mode
|
||||
].setChecked(True)
|
||||
@@ -3097,6 +3023,7 @@ class WillExportDialog(BalDialog):
|
||||
self._audio_plugin = self.bal_window.get_audio_modem_plugin()
|
||||
self.bitrates = audio_bitrates()
|
||||
available = self._audio_plugin is not None
|
||||
self._audio_available = available
|
||||
if available:
|
||||
self.audio_warn_label.hide()
|
||||
else:
|
||||
@@ -3125,6 +3052,14 @@ class WillExportDialog(BalDialog):
|
||||
def _selected_items(self):
|
||||
return filter_willitems(self._source, self._filters, self._filter_index)
|
||||
|
||||
def _remember_qr_size(self, size):
|
||||
"""Persist the QR size chosen in the export page for the next export."""
|
||||
if self.bal_plugin is not None:
|
||||
try:
|
||||
self.bal_plugin.QR_CHUNK_SIZE.set(size)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _payload_strings(self):
|
||||
"""The data handed to the QR page, honouring the content scope.
|
||||
|
||||
@@ -3296,6 +3231,10 @@ class WillImportDialog(BalDialog):
|
||||
close_btn.clicked.connect(self.close)
|
||||
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# The audio transport is only offered when the audio_modem plugin is
|
||||
# available; otherwise its radio button is disabled.
|
||||
self.transport_audio.setEnabled(self._audio_available)
|
||||
|
||||
self.transport_file.setChecked(True)
|
||||
self._on_mode_clicked(self.MODE_FILE)
|
||||
|
||||
@@ -3323,6 +3262,7 @@ class WillImportDialog(BalDialog):
|
||||
self._audio_plugin = self.bal_window.get_audio_modem_plugin()
|
||||
self.bitrates = audio_bitrates()
|
||||
available = self._audio_plugin is not None
|
||||
self._audio_available = available
|
||||
if available:
|
||||
self.audio_warn_label.hide()
|
||||
else:
|
||||
@@ -3563,9 +3503,9 @@ def qr_import_accept_frame(
|
||||
seen ``stable_reads`` times in a row -- this mirrors the export-side
|
||||
slideshow pacing and swallows transition artifacts;
|
||||
* re-reading the currently accepted frame is ``ignore``d;
|
||||
* a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or
|
||||
``"ur2:2-31-3804692811"``) contradicts the transfer already being built
|
||||
is a ``reset`` (a different transfer was presented).
|
||||
* a frame whose ``session_key`` (transfer identity, e.g. ``"bbqr:Z:B:3"``)
|
||||
contradicts the transfer already being built is a ``reset`` (a
|
||||
different transfer was presented).
|
||||
|
||||
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
|
||||
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
|
||||
@@ -3751,8 +3691,8 @@ class BalQrImportWidget(QWidget):
|
||||
self._key = key
|
||||
self._init_slots()
|
||||
if status == "ok":
|
||||
# Slot grid is always 1-based even for 0-based wire formats.
|
||||
slot = index + 1 if fmt == "bbqr" else index
|
||||
# Slot grid is always 1-based even for the 0-based BBQR indexes.
|
||||
slot = index + 1
|
||||
self.frames[slot] = frame_text
|
||||
self._update_slots()
|
||||
self._update_status()
|
||||
@@ -4008,7 +3948,7 @@ class BalQrImportWidget(QWidget):
|
||||
try:
|
||||
transfer, compressed = session.resolve()
|
||||
parts = decode_transfer(transfer, compressed)
|
||||
except (MissingFramesError, QrTransferError, AnimatedQrError) as e:
|
||||
except AnimatedQrError as e:
|
||||
self.show_error(str(e))
|
||||
self._reset_all()
|
||||
return
|
||||
|
||||
@@ -19,7 +19,6 @@ from electrum.plugin import hook
|
||||
from electrum.util import EventListener, event_listener
|
||||
from PyQt6.QtWidgets import QLayout
|
||||
|
||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||
from ...i18n import init_from_config
|
||||
from .common import (
|
||||
BalPlugin,
|
||||
@@ -538,22 +537,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
# users (BASIC and ADVANCED).
|
||||
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
|
||||
|
||||
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
|
||||
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
|
||||
# Ordered low -> high so the user picks the resolution matching their
|
||||
# camera. Visible to all users (BASIC and ADVANCED).
|
||||
qr_size_combo = QComboBox()
|
||||
# Labels marked for translation in common.QR_PRESET_LABELS.
|
||||
qr_size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS])
|
||||
qr_size_combo.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
|
||||
)
|
||||
|
||||
def on_qr_size_change(index):
|
||||
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
|
||||
|
||||
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
|
||||
|
||||
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||
# (not a free-text field) bound to the USER_TYPE config:
|
||||
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
|
||||
@@ -670,10 +653,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
btn.clicked.connect(reset)
|
||||
return btn
|
||||
|
||||
@@ -932,25 +911,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
)
|
||||
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
||||
|
||||
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
|
||||
# size used when exporting a will via QR codes; changeable per export
|
||||
# inside the export dialog itself.
|
||||
lbl_qr_size = QLabel(_("QR Code Size"))
|
||||
help_qr_size = HelpButton(_(
|
||||
"Payload size of a single QR code when exporting a will via QR.\n\n"
|
||||
"Larger QR codes hold more data (fewer shots) but are easier to "
|
||||
"scan with a high-resolution camera; smaller QR codes scan fine "
|
||||
"even with low-resolution cameras but require more shots.\n"
|
||||
"The same selector is available inside the export dialog."
|
||||
))
|
||||
grid.addWidget(lbl_qr_size, 16, 0)
|
||||
grid.addWidget(qr_size_combo, 16, 1)
|
||||
grid.addWidget(help_qr_size, 16, 2)
|
||||
reset_btn_qr_size = _make_reset_btn(
|
||||
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
|
||||
)
|
||||
grid.addWidget(reset_btn_qr_size, 16, 3)
|
||||
|
||||
# ----------------------------------------------------------------- #
|
||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||
# their factory defaults. It only resets the settings exposed by THIS #
|
||||
@@ -984,7 +944,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
(self.HISTORY_LABEL, edit_history_label, "line"),
|
||||
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
|
||||
]
|
||||
for cfg, widget, kind in resets:
|
||||
# Persist the default value back into the Electrum config.
|
||||
@@ -1005,10 +964,6 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
# Re-sync the history-label field's enabled state after a reset: the
|
||||
# reset restores SAVE_HISTORY to its default, so the field must
|
||||
# follow the (default) checkbox state again.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user