diff --git a/android/.gitignore b/android/.gitignore
deleted file mode 100644
index e76be97..0000000
--- a/android/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-.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
deleted file mode 100644
index cf15a05..0000000
--- a/android/README.md
+++ /dev/null
@@ -1,153 +0,0 @@
-# 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
deleted file mode 100644
index dbd7079..0000000
--- a/android/app/build.gradle.kts
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index e20d335..0000000
--- a/android/app/proguard-rules.pro
+++ /dev/null
@@ -1,5 +0,0 @@
-# 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
deleted file mode 100644
index cc2f69d..0000000
--- a/android/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
deleted file mode 100644
index e75d627..0000000
--- a/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-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
deleted file mode 100644
index bdc0c2f..0000000
--- a/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-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
deleted file mode 100644
index 49cdb5d..0000000
--- a/android/app/src/main/java/life/after/bitcoin/MainActivity.kt
+++ /dev/null
@@ -1,209 +0,0 @@
-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
deleted file mode 100644
index 4b0bb85..0000000
--- a/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-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
deleted file mode 100644
index 6e1c1b2..0000000
--- a/android/app/src/main/python/bal/core/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""
-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
deleted file mode 100644
index 58cfd30..0000000
--- a/android/app/src/main/python/bal/core/animated_qr.py
+++ /dev/null
@@ -1,1180 +0,0 @@
-"""
-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
deleted file mode 100644
index 6c7d4f2..0000000
--- a/android/app/src/main/python/bal/core/qrtransfer.py
+++ /dev/null
@@ -1,304 +0,0 @@
-"""
-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
deleted file mode 100644
index ec39957..0000000
--- a/android/app/src/main/python/balreader/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""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
deleted file mode 100644
index 2b49974..0000000
--- a/android/app/src/main/python/balreader/bridge.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""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
deleted file mode 100644
index f454b1a..0000000
--- a/android/app/src/main/python/balreader/payload.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""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
deleted file mode 100644
index a3043c2..0000000
--- a/android/app/src/main/res/drawable/ic_launcher.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
deleted file mode 100644
index f28fb91..0000000
--- a/android/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
deleted file mode 100644
index 0100515..0000000
--- a/android/app/src/main/res/layout/activity_result.xml
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
deleted file mode 100644
index c6ecca0..0000000
--- a/android/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- #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
deleted file mode 100644
index 8966a8a..0000000
--- a/android/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
- 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
deleted file mode 100644
index f341d08..0000000
--- a/android/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
deleted file mode 100644
index 308324a..0000000
--- a/android/build.gradle.kts
+++ /dev/null
@@ -1,7 +0,0 @@
-// 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
deleted file mode 100644
index a86957e..0000000
--- a/android/gradle.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index 1b33c55..0000000
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index 598c78d..0000000
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100755
index 0f14772..0000000
--- a/android/gradlew
+++ /dev/null
@@ -1,251 +0,0 @@
-#!/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
deleted file mode 100644
index 8de1053..0000000
--- a/android/gradlew.bat
+++ /dev/null
@@ -1,94 +0,0 @@
-@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
deleted file mode 100755
index 211b7a8..0000000
--- a/android/scripts/build_apk.py
+++ /dev/null
@@ -1,161 +0,0 @@
-#!/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
deleted file mode 100644
index ed7b066..0000000
--- a/android/scripts/sync_codecs.py
+++ /dev/null
@@ -1,99 +0,0 @@
-#!/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
deleted file mode 100644
index 00040c7..0000000
--- a/android/settings.gradle.kts
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index 9aadd0b..0000000
--- a/android/test_chain/verify_chain.py
+++ /dev/null
@@ -1,314 +0,0 @@
-#!/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
deleted file mode 100644
index 8532907..0000000
--- a/android/tools/emitter.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""Standalone QR emitter for testing the Android reader on a real camera.
-
-Replicates exactly what the plugin's QR export page puts on screen
-(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
-encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
-
-Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
-PyQt6):
-
- source "$BAL_HOME/electrum/env/bin/activate"
- QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
-
-Controls:
- Left/Right previous / next frame
- Space toggle autoplay
- L toggle loop (default off)
- Q / Esc quit
-"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
-
-import qrcode # noqa: E402
-from PyQt6.QtCore import Qt, QTimer # noqa: E402
-from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
-from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
-
-from bal.core import animated_qr as aq # noqa: E402
-from bal.core import qrtransfer as qtf # noqa: E402
-
-
-def build_frames(tx_strings, fmt, budget):
- """Frames exactly as BalQrExportWidget._refresh_frames produces them."""
- transfer = qtf.encode_transfer(tx_strings, compress=False)
- if fmt == "balqr":
- return qtf.split_frames(transfer, budget, compressed=False)
- payload = transfer.encode("utf-8")
- if fmt == "ur1":
- return aq.ur1_frames(payload, budget)
- if fmt == "ur2":
- return aq.ur2_frames(payload, budget)
- if fmt == "bbqr":
- return aq.bbqr_frames(payload, budget, encoding="Z")
- raise SystemExit("unknown format: {}".format(fmt))
-
-
-def load_payload(args):
- """Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
- if args.will is not None:
- will = json.loads(Path(args.will).read_text())
- return (
- [json.dumps(will, ensure_ascii=False)],
- "whole-will JSON ({})".format(Path(args.will).name),
- )
- if args.txs is not None:
- raw = Path(args.txs).read_text()
- return [line.strip() for line in raw.split() if line.strip()], "txs"
- raise SystemExit("give --will FILE or --txs FILE")
-
-
-def qr_pixmap(text, size):
- """Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
- qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
- qr.add_data(text)
- qr.make(fit=True)
- matrix = qr.modules
- n = len(matrix)
- border = qr.border
- scale = max(1, size // (n + 2 * border))
- dim = (n + 2 * border) * scale
- image = QImage(dim, dim, QImage.Format.Format_RGB32)
- image.fill(Qt.GlobalColor.white)
- painter = QPainter(image)
- painter.fillRect(0, 0, dim, dim, QColor("white"))
- painter.setBrush(QColor("black"))
- painter.setPen(Qt.PenStyle.NoPen)
- for y, row in enumerate(matrix):
- for x, on in enumerate(row):
- if on:
- painter.fillRect(
- (x + border) * scale, (y + border) * scale, scale, scale,
- QColor("black"),
- )
- painter.end()
- return QPixmap.fromImage(image).scaled(
- size, size, Qt.AspectRatioMode.KeepAspectRatio,
- Qt.TransformationMode.SmoothTransformation,
- )
-
-
-class EmitterWindow(QMainWindow):
- def __init__(self, frames, description, fmt, fps, loop):
- super().__init__()
- self.frames = frames
- self.fps = fps
- self.loop = loop
- self.index = 0
- self.autoplay = True
-
- central = QWidget(self)
- self.setCentralWidget(central)
- self.pix = QLabel(central)
- self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.caption = QLabel(central)
- self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
-
- import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
-
- v = qt.QVBoxLayout(central)
- v.addWidget(self.pix, 1)
- v.addWidget(self.caption)
-
- self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
- self.resize(900, 1000)
-
- self.timer = QTimer(self)
- self.timer.timeout.connect(self._step)
- self.timer.start(int(1000 / self.fps))
-
- self._render()
- self.caption.setText(
- "{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
- desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
- auto="on" if self.autoplay else "off", loop="on" if loop else "off",
- )
- )
- self.show()
-
- def _render(self):
- self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
-
- def _update_caption(self):
- auto = "on" if self.autoplay else "off"
- loop = "on" if self.loop else "off"
- self.caption.setText(
- "frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
- i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
- )
-
- def _step(self):
- if self.index + 1 < len(self.frames):
- self.index += 1
- elif self.loop:
- self.index = 0
- else:
- self.autoplay = False
- self.timer.stop()
- self._render()
- self._update_caption()
-
- def keyPressEvent(self, event): # noqa: N802 - Qt override name
- key = event.key()
- if key == Qt.Key.Key_Right:
- self.autoplay = False
- self.index = min(self.index + 1, len(self.frames) - 1)
- self._render()
- elif key == Qt.Key.Key_Left:
- self.autoplay = False
- self.index = max(self.index - 1, 0)
- self._render()
- elif key == Qt.Key.Key_Space:
- self.autoplay = not self.autoplay
- if self.autoplay:
- self.timer.start(int(1000 / self.fps))
- else:
- self.timer.stop()
- elif key == Qt.Key.Key_L:
- self.loop = not self.loop
- elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
- self.close()
- self._update_caption()
-
-
-def main(argv):
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
- default="balqr")
- parser.add_argument("--budget", type=int, default=400)
- parser.add_argument("--fps", type=float, default=1.0)
- parser.add_argument("--loop", action="store_true")
- parser.add_argument("--will", help="whole-will JSON file")
- parser.add_argument("--txs", help="file with serialized tx strings")
- args = parser.parse_args(argv)
-
- tx_strings, description = load_payload(args)
- frames = build_frames(tx_strings, args.format, args.budget)
- if len(frames) == 1:
- print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
- else:
- print("{} frames ready".format(len(frames)), file=sys.stderr)
-
- app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
- EmitterWindow(frames, description, args.format, args.fps, args.loop)
- app.exec()
-
-
-if __name__ == "__main__":
- main(sys.argv[1:])
diff --git a/android/tools/sample_will.json b/android/tools/sample_will.json
deleted file mode 100644
index 94c0693..0000000
--- a/android/tools/sample_will.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "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