diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..e76be97
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,10 @@
+.gradle/
+build/
+local.properties
+.idea/
+*.apk
+*.aab
+captures/
+.externalNativeBuild/
+.cxx/
+*.hprof
\ No newline at end of file
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..cf15a05
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,153 @@
+# BAL Reader (Android)
+
+A minimal Android app that reads a Bitcoin will exported by the
+[BAL Electrum plugin](https://bitcoin-after.life) directly from your screen,
+then lets you view, copy, share, or save the recovered data. **Reader only** —
+it never signs or broadcasts.
+
+It decodes **all four** transfer formats the plugin can export, auto-detecting
+the format from the first frame:
+
+- **BAL QR** (the plugin's default), single- and multi-frame, plain and
+ zlib-compressed, compact `BAL1` header (legacy `BALQR1` frames are still
+ accepted on import);
+- **BC-UR v1** (single-part and `NofM` multipart);
+- **BC-UR v2** (single-part and XOR-fountain multipart — it tolerates dropped,
+ repeated, and out-of-order frames);
+- **BBQR** (`Z`/`H`/`2` encodings).
+
+Both payload kinds are handled: the **whole-will JSON** and the plain
+**transaction-hex list**.
+
+## How decoding works
+
+The app does not reimplement the QR formats. It bundles the plugin's own
+codec modules — `bal/core/__init__.py`, `bal/core/animated_qr.py`,
+`bal/core/qrtransfer.py` — and runs them verbatim through **Chaquopy** (CPython
+on Android). Kotlin is only camera glue and UI:
+
+```
+Camera (CameraX) → ML Kit QR detection (on-device, no API key)
+ → BalDecoder.add(text) → bal.core.animated_qr.AnimatedQrSession
+ → when done → BalDecoder.finish()
+ = session.resolve() → qrtransfer.decode_transfer()
+ → balreader.payload.decode_will_payload()
+ → ResultActivity: view / copy / share / save
+```
+
+The decode tail mirrors the plugin's import dialog function-for-function, and
+`android/test_chain/verify_chain.py` proves the bundled code decodes every
+format the way the desktop import does (including scrambled, duplicated, and
+missing frames).
+
+## Repository layout
+
+```
+android/
+├── app/src/main/
+│ ├── AndroidManifest.xml
+│ ├── java/life/after/bitcoin/
+│ │ ├── BalDecoder.kt Chaquopy bridge over the bundled codecs
+│ │ ├── MainActivity.kt camera + ML Kit scan loop + progress
+│ │ └── ResultActivity.kt viewer (copy / share / save)
+│ └── python/ bundled Python (regenerate, do not hand-edit)
+│ ├── bal/core/ SYNCED COPY of the plugin codecs
+│ └── balreader/payload.py verbatim copy of dialogs.decode_will_payload
+├── scripts/
+│ ├── sync_codecs.py re-copy + verify the bundled codecs
+│ └── build_apk.py resync codecs, run Gradle, print APK + sha256
+└── test_chain/verify_chain.py decode-chain simulation for all formats
+```
+
+## Build
+
+You need Android Studio (Jellyfish or newer), JDK 17, an Android SDK with
+platform 35, and a network connection for the first Gradle sync.
+
+1. Open this `android/` folder in Android Studio and let it sync (it will
+ fetch the Gradle wrapper 8.14, AGP 8.10.0, Kotlin 2.0.21, Chaquopy 17.0.0,
+ CameraX 1.3.4, and ML Kit).
+2. Connect a phone (API 24+) or start an emulator and press **Run**.
+3. Grant the camera permission when asked.
+
+Alternatively, from the command line (from the repository root):
+
+```bash
+python3 android/scripts/build_apk.py # debug APK + sha256
+python3 android/scripts/build_apk.py --release # (unsigned) release APK
+```
+
+The script re-synchronises the bundled codec modules first (so the APK always
+carries the current `bal/core` sources), runs `./gradlew`, and prints the APK
+path, size and sha256. Flags: `--no-sync` (skip the re-sync), `--offline`
+(Gradle without downloads), `--clean`, `--verbose`.
+
+Equivalent raw Gradle call:
+
+```bash
+cd android
+./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk
+```
+
+### If the Gradle wrapper jar is missing
+
+`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of
+the box. If it is ever absent, Android Studio regenerates it on the first
+sync; no manual steps needed.
+
+## Use
+
+1. In Electrum + BAL, open the will's **export** dialog.
+2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**,
+ **BC-UR v2**, and **BBQR**.
+3. Make sure the wording toggle shows a payload (business logic), then display
+ the animated QR and keep it on screen.
+4. Point the phone at the screen. The header shows the detected format and
+ `received / total`; scanning stops automatically when the transfer is
+ complete.
+5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it
+ as `will.json` (whole will) or `will_tx.txt` (transaction list), or press
+ **Scan another**.
+
+Notes:
+
+- Keep the phone still and the whole QR inside the frame (the codec dedups
+ repeated frames, so a slow capture is fine).
+- If the camera glares off the screen, reduce brightness or tilt slightly.
+- If scanning jumps between exports, the app detects the format switch,
+ resets, and asks you to let it re-scan.
+
+## Keeping the bundled code in sync with the plugin
+
+The codecs under `app/src/main/python/bal/core/` are **committed copies** for
+deterministic builds, but they must stay identical to the plugin. Re-run this
+after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and
+after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which
+mirrors `balreader/payload.py`):
+
+```bash
+python3 android/scripts/sync_codecs.py # copy
+python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly)
+python3 android/test_chain/verify_chain.py # full decode-chain regression
+```
+
+`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from
+the plugin's `decode_will_payload` (AST identity + result parity).
+
+## Version pins (see `PLAN_ANDROID_READER.md`)
+
+| Item | Version |
+|---|---|
+| AGP | 8.10.0 |
+| Gradle | 8.14 (wrapper) |
+| Kotlin | 2.0.21 |
+| Chaquopy | 17.0.0 (Python 3.12) |
+| compile / target / min SDK | 35 / 35 / 24 |
+| CameraX | 1.3.4 |
+| ML Kit barcode-scanning | 17.3.0 |
+| JDK | 17 |
+
+## License
+
+MIT. The bundled Python codec files inherit the plugin's MIT license
+(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution.
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..dbd7079
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -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")
+}
\ No newline at end of file
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..e20d335
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,5 @@
+# Chaquopy Python runtime.
+-keep class com.chaquo.python.** { *; }
+
+# ML Kit barcode scanning.
+-keep class com.google.mlkit.** { *; }
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..cc2f69d
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt b/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
new file mode 100644
index 0000000..e75d627
--- /dev/null
+++ b/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
@@ -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 // [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())
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt b/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt
new file mode 100644
index 0000000..bdc0c2f
--- /dev/null
+++ b/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/life/after/bitcoin/MainActivity.kt b/android/app/src/main/java/life/after/bitcoin/MainActivity.kt
new file mode 100644
index 0000000..49cdb5d
--- /dev/null
+++ b/android/app/src/main/java/life/after/bitcoin/MainActivity.kt
@@ -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 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"
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt b/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
new file mode 100644
index 0000000..4b0bb85
--- /dev/null
+++ b/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
@@ -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 = 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"
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/python/bal/core/__init__.py b/android/app/src/main/python/bal/core/__init__.py
new file mode 100644
index 0000000..6e1c1b2
--- /dev/null
+++ b/android/app/src/main/python/bal/core/__init__.py
@@ -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.
+"""
diff --git a/android/app/src/main/python/bal/core/animated_qr.py b/android/app/src/main/python/bal/core/animated_qr.py
new file mode 100644
index 0000000..58cfd30
--- /dev/null
+++ b/android/app/src/main/python/bal/core/animated_qr.py
@@ -0,0 +1,1180 @@
+"""
+bal.core.animated_qr
+====================
+
+GUI-free implementation of the interoperable animated-QR transfer formats
+used to move BAL will data between devices.
+
+Supported wire formats (each self-describing and order-independent on
+receive):
+
+* **BALQR** (native): ``BAL1`` 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//
+ 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/
+ 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$<2 base36 total><2 base36 index>
+ 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
+self-contained bundle with no third-party dependencies (house rule).
+
+This module never imports Qt or any Electrum GUI code (house rule).
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import zlib
+from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
+
+# --------------------------------------------------------------------------- #
+# Errors & safety caps
+# --------------------------------------------------------------------------- #
+
+
+class AnimatedQrError(ValueError):
+ """Base error for all animated-QR codec failures."""
+
+
+class FormatNotDetectedError(AnimatedQrError):
+ """The scanned text does not look like any known animated-QR format."""
+
+
+class TransferConflictError(AnimatedQrError):
+ """An incoming frame belongs to a different transfer than the open one."""
+
+
+class SessionLimitError(AnimatedQrError):
+ """A receive session exceeded its safety caps."""
+
+
+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//``) and
+ the single-part form (``ur:bytes/``, no sequence header or digest).
+ """
+ frame_text = frame_text.strip().lower()
+ prefix = "ur:bytes/"
+ if not frame_text.startswith(prefix):
+ raise AnimatedQrError("not a BC-UR v1 part")
+ tail = frame_text[len(prefix) :]
+ parts = tail.split("/")
+ if len(parts) == 1:
+ return 1, 1, "", parts[0]
+ if len(parts) != 3:
+ raise AnimatedQrError("bad BC-UR v1 part structure")
+ seq_head, digest, fragment = parts
+ if "of" not in seq_head:
+ raise AnimatedQrError("BC-UR v1 part misses the sequence header")
+ try:
+ index_s, total_s = seq_head.split("of", 1)
+ index, total = int(index_s), int(total_s)
+ except ValueError:
+ raise AnimatedQrError("bad BC-UR v1 sequence header") from None
+ if total < 1 or not 1 <= index <= total:
+ raise AnimatedQrError("bad BC-UR v1 sequence numbers")
+ if len(digest) != 58:
+ raise AnimatedQrError("bad BC-UR v1 digest")
+ return index, total, digest, fragment
+
+
+# --------------------------------------------------------------------------- #
+# BBQR (Coinkite)
+# --------------------------------------------------------------------------- #
+
+_BBQR_PREFIX = "B$"
+
+
+def _bbqr_base36(n: int) -> str:
+ if not 0 <= n <= 1295:
+ raise AnimatedQrError("BBQR part count out of range")
+
+ def digit(x: int) -> str:
+ return chr(48 + x) if x < 10 else chr(65 + x - 10)
+
+ return digit(n // 36) + digit(n % 36)
+
+
+def _bbqr_base32(data: bytes) -> str:
+ return base64.b32encode(data).decode("ascii").rstrip("=")
+
+
+def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]:
+ """Return ``(encoding, encoded_text, split_mod)`` honouring the reference."""
+ if encoding == "H":
+ return "H", raw.hex().upper(), 2
+ if encoding == "Z":
+ compressor = zlib.compressobj(wbits=-10)
+ compressed = compressor.compress(raw) + compressor.flush()
+ if len(compressed) < len(raw):
+ return "Z", _bbqr_base32(compressed), 8
+ encoding = "2"
+ if encoding != "2":
+ raise AnimatedQrError("unknown BBQR encoding")
+ return "2", _bbqr_base32(raw), 8
+
+
+def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]:
+ """Encode ``payload`` into BBQR frames (``B$…``)."""
+ if len(type_code) != 1 or not type_code.isalnum():
+ raise AnimatedQrError("bad BBQR type code")
+ encoding, encoded, split_mod = _bbqr_encode(payload, encoding)
+ chunk = budget_chars - 8
+ if chunk < split_mod:
+ raise AnimatedQrError("QR budget too small for a BBQR frame")
+ chunk -= chunk % split_mod
+ if chunk < 1:
+ raise AnimatedQrError("QR budget too small for a BBQR frame")
+ if len(payload) > _MAX_MESSAGE_BYTES:
+ raise AnimatedQrError("BBQR payload exceeds the size cap")
+ total = -(-len(encoded) // chunk)
+ if total > 1295:
+ raise AnimatedQrError("BBQR transfer demands too many parts")
+ header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total)
+ frames: List[str] = []
+ pos = 0
+ for index in range(total):
+ frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk])
+ pos += chunk
+ return frames
+
+
+def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]:
+ """Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``."""
+ frame_text = frame_text.strip()
+ if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX):
+ raise AnimatedQrError("not a BBQR frame")
+ encoding = frame_text[2]
+ type_code = frame_text[3]
+ if encoding not in ("H", "2", "Z"):
+ raise AnimatedQrError("unknown BBQR encoding")
+ try:
+ total = int(frame_text[4:6], 36)
+ index = int(frame_text[6:8], 36)
+ except ValueError:
+ raise AnimatedQrError("bad BBQR part numbers") from None
+ if total < 1 or not 0 <= index < total:
+ raise AnimatedQrError("bad BBQR part numbers")
+ if index >= _MAX_SESSION_PARTS:
+ raise AnimatedQrError("BBQR part number out of range")
+ return encoding, type_code, total, index, frame_text[8:]
+
+
+def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
+ pieces: List[bytes] = []
+ for part in encoded_parts:
+ if encoding == "H":
+ try:
+ pieces.append(bytes.fromhex(part))
+ except ValueError:
+ raise AnimatedQrError("invalid BBQR hex payload") from None
+ continue
+ padding = (8 - (len(part) % 8)) % 8
+ try:
+ pieces.append(base64.b32decode(part + "=" * padding))
+ except (ValueError, TypeError):
+ raise AnimatedQrError("invalid BBQR base32 payload") from None
+ raw = b"".join(pieces)
+ if encoding == "Z":
+ try:
+ inflater = zlib.decompressobj(wbits=-10)
+ out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1)
+ except zlib.error:
+ raise AnimatedQrError("invalid BBQR zlib payload") from None
+ if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail:
+ raise AnimatedQrError("BBQR payload exceeds the size cap")
+ return out
+ return raw
+
+
+# --------------------------------------------------------------------------- #
+# Format detection & per-frame identity for the shared debounce
+# --------------------------------------------------------------------------- #
+
+FORMAT_LABELS = {
+ "balqr": "BAL QR",
+ "ur1": "BC-UR v1",
+ "ur2": "BC-UR v2",
+ "bbqr": "BBQR",
+}
+
+
+def format_name(fmt: str) -> str:
+ """Human-readable name of a wire format for UI labels."""
+ return FORMAT_LABELS.get(fmt, fmt)
+
+
+def detect_format(text: str) -> Optional[str]:
+ """Return the wire format of a scanned string, or ``None``."""
+ text = text.strip()
+ if not text:
+ return None
+ 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
+
+
+def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
+ """Parse a frame and return ``(format, session_key, frame_total, index)``.
+
+ ``session_key`` identifies the transfer the frame belongs to and drives
+ the shared reset/ignore/accept debounce. Raises
+ :class:`AnimatedQrError` when the text cannot be parsed.
+ """
+ fmt = detect_format(text)
+ if fmt == "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)
+# --------------------------------------------------------------------------- #
+
+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
+ self._encoding = ""
+ self._type_code = ""
+ self._parts: Dict[int, str] = {}
+
+ @property
+ def total(self) -> int:
+ return self._total
+
+ @property
+ def received(self) -> int:
+ return len(self._parts)
+
+ @property
+ def done(self) -> bool:
+ return bool(self._total) and len(self._parts) >= self._total
+
+ def add(self, text: str) -> str:
+ encoding, type_code, total, index, payload = bbqr_parse_part(text)
+ if self._total:
+ if (encoding, type_code, total) != (self._encoding, self._type_code, self._total):
+ raise TransferConflictError("BBQR frame header changed")
+ else:
+ self._total = total
+ self._encoding = encoding
+ self._type_code = type_code
+ if index in self._parts:
+ return "dup"
+ self._parts[index] = payload
+ return "ok"
+
+ def resolve(self) -> Tuple[str, bool]:
+ ordered = [self._parts[i] for i in range(self._total)]
+ raw = _bbqr_decode(ordered, self._encoding)
+ return _transfer_text(raw), False
+
+
+def _transfer_text(raw: bytes) -> str:
+ try:
+ return raw.decode("utf-8")
+ except UnicodeDecodeError:
+ raise AnimatedQrError("decoded transfer is not valid UTF-8") from None
+
+
+class AnimatedQrSession:
+ """Facade over the per-format receive sessions used by the QR import page."""
+
+ _DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession"))
+
+ def __init__(self):
+ self._inner: Optional[object] = None
+ self._fmt: Optional[str] = None
+
+ @property
+ def format(self) -> Optional[str]:
+ return self._fmt
+
+ def add_part(self, text: str) -> str:
+ """Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input."""
+ fmt = detect_format(text)
+ if fmt is None:
+ raise FormatNotDetectedError("Not a supported QR transfer format")
+ if self._inner is None:
+ self._fmt = fmt
+ self._inner = self._make(fmt)
+ elif fmt != self._fmt:
+ raise TransferConflictError(
+ "Switched QR format mid-import ({} -> {})".format(self.format, fmt)
+ )
+ return self._inner.add(text) # type: ignore[no-any-return]
+
+ @staticmethod
+ def _make(fmt: str) -> object:
+ if fmt == "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))
+
+ @property
+ def total(self) -> int:
+ return self._inner.total if self._inner is not None else 0
+
+ @property
+ def received(self) -> int:
+ return self._inner.received if self._inner is not None else 0
+
+ @property
+ def done(self) -> bool:
+ return bool(self._inner is not None and self._inner.done)
+
+ def resolve(self) -> Tuple[str, bool]:
+ if self._inner is None:
+ raise AnimatedQrError("no transfer has been received")
+ return self._inner.resolve() # type: ignore[no-any-return]
diff --git a/android/app/src/main/python/bal/core/qrtransfer.py b/android/app/src/main/python/bal/core/qrtransfer.py
new file mode 100644
index 0000000..6c7d4f2
--- /dev/null
+++ b/android/app/src/main/python/bal/core/qrtransfer.py
@@ -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`` frames for
+ multi-QR export, and reassembles/validates them on import.
+
+Wire format (v2, compact)
+-------------------------
+A frame is::
+
+ BAL1
+
+* ``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`` 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
diff --git a/android/app/src/main/python/balreader/__init__.py b/android/app/src/main/python/balreader/__init__.py
new file mode 100644
index 0000000..ec39957
--- /dev/null
+++ b/android/app/src/main/python/balreader/__init__.py
@@ -0,0 +1 @@
+"""Android reader helpers built on the bundled plugin codecs."""
\ No newline at end of file
diff --git a/android/app/src/main/python/balreader/bridge.py b/android/app/src/main/python/balreader/bridge.py
new file mode 100644
index 0000000..2b49974
--- /dev/null
+++ b/android/app/src/main/python/balreader/bridge.py
@@ -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": , "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": []})
\ No newline at end of file
diff --git a/android/app/src/main/python/balreader/payload.py b/android/app/src/main/python/balreader/payload.py
new file mode 100644
index 0000000..f454b1a
--- /dev/null
+++ b/android/app/src/main/python/balreader/payload.py
@@ -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)
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..a3043c2
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..f28fb91
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/activity_result.xml b/android/app/src/main/res/layout/activity_result.xml
new file mode 100644
index 0000000..0100515
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_result.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..c6ecca0
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #9BE8C0
+ #3A3A3A
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8966a8a
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,24 @@
+
+ BAL Reader
+
+ Point the camera at the QR screen
+ —
+ %1$d / %2$d
+
+ BAL QR
+ BC-UR v1
+ BC-UR v2
+ BBQR
+
+ Whole will (JSON)
+ Transaction list
+ Copy
+ Share
+ Save
+ Scan another
+ Transfer copied to clipboard
+ will.json
+ will_tx.txt
+ Camera permission is required to scan QR codes.
+ The QR switched to a different transfer. Let it rescan.
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f341d08
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..308324a
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file: plugin versions only. See the docs for the full
+// compatibility matrix (Chaquopy 17 requires AGP 7.3-9.2 and minSdk 24).
+plugins {
+ id("com.android.application") version "8.10.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("com.chaquo.python") version "17.0.0" apply false
+}
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..a86957e
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..598c78d
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..0f14772
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..8de1053
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/scripts/build_apk.py b/android/scripts/build_apk.py
new file mode 100755
index 0000000..211b7a8
--- /dev/null
+++ b/android/scripts/build_apk.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""Build the BAL Reader Android APK via Gradle.
+
+Re-synchronises the bundled codec modules into the app (so the APK always
+carries the current ``bal/core`` sources), then invokes the Gradle wrapper to
+produce the APK, and finally prints the artifact path, size and sha256.
+
+Run from the repository root (any Python 3.8+, needs JDK 17 and an Android
+SDK; the first build also needs a network connection for Gradle downloads):
+
+ python3 android/scripts/build_apk.py # debug APK
+ python3 android/scripts/build_apk.py --release # (unsigned) release APK
+ python3 android/scripts/build_apk.py --no-sync # skip codec re-sync
+ python3 android/scripts/build_apk.py --offline # gradle --offline
+
+The APK is written under ``android/app/build/outputs/apk/``.
+"""
+
+import argparse
+import hashlib
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+ANDROID_DIR = REPO_ROOT / "android"
+GRADLEW = ANDROID_DIR / "gradlew"
+LOCAL_PROPERTIES = ANDROID_DIR / "local.properties"
+WRAPPER_JAR = ANDROID_DIR / "gradle" / "wrapper" / "gradle-wrapper.jar"
+
+VARIANTS = {
+ "debug": "assembleDebug",
+ "release": "assembleRelease",
+}
+APK_REL = {
+ "debug": Path("app") / "build" / "outputs" / "apk" / "debug" / "app-debug.apk",
+ "release": Path("app") / "build" / "outputs" / "apk" / "release" / "app-release-unsigned.apk",
+}
+
+SYNC_SCRIPT = ANDROID_DIR / "scripts" / "sync_codecs.py"
+
+
+def sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def run(cmd, cwd, verbose: bool) -> int:
+ if verbose:
+ print("+", " ".join(str(c) for c in cmd))
+ result = subprocess.run(
+ cmd, cwd=str(cwd), capture_output=not verbose, text=True
+ )
+ if not verbose:
+ sys.stdout.write(result.stdout)
+ sys.stderr.write(result.stderr)
+ return result.returncode
+
+
+def check_prerequisites() -> None:
+ if not (GRADLEW.exists() and WRAPPER_JAR.exists()):
+ sys.exit(
+ "error: gradle wrapper is incomplete ({} missing).\n"
+ "hint: run `gradle wrapper` in android/ once, or re-clone.".format(
+ WRAPPER_JAR if not WRAPPER_JAR.exists() else GRADLEW
+ )
+ )
+
+ java_ok = None
+ try:
+ out = subprocess.run(
+ ["java", "-version"], capture_output=True, text=True, check=False
+ ).stderr
+ match = re.search(r'version "(?:1\.)?(\d+)', out)
+ java_ok = int(match.group(1)) if match else None
+ except FileNotFoundError:
+ java_ok = None
+ if java_ok is None:
+ sys.exit("error: no JDK found on PATH (need JDK 17 for AGP 8.10).")
+ if java_ok < 17:
+ sys.exit("error: JDK {} on PATH, but the Android build needs JDK 17.".format(java_ok))
+
+ sdk = None
+ if LOCAL_PROPERTIES.exists():
+ for line in LOCAL_PROPERTIES.read_text().splitlines():
+ if line.startswith("sdk.dir="):
+ sdk = line.split("=", 1)[1]
+ sdk = sdk or os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
+ if not sdk or not Path(sdk).exists():
+ sys.exit(
+ "error: Android SDK not found.\n"
+ "hint: set sdk.dir in android/local.properties or ANDROID_HOME."
+ )
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--release",
+ action="store_true",
+ help="build the (unsigned) release APK instead of the debug APK",
+ )
+ parser.add_argument(
+ "--no-sync",
+ action="store_true",
+ help="skip re-synchronising the bundled codec modules",
+ )
+ parser.add_argument(
+ "--offline",
+ action="store_true",
+ help="pass --offline to Gradle (no dependency downloads)",
+ )
+ parser.add_argument(
+ "--clean",
+ action="store_true",
+ help="run the Gradle clean task before building",
+ )
+ parser.add_argument(
+ "--verbose", action="store_true", help="stream Gradle output"
+ )
+ args = parser.parse_args(argv)
+
+ check_prerequisites()
+ variant = "release" if args.release else "debug"
+
+ if not args.no_sync:
+ sync = subprocess.run(
+ [sys.executable, str(SYNC_SCRIPT)], cwd=str(REPO_ROOT), check=False
+ )
+ if sync.returncode != 0:
+ print("error: codec re-sync failed; refusing to build a stale APK.")
+ return sync.returncode
+
+ tasks = []
+ if args.clean:
+ tasks.append("clean")
+ tasks.append(VARIANTS[variant])
+ cmd = [str(GRADLEW)]
+ if args.offline:
+ cmd.append("--offline")
+ cmd.extend(tasks)
+
+ rc = run(cmd, cwd=ANDROID_DIR, verbose=args.verbose)
+ if rc != 0:
+ print("error: Gradle {} failed (exit {}).".format(" ".join(tasks), rc))
+ return rc
+
+ apk = ANDROID_DIR / APK_REL[variant]
+ if not apk.exists():
+ print("error: expected APK not found at {}".format(apk))
+ return 1
+ data = apk.read_bytes()
+ print("APK : {}".format(apk.relative_to(REPO_ROOT)))
+ print("size : {} bytes".format(len(data)))
+ print("sha256: {}".format(sha256(data)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/android/scripts/sync_codecs.py b/android/scripts/sync_codecs.py
new file mode 100644
index 0000000..ed7b066
--- /dev/null
+++ b/android/scripts/sync_codecs.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Synchronise the BAL QR codec modules into the Android app.
+
+Copies ``bal/core/{__init__,animated_qr,qrtransfer}.py`` from the plugin repo
+into ``android/app/src/main/python/bal/core/`` so Chaquopy ships exactly the
+same code the desktop plugin runs. Afterwards the copies are imported
+standalone and used for one quick encode/decode round trip.
+
+Run from the repository root (any Python 3.8+, no dependencies):
+
+ python3 android/scripts/sync_codecs.py
+ python3 android/scripts/sync_codecs.py --check # no writes
+
+Re-run whenever ``bal/core/animated_qr.py`` or ``bal/core/qrtransfer.py``
+changes; the bundled copies are committed for deterministic builds.
+"""
+
+import argparse
+import hashlib
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+CORE_SRC = REPO_ROOT / "bal" / "core"
+PYTHON_DEST = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
+BAL_CORE_DEST = PYTHON_DEST / "bal" / "core"
+
+FILES = ("__init__.py", "animated_qr.py", "qrtransfer.py")
+
+ROUNDTRIP = (
+ "import sys; "
+ "sys.path.insert(0, {dest!r}); "
+ "from bal.core.animated_qr import AnimatedQrSession, ur2_frames; "
+ "import bal.core.qrtransfer as qtf; "
+ "frames = ur2_frames(b'roundtrip-check', 400); "
+ "assert frames, 'no frames produced'; "
+ "s = AnimatedQrSession(); "
+ "assert all(s.add_part(f) == 'ok' for f in frames); "
+ "transfer, compressed = s.resolve(); "
+ "assert not compressed and transfer == 'roundtrip-check', 'roundtrip failed'; "
+ "print('standalone import + roundtrip OK'); "
+)
+
+
+def sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def check_up_to_date() -> int:
+ outdated = []
+ for name in FILES:
+ src = (CORE_SRC / name).read_bytes()
+ dst = BAL_CORE_DEST / name
+ if not dst.exists() or dst.read_bytes() != src:
+ outdated.append(name)
+ if outdated:
+ print("OUT OF DATE: {}".format(", ".join(outdated)))
+ print("run: python3 android/scripts/sync_codecs.py")
+ return 1
+ print("codec bundles are up to date")
+ return 0
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="verify the bundled copies are up to date without writing",
+ )
+ args = parser.parse_args(argv)
+
+ if args.check:
+ return check_up_to_date()
+
+ BAL_CORE_DEST.mkdir(parents=True, exist_ok=True)
+ for name in FILES:
+ src = (CORE_SRC / name).read_bytes()
+ dst = BAL_CORE_DEST / name
+ dst.write_bytes(src)
+ print("synced {:16s} sha256={}".format(name, sha256(src)[:16]))
+
+ run = subprocess.run(
+ [sys.executable, "-c", ROUNDTRIP.format(dest=str(PYTHON_DEST))],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if run.returncode != 0:
+ print(run.stdout, end="")
+ print(run.stderr, end="")
+ return 1
+ print(run.stdout.strip())
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..00040c7
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "BALReader"
+include(":app")
\ No newline at end of file
diff --git a/android/test_chain/verify_chain.py b/android/test_chain/verify_chain.py
new file mode 100644
index 0000000..9aadd0b
--- /dev/null
+++ b/android/test_chain/verify_chain.py
@@ -0,0 +1,314 @@
+#!/usr/bin/env python3
+"""Verify the Android app can decode every frame format the plugin exports.
+
+Simulates the exact runtime path of the APK on the development machine:
+
+* imports ``bal.core`` and ``balreader.payload`` from the *bundled* copies in
+ ``android/app/src/main/python`` (the code Chaquopy actually ships);
+* generates frames exactly as the plugin's export page does
+ (``split_frames`` for BAL QR, ``encode_animated_frames`` semantics for
+ UR v1 / UR v2 / BBQR);
+* drives an :class:`~bal.core.animated_qr.AnimatedQrSession` the way
+ ``BalDecoder.add`` does (scrambled input, duplicates, dropped frames);
+* runs the app's ``finish()`` chain (``resolve()`` -> ``decode_transfer()``
+ -> ``decode_will_payload()``) and checks the result;
+* cross-checks the app's ``decode_will_payload`` copy result-for-result (and
+ AST-for-AST) against the plugin's original in ``bal/gui/qt/dialogs.py``.
+
+Run from the repository root (any Python 3.8+, no dependencies):
+
+ python3 android/test_chain/verify_chain.py
+"""
+
+import ast
+import json
+import random
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+APP_PYTHON = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
+DIALOGS = REPO_ROOT / "bal" / "gui" / "qt" / "dialogs.py"
+
+sys.path.insert(0, str(APP_PYTHON))
+
+from bal.core import animated_qr as aq # noqa: E402
+from bal.core import qrtransfer as qtf # noqa: E402
+
+from balreader import bridge as bridge_codec # noqa: E402
+from balreader import payload as payload_codec # noqa: E402
+
+PASSED = 0
+
+
+def ok(condition, label):
+ global PASSED
+ if not condition:
+ raise AssertionError("FAILED: " + label)
+ PASSED += 1
+
+
+def check_imported_bundle():
+ for module in (aq, qtf, payload_codec):
+ assert module.__file__ is not None
+ path = str(Path(module.__file__).resolve())
+ assert path.startswith(str(APP_PYTHON)), path
+ ok(True, "all modules imported from the bundled android/ copies")
+
+
+def find_function(tree, name):
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef) and node.name == name:
+ return node
+ raise RuntimeError("{} not found".format(name))
+
+
+# --------------------------------------------------------------------------- #
+# Fixtures
+# --------------------------------------------------------------------------- #
+
+TXS = ["{:064x}".format(i) for i in range(1, 8)]
+WILL_ITEMS = {
+ "imp{}".format(i): {
+ "tx": "{:064x}".format(i + 1),
+ "addr": "bc1qdeadbeef{:x}".format(i),
+ "amount": 100000 + i,
+ "tag": "heiress-{}".format(i),
+ "metadata": {},
+ "notify": "mail-{}@example.invalid".format(i),
+ }
+ for i in range(3)
+}
+WILL_JSON_COMPACT = json.dumps(WILL_ITEMS, separators=(",", ":"))
+WILL_JSON_PRETTY = json.dumps(WILL_ITEMS, indent=2)
+
+
+# --------------------------------------------------------------------------- #
+# Parity: app's decode_will_payload vs the plugin's dialogs.py original
+# --------------------------------------------------------------------------- #
+
+def build_extracted_and_app_function():
+ dialogs_source = DIALOGS.read_text()
+ app_source = Path(payload_codec.__file__).read_text()
+ dialogs_node = find_function(ast.parse(dialogs_source), "decode_will_payload")
+ app_node = find_function(ast.parse(app_source), "decode_will_payload")
+ ok(
+ ast.dump(app_node) == ast.dump(dialogs_node),
+ "decode_will_payload AST identical between app copy and plugin",
+ )
+ namespace = {}
+ exec("import json\nimport re\nfrom typing import Any", namespace)
+ exec(compile(ast.Module(body=[dialogs_node], type_ignores=[]), "dialogs.py", "exec"), namespace)
+ return namespace["decode_will_payload"]
+
+
+def run_payload_parity_cases():
+ plugin_decode = build_extracted_and_app_function()
+ samples = {
+ "will-compact": WILL_JSON_COMPACT,
+ "will-pretty": WILL_JSON_PRETTY,
+ "txs-newlines": "\n".join(TXS),
+ "txs-comma-crlf": ",\r\n".join(TXS[:3]),
+ "single-tx": TXS[0],
+ "json-array": json.dumps(TXS),
+ "not-json-dict": "hello world",
+ "empty": "",
+ "whitespace": " \n\t ",
+ }
+ for label, text in samples.items():
+ app_result = payload_codec.decode_will_payload(text)
+ plugin_result = plugin_decode(text)
+ ok(
+ app_result == plugin_result,
+ "payload parity for {!r}".format(label),
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Full decode chain (the app's BalDecoder.finish())
+# --------------------------------------------------------------------------- #
+
+def app_chain(transfer_text, compressed):
+ parts = qtf.decode_transfer(transfer_text, compressed)
+ payload = "\n".join(parts)
+ kind, data = payload_codec.decode_will_payload(payload)
+ return parts, payload, kind, data
+
+
+def feed_frame_set(session, frames, *, drop=None, order=None):
+ indexes = list(range(len(frames)))
+ if drop:
+ indexes = [i for i in indexes if i not in drop]
+ if order is not None:
+ indexes = list(order)
+ for i in indexes:
+ session.add_part(frames[i])
+
+
+def make_frames(transfer_text, fmt, budget_chars, *, compressed=False):
+ payload = transfer_text.encode("utf-8")
+ if fmt == "balqr":
+ return qtf.split_frames(transfer_text, budget_chars, compressed=compressed)
+ if fmt == "ur1":
+ return aq.ur1_frames(payload, budget_chars)
+ if fmt == "ur2":
+ return aq.ur2_frames(payload, budget_chars)
+ if fmt == "bbqr":
+ return aq.bbqr_frames(payload, budget_chars, encoding="Z")
+ raise AssertionError("unknown format " + fmt)
+
+
+def run_transport_case(transport, budget_chars, transfer_text, compressed=False):
+ frames = make_frames(transfer_text, transport, budget_chars, compressed=compressed)
+ session = aq.AnimatedQrSession()
+ rng = random.Random(len(transfer_text) + len(transport.encode()))
+ order = [i for i in range(len(frames))]
+ rng.shuffle(order)
+ feed_frame_set(session, frames, order=order)
+ ok(session.done, "{} (.{} chars) reaches done in scrambled order".format(transport, budget_chars))
+ if transport == "ur2":
+ # Fountain indexes can range wider than seq_len, so received may
+ # exceed (or fall short of) total; only progress and completion matter.
+ ok(session.total >= 1 and session.received >= 1,
+ "{} reports positive progress".format(transport))
+ else:
+ ok(
+ session.received == session.total,
+ "{} received matches total".format(transport),
+ )
+ ok(
+ session.received == len(frames) and session.total == len(frames),
+ "{} received/total equals frame count".format(transport),
+ )
+ transfer, compressed_flag = session.resolve()
+ ok(transfer == transfer_text, "{} restores exact transfer text".format(transport))
+ parts, payload, kind, data = app_chain(transfer, compressed_flag)
+ bridge_json = json.loads(bridge_codec.finish(session))
+ ok(
+ bridge_json == {"kind": kind, "payload": payload, "parts": parts},
+ "{} bridge.finish JSON matches the app chain".format(transport),
+ )
+ return parts, payload, kind, data
+
+
+def test_tx_transports():
+ transfer = qtf.encode_transfer(TXS, compress=False)
+ for transport in ("balqr", "ur1", "ur2", "bbqr"):
+ parts, payload, kind, data = run_transport_case(transport, 400, transfer)
+ ok(kind == "txs", "{} classifies as txs".format(transport))
+ ok(parts == TXS and payload == "\n".join(TXS), "{} yields the tx list".format(transport))
+
+
+def test_compressed_bal_transport():
+ transfer = qtf.encode_transfer(TXS, compress=True)
+ frames = make_frames(transfer, "balqr", 400, compressed=True)
+ session = aq.AnimatedQrSession()
+ feed_frame_set(session, frames)
+ ok(session.done, "compressed BAL QR done")
+ parts, payload, kind, data = app_chain(*session.resolve())
+ ok(kind == "txs" and parts == TXS, "compressed BAL QR yields the tx list")
+
+
+def test_will_transports():
+ for transport in ("balqr", "ur1", "ur2", "bbqr"):
+ parts, payload, kind, data = run_transport_case(transport, 400, WILL_JSON_COMPACT)
+ ok(kind == "will", "{} classifies as will".format(transport))
+ ok(data == WILL_ITEMS, "{} restores the whole-will dict".format(transport))
+ # Pretty JSON works too (blank lines are whitespace for json.loads).
+ parts, payload, kind, data = run_transport_case("ur2", 400, WILL_JSON_PRETTY)
+ ok(kind == "will" and data == WILL_ITEMS, "pretty JSON transport restores the will dict")
+
+
+def test_duplicates_are_ignored():
+ # UR v1 (multi-fragment) dedups by fragment index; UR v2 fountains never
+ # report "dup" (they dedup internally), matching the plugin's behaviour.
+ frames = make_frames(WILL_JSON_COMPACT, "ur1", 200)
+ ok(len(frames) >= 2, "UR v1 yields multiple fragments (got {})".format(len(frames)))
+ session = aq.AnimatedQrSession()
+ for i in range(len(frames)):
+ first = session.add_part(frames[i])
+ dup = session.add_part(frames[i])
+ ok(first == "ok", "fresh UR v1 frame reported as ok")
+ ok(dup == "dup", "duplicate UR v1 frame reported as dup")
+ ok(session.received == len(frames), "duplicates do not inflate received")
+ ok(session.done, "UR v1 completes after duplicates")
+
+
+def test_ur2_fountain_survives_loss_and_reorder():
+ transfer = qtf.encode_transfer(TXS, compress=False)
+ frames = make_frames(transfer, "ur2", 120)
+ ok(len(frames) >= 6, "fountain yields multiple frames (got {})".format(len(frames)))
+ drop = (0, 2, len(frames) - 1)
+ session = aq.AnimatedQrSession()
+ rng = random.Random(99)
+ order = [i for i in range(len(frames)) if i not in drop]
+ rng.shuffle(order)
+ feed_frame_set(session, frames, order=order, drop=drop)
+ ok(session.done, "fountain completes after dropped + reordered frames")
+ transfer, compressed_flag = session.resolve()
+ ok(transfer == transfer, "fountain restores exact transfer text")
+ parts, payload, kind, data = app_chain(transfer, compressed_flag)
+ ok(kind == "txs" and parts == TXS, "fountain output feeds the full import tail")
+
+
+def test_ur2_single_part_and_duplicate():
+ transfer = qtf.encode_transfer(TXS, compress=False)
+ frames = make_frames(transfer, "ur2", 2000)
+ ok(len(frames) == 1, "large budget yields a single-part UR v2 frame")
+ session = aq.AnimatedQrSession()
+ session.add_part(frames[0])
+ ok(session.done and session.received == 1, "single-part UR v2 completes")
+
+
+def test_bbqr_all_three_encodings():
+ for encoding in ("Z", "H", "2"):
+ frames = aq.bbqr_frames(WILL_JSON_COMPACT.encode(), 120, encoding=encoding)
+ session = aq.AnimatedQrSession()
+ feed_frame_set(session, frames)
+ ok(session.done, "BBQR {} done".format(encoding))
+ transfer, compressed = session.resolve()
+ parts, payload, kind, data = app_chain(transfer, compressed)
+ ok(kind == "will" and data == WILL_ITEMS, "BBQR {} restores the will".format(encoding))
+
+
+def test_mid_transfer_conflict():
+ ur1 = aq.ur1_frames(b"transfer-one", 400)
+ ur2 = aq.ur2_frames(b"transfer-two", 400)
+ session = aq.AnimatedQrSession()
+ session.add_part(ur1[0])
+ try:
+ session.add_part(ur2[0])
+ except aq.TransferConflictError:
+ ok(True, "format switch raises TransferConflictError")
+ else:
+ ok(False, "format switch raised TransferConflictError")
+
+
+def test_garbage_and_single_line():
+ session = aq.AnimatedQrSession()
+ try:
+ session.add_part("this is not a QR transfer")
+ except aq.FormatNotDetectedError:
+ ok(True, "garbage raises FormatNotDetectedError")
+ else:
+ ok(False, "garbage raised FormatNotDetectedError")
+
+
+def main():
+ check_imported_bundle()
+ run_payload_parity_cases()
+ test_tx_transports()
+ test_compressed_bal_transport()
+ test_will_transports()
+ test_duplicates_are_ignored()
+ test_ur2_fountain_survives_loss_and_reorder()
+ test_ur2_single_part_and_duplicate()
+ test_bbqr_all_three_encodings()
+ test_mid_transfer_conflict()
+ test_garbage_and_single_line()
+ print("verify_chain: {} checks passed".format(PASSED))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/android/tools/emitter.py b/android/tools/emitter.py
new file mode 100644
index 0000000..6cff8ad
--- /dev/null
+++ b/android/tools/emitter.py
@@ -0,0 +1,202 @@
+"""Standalone QR emitter for testing the Android reader on a real camera.
+
+Replicates exactly what the plugin's QR export page puts on screen
+(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
+encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
+
+Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
+PyQt6):
+
+ source /home/steal/devel/bal/electrum/env/bin/activate
+ QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
+
+Controls:
+ Left/Right previous / next frame
+ Space toggle autoplay
+ L toggle loop (default off)
+ Q / Esc quit
+"""
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+import qrcode # noqa: E402
+from PyQt6.QtCore import Qt, QTimer # noqa: E402
+from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
+from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
+
+from bal.core import animated_qr as aq # noqa: E402
+from bal.core import qrtransfer as qtf # noqa: E402
+
+
+def build_frames(tx_strings, fmt, budget):
+ """Frames exactly as BalQrExportWidget._refresh_frames produces them."""
+ transfer = qtf.encode_transfer(tx_strings, compress=False)
+ if fmt == "balqr":
+ return qtf.split_frames(transfer, budget, compressed=False)
+ payload = transfer.encode("utf-8")
+ if fmt == "ur1":
+ return aq.ur1_frames(payload, budget)
+ if fmt == "ur2":
+ return aq.ur2_frames(payload, budget)
+ if fmt == "bbqr":
+ return aq.bbqr_frames(payload, budget, encoding="Z")
+ raise SystemExit("unknown format: {}".format(fmt))
+
+
+def load_payload(args):
+ """Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
+ if args.will is not None:
+ will = json.loads(Path(args.will).read_text())
+ return (
+ [json.dumps(will, ensure_ascii=False)],
+ "whole-will JSON ({})".format(Path(args.will).name),
+ )
+ if args.txs is not None:
+ raw = Path(args.txs).read_text()
+ return [line.strip() for line in raw.split() if line.strip()], "txs"
+ raise SystemExit("give --will FILE or --txs FILE")
+
+
+def qr_pixmap(text, size):
+ """Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
+ qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
+ qr.add_data(text)
+ qr.make(fit=True)
+ matrix = qr.modules
+ n = len(matrix)
+ border = qr.border
+ scale = max(1, size // (n + 2 * border))
+ dim = (n + 2 * border) * scale
+ image = QImage(dim, dim, QImage.Format.Format_RGB32)
+ image.fill(Qt.GlobalColor.white)
+ painter = QPainter(image)
+ painter.fillRect(0, 0, dim, dim, QColor("white"))
+ painter.setBrush(QColor("black"))
+ painter.setPen(Qt.PenStyle.NoPen)
+ for y, row in enumerate(matrix):
+ for x, on in enumerate(row):
+ if on:
+ painter.fillRect(
+ (x + border) * scale, (y + border) * scale, scale, scale,
+ QColor("black"),
+ )
+ painter.end()
+ return QPixmap.fromImage(image).scaled(
+ size, size, Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+
+
+class EmitterWindow(QMainWindow):
+ def __init__(self, frames, description, fmt, fps, loop):
+ super().__init__()
+ self.frames = frames
+ self.fps = fps
+ self.loop = loop
+ self.index = 0
+ self.autoplay = True
+
+ central = QWidget(self)
+ self.setCentralWidget(central)
+ self.pix = QLabel(central)
+ self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.caption = QLabel(central)
+ self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
+
+ v = qt.QVBoxLayout(central)
+ v.addWidget(self.pix, 1)
+ v.addWidget(self.caption)
+
+ self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
+ self.resize(900, 1000)
+
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self._step)
+ self.timer.start(int(1000 / self.fps))
+
+ self._render()
+ self.caption.setText(
+ "{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
+ desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
+ auto="on" if self.autoplay else "off", loop="on" if loop else "off",
+ )
+ )
+ self.show()
+
+ def _render(self):
+ self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
+
+ def _update_caption(self):
+ auto = "on" if self.autoplay else "off"
+ loop = "on" if self.loop else "off"
+ self.caption.setText(
+ "frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
+ i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
+ )
+
+ def _step(self):
+ if self.index + 1 < len(self.frames):
+ self.index += 1
+ elif self.loop:
+ self.index = 0
+ else:
+ self.autoplay = False
+ self.timer.stop()
+ self._render()
+ self._update_caption()
+
+ def keyPressEvent(self, event): # noqa: N802 - Qt override name
+ key = event.key()
+ if key == Qt.Key.Key_Right:
+ self.autoplay = False
+ self.index = min(self.index + 1, len(self.frames) - 1)
+ self._render()
+ elif key == Qt.Key.Key_Left:
+ self.autoplay = False
+ self.index = max(self.index - 1, 0)
+ self._render()
+ elif key == Qt.Key.Key_Space:
+ self.autoplay = not self.autoplay
+ if self.autoplay:
+ self.timer.start(int(1000 / self.fps))
+ else:
+ self.timer.stop()
+ elif key == Qt.Key.Key_L:
+ self.loop = not self.loop
+ elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
+ self.close()
+ self._update_caption()
+
+
+def main(argv):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
+ default="balqr")
+ parser.add_argument("--budget", type=int, default=400)
+ parser.add_argument("--fps", type=float, default=1.0)
+ parser.add_argument("--loop", action="store_true")
+ parser.add_argument("--will", help="whole-will JSON file")
+ parser.add_argument("--txs", help="file with serialized tx strings")
+ args = parser.parse_args(argv)
+
+ tx_strings, description = load_payload(args)
+ frames = build_frames(tx_strings, args.format, args.budget)
+ if len(frames) == 1:
+ print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
+ else:
+ print("{} frames ready".format(len(frames)), file=sys.stderr)
+
+ app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
+ EmitterWindow(frames, description, args.format, args.fps, args.loop)
+ app.exec()
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/android/tools/sample_will.json b/android/tools/sample_will.json
new file mode 100644
index 0000000..94c0693
--- /dev/null
+++ b/android/tools/sample_will.json
@@ -0,0 +1,26 @@
+{
+ "imp-heiress-1": {
+ "tx": "0f1e2d3c4b5a69788796a5b4c3d2e1f0112233445566778899aabbccddeeff00",
+ "addr": "bc1qdeadbeef0",
+ "amount": 100000,
+ "tag": "heiress-1",
+ "metadata": {},
+ "notify": "heir1@example.invalid"
+ },
+ "imp-heiress-2": {
+ "tx": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
+ "addr": "bc1qdeadbeef1",
+ "amount": 200000,
+ "tag": "heiress-2",
+ "metadata": {},
+ "notify": "heir2@example.invalid"
+ },
+ "imp-heiress-3": {
+ "tx": "a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809",
+ "addr": "bc1qdeadbeef2",
+ "amount": 300000,
+ "tag": "heiress-3",
+ "metadata": {},
+ "notify": "heir3@example.invalid"
+ }
+}
\ No newline at end of file