feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests

This commit is contained in:
2026-09-14 09:11:50 -04:00
parent 9c4697c923
commit fb88d7540c
82 changed files with 11665 additions and 3116 deletions

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

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

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

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View 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

View File

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

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

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

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

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

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

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

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

View File

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