From 458968fadc9b4b0dc7fc553772642b0ba13e3dc2 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Wed, 9 Sep 2026 08:43:39 -0400 Subject: [PATCH] android: BAL Reader app (Chaquopy decode bridge, CameraX/ML Kit scanner, result view) --- android/.gitignore | 10 + android/README.md | 138 ++ android/app/build.gradle.kts | 67 + android/app/proguard-rules.pro | 5 + android/app/src/main/AndroidManifest.xml | 31 + .../java/life/after/bitcoin/BalDecoder.kt | 129 ++ .../java/life/after/bitcoin/MainActivity.kt | 206 +++ .../java/life/after/bitcoin/ResultActivity.kt | 100 ++ .../app/src/main/python/bal/core/__init__.py | 21 + .../src/main/python/bal/core/animated_qr.py | 1178 +++++++++++++++++ .../src/main/python/bal/core/qrtransfer.py | 212 +++ .../app/src/main/python/balreader/__init__.py | 1 + .../app/src/main/python/balreader/bridge.py | 33 + .../app/src/main/python/balreader/payload.py | 33 + .../app/src/main/res/drawable/ic_launcher.xml | 34 + .../app/src/main/res/layout/activity_main.xml | 51 + .../src/main/res/layout/activity_result.xml | 67 + android/app/src/main/res/values/strings.xml | 24 + android/app/src/main/res/values/themes.xml | 3 + android/build.gradle.kts | 7 + android/gradle.properties | 5 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 251 ++++ android/gradlew.bat | 94 ++ android/scripts/sync_codecs.py | 99 ++ android/settings.gradle.kts | 23 + android/test_chain/verify_chain.py | 314 +++++ android/tools/emitter.py | 202 +++ android/tools/sample_will.json | 26 + 30 files changed, 3371 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/life/after/bitcoin/BalDecoder.kt create mode 100644 android/app/src/main/java/life/after/bitcoin/MainActivity.kt create mode 100644 android/app/src/main/java/life/after/bitcoin/ResultActivity.kt create mode 100644 android/app/src/main/python/bal/core/__init__.py create mode 100644 android/app/src/main/python/bal/core/animated_qr.py create mode 100644 android/app/src/main/python/bal/core/qrtransfer.py create mode 100644 android/app/src/main/python/balreader/__init__.py create mode 100644 android/app/src/main/python/balreader/bridge.py create mode 100644 android/app/src/main/python/balreader/payload.py create mode 100644 android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/layout/activity_result.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/scripts/sync_codecs.py create mode 100644 android/settings.gradle.kts create mode 100644 android/test_chain/verify_chain.py create mode 100644 android/tools/emitter.py create mode 100644 android/tools/sample_will.json diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..e76be97 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,10 @@ +.gradle/ +build/ +local.properties +.idea/ +*.apk +*.aab +captures/ +.externalNativeBuild/ +.cxx/ +*.hprof \ No newline at end of file diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..f4b4ebb --- /dev/null +++ b/android/README.md @@ -0,0 +1,138 @@ +# 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; +- **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 +└── 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: + +```bash +cd android +./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk +``` + +### If the Gradle wrapper jar is missing + +`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of +the box. If it is ever absent, Android Studio regenerates it on the first +sync; no manual steps needed. + +## Use + +1. In Electrum + BAL, open the will's **export** dialog. +2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**, + **BC-UR v2**, and **BBQR**. +3. Make sure the wording toggle shows a payload (business logic), then display + the animated QR and keep it on screen. +4. Point the phone at the screen. The header shows the detected format and + `received / total`; scanning stops automatically when the transfer is + complete. +5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it + as `will.json` (whole will) or `will_tx.txt` (transaction list), or press + **Scan another**. + +Notes: + +- Keep the phone still and the whole QR inside the frame (the codec dedups + repeated frames, so a slow capture is fine). +- If the camera glares off the screen, reduce brightness or tilt slightly. +- If scanning jumps between exports, the app detects the format switch, + resets, and asks you to let it re-scan. + +## Keeping the bundled code in sync with the plugin + +The codecs under `app/src/main/python/bal/core/` are **committed copies** for +deterministic builds, but they must stay identical to the plugin. Re-run this +after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and +after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which +mirrors `balreader/payload.py`): + +```bash +python3 android/scripts/sync_codecs.py # copy +python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly) +python3 android/test_chain/verify_chain.py # full decode-chain regression +``` + +`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from +the plugin's `decode_will_payload` (AST identity + result parity). + +## Version pins (see `PLAN_ANDROID_READER.md`) + +| Item | Version | +|---|---| +| AGP | 8.10.0 | +| Gradle | 8.14 (wrapper) | +| Kotlin | 2.0.21 | +| Chaquopy | 17.0.0 (Python 3.12) | +| compile / target / min SDK | 35 / 35 / 24 | +| CameraX | 1.3.4 | +| ML Kit barcode-scanning | 17.3.0 | +| JDK | 17 | + +## License + +MIT. The bundled Python codec files inherit the plugin's MIT license +(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution. \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..dbd7079 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.chaquo.python") +} + +android { + namespace = "life.after.bitcoin" + compileSdk = 35 + + defaultConfig { + applicationId = "life.after.bitcoin" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + // Chaquopy requires explicit ABI filters. Python 3.12 ships only for + // 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64). + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + viewBinding = true + } +} + +chaquopy { + defaultConfig { + version = "3.12" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + + // CameraX + implementation("androidx.camera:camera-core:1.3.4") + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + + // ML Kit on-device barcode scanning (QR only, no API key) + implementation("com.google.mlkit:barcode-scanning:17.3.0") +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e20d335 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Chaquopy Python runtime. +-keep class com.chaquo.python.** { *; } + +# ML Kit barcode scanning. +-keep class com.google.mlkit.** { *; } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc2f69d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt b/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt new file mode 100644 index 0000000..e75d627 --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt @@ -0,0 +1,129 @@ +package life.after.bitcoin + +import android.content.Context +import com.chaquo.python.PyObject +import com.chaquo.python.PyException +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import org.json.JSONException +import org.json.JSONObject + +/** + * Chaquopy bridge over the plugin's animated-QR codecs + * (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under + * ``app/src/main/python``). + * + * The decode tail mirrors the plugin's import dialog exactly, and runs + * entirely inside Python (``balreader.bridge.finish``) so no container + * conversion happens across the bridge: + * + * session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload() + * + * Kotlin only feeds frames, reads progress, and renders the JSON the bridge + * returns. + */ +class BalDecoder(private val context: Context) { + + /** Outcome of feeding one scanned frame to the session. */ + enum class AddResult { + /** A new frame was accepted. */ + OK, + + /** The frame was already present (duplicate); ignore. */ + DUP, + + /** The frame was not a supported QR transfer; ignore. */ + GARBAGE, + + /** The QR switched to a different transfer; caller should rescan. */ + CONFLICT, + } + + /** Fully decoded transfer, mirroring the plugin's import tail. */ + data class DecodedResult( + val kind: String, // "will", "txs" or "error" + val payload: String, // raw transfer text (JSON or joined tx hexes) + val parts: List // [whole-will JSON] or [tx hex strings] + ) + + private val python: Python by lazy { + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + Python.getInstance() + } + private val animatedQr by lazy { python.getModule("bal.core.animated_qr") } + private val bridge by lazy { python.getModule("balreader.bridge") } + + private var session: PyObject? = null + + /** Start a fresh receive session (clears any accumulated frames). */ + fun reset() { + session = null + } + + private fun sessionOrCreate(): PyObject { + val current = session + if (current != null) { + return current + } + return animatedQr.callAttr("AnimatedQrSession").also { session = it } + } + + /** Feed one scanned frame string; see [AddResult] for semantics. */ + fun add(text: String): AddResult { + return try { + when (sessionOrCreate().callAttr("add_part", text).toString()) { + "dup" -> AddResult.DUP + else -> AddResult.OK + } + } catch (e: PyException) { + val msg = e.message ?: "" + // TransferConflictError: "Switched QR format mid-import (.. -> ..)". + if (msg.contains("Switched QR format")) { + AddResult.CONFLICT + } else { + AddResult.GARBAGE + } + } + } + + /** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */ + val format: String? + get() = runCatching { + session?.get("format")?.toString()?.takeIf { it != "None" } + }.getOrNull() + + /** Number of distinct frames accepted. */ + val received: Int + get() = session?.get("received")?.toInt() ?: 0 + + /** Total frames expected for the current transfer (0 until known). */ + val total: Int + get() = session?.get("total")?.toInt() ?: 0 + + /** True once the whole transfer has been captured. */ + val done: Boolean + get() = session?.get("done")?.toBoolean() ?: false + + /** + * Resolve the completed session into a [DecodedResult]. The decoding runs + * in Python (``balreader.bridge.finish``) using the exact same three steps + * as the plugin's import dialog. + */ + fun finish(): DecodedResult { + val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString() + return try { + val obj = JSONObject(jsonText) + val partsArray = obj.getJSONArray("parts") + val parts = (0 until partsArray.length()).map { partsArray.getString(it) } + DecodedResult( + kind = obj.getString("kind"), + payload = obj.getString("payload"), + parts = parts, + ) + } catch (e: JSONException) { + DecodedResult(kind = "error", payload = jsonText, parts = emptyList()) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/MainActivity.kt b/android/app/src/main/java/life/after/bitcoin/MainActivity.kt new file mode 100644 index 0000000..6647c0d --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/MainActivity.kt @@ -0,0 +1,206 @@ +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) + } + 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) + 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) + } + } + } + + private fun finishScan() { + if (finished) { + return + } + finished = true + val result = decoder.finish() + Log.i(TAG, "FINISH kind=${result.kind} payload=${result.payload.length}B parts=${result.parts.size}") + val intent = Intent(this, ResultActivity::class.java).apply { + putExtra(ResultActivity.EXTRA_KIND, result.kind) + putExtra(ResultActivity.EXTRA_PAYLOAD, result.payload) + putStringArrayListExtra(ResultActivity.EXTRA_PARTS, ArrayList(result.parts)) + } + startActivity(intent) + } + + companion object { + private const val TAG = "BalReader" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt b/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt new file mode 100644 index 0000000..4b0bb85 --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt @@ -0,0 +1,100 @@ +package life.after.bitcoin + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import life.after.bitcoin.databinding.ActivityResultBinding +import org.json.JSONException +import org.json.JSONObject + +class ResultActivity : AppCompatActivity() { + + private lateinit var binding: ActivityResultBinding + private var kind = "txs" + private var payload = "" + private var parts: List = emptyList() + + private val saveWillPicker = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? -> + saveTo(uri) + } + private val saveTxsPicker = + registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? -> + saveTo(uri) + } + + private fun saveTo(uri: Uri?) { + if (uri != null) { + contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityResultBinding.inflate(layoutInflater) + setContentView(binding.root) + + kind = intent.getStringExtra(EXTRA_KIND) ?: "txs" + payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: "" + parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList() + + binding.tvKind.text = + getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs) + binding.tvContent.text = pretty(payload) + + binding.btnCopy.setOnClickListener { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload)) + Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show() + } + binding.btnShare.setOnClickListener { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, payload) + } + startActivity(Intent.createChooser(send, null)) + } + binding.btnSave.setOnClickListener { saveFile() } + binding.btnScanAnother.setOnClickListener { finish() } + } + + private fun pretty(json: String): String { + if (kind == "will") { + try { + return JSONObject(json).toString(2) + } catch (_: JSONException) { + return json + } + } + // Transaction list: one numbered line per tx. + if (parts.isNotEmpty()) { + return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) } + .joinToString("\n") + } + return json + } + + private fun saveFile() { + val name = if (kind == "will") { + getString(R.string.save_file_will) + } else { + getString(R.string.save_file_txs) + } + // ActivityResultContracts.CreateDocument takes the suggested file name; + // it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally. + val picker = if (kind == "will") saveWillPicker else saveTxsPicker + picker.launch(name) + } + + companion object { + const val EXTRA_KIND = "kind" + const val EXTRA_PAYLOAD = "payload" + const val EXTRA_PARTS = "parts" + } +} \ No newline at end of file diff --git a/android/app/src/main/python/bal/core/__init__.py b/android/app/src/main/python/bal/core/__init__.py new file mode 100644 index 0000000..6e1c1b2 --- /dev/null +++ b/android/app/src/main/python/bal/core/__init__.py @@ -0,0 +1,21 @@ +""" +bal.core +======== + +Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin. + +Everything in this sub-package MUST stay completely free of any GUI / Qt +imports. The rule of thumb is: + + * ``bal.core`` -> "what the plugin does" (inheritance rules, building + and validating transactions, talking to + will-executor servers, persistence helpers). + * ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views). + +Keeping the two apart is the main motivation behind this rewrite: the original +code mixed transaction-building logic and presentation inside a single +4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit. + +No behaviour is changed with respect to the original plugin; the code has only +been reorganised and documented. +""" diff --git a/android/app/src/main/python/bal/core/animated_qr.py b/android/app/src/main/python/bal/core/animated_qr.py new file mode 100644 index 0000000..64b30cf --- /dev/null +++ b/android/app/src/main/python/bal/core/animated_qr.py @@ -0,0 +1,1178 @@ +""" +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, unchanged): ``BALQR1|total|index|flags|payload``. +* **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"): + return "balqr" + if text.startswith(_BBQR_PREFIX): + return "bbqr" + if not lowered.startswith("ur:"): + return None + if lowered.startswith("ur:bytes/"): + remainder = lowered[len("ur:bytes/") :] + first = remainder.split("/", 1)[0] + if "of" in first: + return "ur1" + if "-" in first: + return "ur2" + # Single-part: the whole remainder is the body. Prefer a bytewords v2 + # body (CBOR byte-string head 0x40..0x60), then BC32 v1. + try: + body = bytewords_minimal_decode(remainder) + except AnimatedQrError: + pass + else: + if body and 0x40 <= body[0] <= 0x60: + return "ur2" + try: + bc32_decode(remainder) + except AnimatedQrError: + return None + return "ur1" + return None + + +def parse_for_detection(text: str) -> Tuple[str, str, int, int]: + """Parse a frame and return ``(format, session_key, frame_total, index)``. + + ``session_key`` identifies the transfer the frame belongs to and drives + the shared reset/ignore/accept debounce. Raises + :class:`AnimatedQrError` when the text cannot be parsed. + """ + fmt = detect_format(text) + if fmt == "balqr": + total, index, _compressed, _payload = _parse_balqr(text) + return "balqr", "balqr:{}".format(total), total, index + if fmt == "ur1": + index, total, digest, _frag = ur1_parse_part(text) + return "ur1", "ur1:{}".format(digest), total, index + if fmt == "ur2": + seq, seq_len, message_len, checksum, _data = ur2_parse_part(text) + return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq + if fmt == "bbqr": + encoding, type_code, total, index, _payload = bbqr_parse_part(text) + return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index + raise FormatNotDetectedError("Not a supported QR transfer format") + + +def _parse_balqr(text: str) -> Tuple[int, int, bool, str]: + from bal.core.qrtransfer import parse_frame + + return parse_frame(text) + + +# --------------------------------------------------------------------------- # +# Receive sessions (order-independent assembly per format) +# --------------------------------------------------------------------------- # + +class _BalQrSession: + def __init__(self): + self._frames: Dict[int, str] = {} + self._total = 0 + self._compressed = False + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._frames) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._frames) >= self._total + + def add(self, text: str) -> str: + total, index, compressed, payload = _parse_balqr(text) + if self._total and total != self._total: + raise TransferConflictError("BAL QR transfer total changed") + if len(self._frames) >= _MAX_SESSION_PARTS: + raise SessionLimitError("too many BAL QR frames") + if not self._total: + self._total = total + self._compressed = compressed + if index in self._frames: + return "dup" + self._frames[index] = payload + return "ok" + + def resolve(self) -> Tuple[str, bool]: + from bal.core.qrtransfer import assemble + + text = assemble(self._frames, self._total) + return text, self._compressed + + +class _Ur1Session: + def __init__(self): + self._total = 0 + self._digest = "" + self._fragments: Dict[int, str] = {} + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._fragments) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._fragments) >= self._total + + def add(self, text: str) -> str: + index, total, digest, fragment = ur1_parse_part(text) + if self._total: + if total != self._total or digest != self._digest: + raise TransferConflictError("BC-UR v1 transfer digest changed") + else: + self._total = total + self._digest = digest + if total > _MAX_SESSION_PARTS: + raise SessionLimitError("BC-UR v1 demands too many parts") + if index in self._fragments: + return "dup" + self._fragments[index] = fragment + return "ok" + + def resolve(self) -> Tuple[str, bool]: + full = "".join(self._fragments[i] for i in range(1, self._total + 1)) + try: + message = bc32_decode(full) + except AnimatedQrError: + raise ChecksumError("BC-UR v1 checksum mismatch") from None + if self._digest and _ur1_digest(message) != self._digest: + raise ChecksumError("BC-UR v1 digest mismatch") + return _transfer_text(unwrap_ur_cbor(message)), False + + +class _Ur2Session: + """Fountain decoder mirroring the reference (C++/python) semantics.""" + + def __init__(self): + self._seq_len = 0 + self._message_len = 0 + self._checksum = 0 + self._fragment_len = 0 + self._received: Set[int] = set() + self._simple: Dict[FrozenSet[int], bytes] = {} + self._mixed: Dict[FrozenSet[int], bytes] = {} + self._queue: List[Tuple[FrozenSet[int], bytes]] = [] + self._processed = 0 + self._result: Optional[bytes] = None + self._bad = False + + @property + def total(self) -> int: + return self._seq_len + + @property + def received(self) -> int: + return self._processed + + @property + def done(self) -> bool: + return self._result is not None + + def add(self, text: str) -> str: + seq, seq_len, message_len, checksum, data = ur2_parse_part(text) + if self._seq_len: + if not self._validate(seq_len, message_len, checksum, len(data)): + raise TransferConflictError("BC-UR v2 transfer header changed") + else: + self._seq_len = seq_len + self._message_len = message_len + self._checksum = checksum + self._fragment_len = len(data) + if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES: + raise SessionLimitError("BC-UR v2 session exceeds safety caps") + indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum)) + self._receive(indexes, bytes(data)) + return "ok" + + def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool: + return ( + seq_len == self._seq_len + and message_len == self._message_len + and checksum == self._checksum + and data_len == self._fragment_len + ) + + def _receive(self, indexes: FrozenSet[int], data: bytes) -> None: + if self._result is not None or self._bad: + return + self._queue.append((indexes, data)) + while self._result is None and not self._bad and self._queue: + self._process(self._queue.pop(0)) + self._processed += 1 + + def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None: + indexes, data = item + if len(indexes) == 1: + self._process_simple(indexes, data) + else: + self._process_mixed(indexes, data) + + def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None: + fragment_index = next(iter(indexes)) + if fragment_index in self._received: + return + self._simple[indexes] = data + self._received.add(fragment_index) + if self._received == set(range(self._seq_len)): + self._finish() + return + self._reduce_mixed_by(indexes, data) + + def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None: + new_mixed: Dict[FrozenSet[int], bytes] = {} + for other_indexes, other_data in self._mixed.items(): + reduced = self._reduce_part(other_indexes, other_data, indexes, data) + if len(reduced[0]) == 1: + self._queue.append(reduced) + else: + new_mixed[reduced[0]] = reduced[1] + self._mixed = new_mixed + + def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None: + if indexes in self._mixed: + return + reduced_indexes, reduced_data = indexes, data + for simple_indexes, simple_data in self._simple.items(): + reduced_indexes, reduced_data = self._reduce_part( + reduced_indexes, reduced_data, simple_indexes, simple_data + ) + for other_indexes, other_data in list(self._mixed.items()): + reduced_indexes, reduced_data = self._reduce_part( + reduced_indexes, reduced_data, other_indexes, other_data + ) + if len(reduced_indexes) == 1: + self._queue.append((reduced_indexes, reduced_data)) + else: + self._reduce_mixed_by(reduced_indexes, reduced_data) + if reduced_indexes not in self._mixed: + self._mixed[reduced_indexes] = reduced_data + + @staticmethod + def _reduce_part( + a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes + ) -> Tuple[FrozenSet[int], bytes]: + if b_indexes == a_indexes or not b_indexes.issubset(a_indexes): + return a_indexes, a_data + new_indexes = a_indexes - b_indexes + new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True)) + return new_indexes, new_data + + def _finish(self) -> None: + fragments = [] + for index in range(self._seq_len): + key = frozenset([index]) + if key not in self._simple: + self._bad = True + return + fragments.append(self._simple[key]) + message = b"".join(fragments)[: self._message_len] + if crc32_int(message) != self._checksum: + self._bad = True + return + self._result = message + + def resolve(self) -> Tuple[str, bool]: + if self._bad: + raise ChecksumError("BC-UR v2 message checksum mismatch") + if self._result is None: + raise AnimatedQrError("BC-UR v2 session is not complete") + return _transfer_text(unwrap_ur_cbor(self._result)), False + + +class _BbqrSession: + def __init__(self): + self._total = 0 + self._encoding = "" + self._type_code = "" + self._parts: Dict[int, str] = {} + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._parts) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._parts) >= self._total + + def add(self, text: str) -> str: + encoding, type_code, total, index, payload = bbqr_parse_part(text) + if self._total: + if (encoding, type_code, total) != (self._encoding, self._type_code, self._total): + raise TransferConflictError("BBQR frame header changed") + else: + self._total = total + self._encoding = encoding + self._type_code = type_code + if index in self._parts: + return "dup" + self._parts[index] = payload + return "ok" + + def resolve(self) -> Tuple[str, bool]: + ordered = [self._parts[i] for i in range(self._total)] + raw = _bbqr_decode(ordered, self._encoding) + return _transfer_text(raw), False + + +def _transfer_text(raw: bytes) -> str: + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + raise AnimatedQrError("decoded transfer is not valid UTF-8") from None + + +class AnimatedQrSession: + """Facade over the per-format receive sessions used by the QR import page.""" + + _DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession")) + + def __init__(self): + self._inner: Optional[object] = None + self._fmt: Optional[str] = None + + @property + def format(self) -> Optional[str]: + return self._fmt + + def add_part(self, text: str) -> str: + """Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input.""" + fmt = detect_format(text) + if fmt is None: + raise FormatNotDetectedError("Not a supported QR transfer format") + if self._inner is None: + self._fmt = fmt + self._inner = self._make(fmt) + elif fmt != self._fmt: + raise TransferConflictError( + "Switched QR format mid-import ({} -> {})".format(self.format, fmt) + ) + return self._inner.add(text) # type: ignore[no-any-return] + + @staticmethod + def _make(fmt: str) -> object: + if fmt == "balqr": + return _BalQrSession() + if fmt == "ur1": + return _Ur1Session() + if fmt == "ur2": + return _Ur2Session() + if fmt == "bbqr": + return _BbqrSession() + raise AssertionError("unknown animated-QR format {}".format(fmt)) + + @property + def total(self) -> int: + return self._inner.total if self._inner is not None else 0 + + @property + def received(self) -> int: + return self._inner.received if self._inner is not None else 0 + + @property + def done(self) -> bool: + return bool(self._inner is not None and self._inner.done) + + def resolve(self) -> Tuple[str, bool]: + if self._inner is None: + raise AnimatedQrError("no transfer has been received") + return self._inner.resolve() # type: ignore[no-any-return] diff --git a/android/app/src/main/python/bal/core/qrtransfer.py b/android/app/src/main/python/bal/core/qrtransfer.py new file mode 100644 index 0000000..227e00a --- /dev/null +++ b/android/app/src/main/python/bal/core/qrtransfer.py @@ -0,0 +1,212 @@ +""" +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 ``BALQR1|N|i|flags|payload`` frames for + multi-QR export, and reassembles/validates them 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" + +# 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 + +_FRAME_MAGIC = MAGIC + str(VERSION) + + +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 flags + of the frame header let the importer reverse this automatically. + """ + return __compress("\n".join(tx_strings), enabled=compress) + + +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 ``BALQR`` frames. + + Every returned frame is at most ``chunk_size`` characters long (header + included). ``compressed`` propagates 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. + """ + flags = FLAG_COMPRESSED if compressed else "" + total = __compute_total(len(transfer_string), chunk_size, flags) + frames = [] + pos = 0 + length = len(transfer_string) + for index in range(1, total + 1): + overhead = len(__frame_header(total, index, flags)) + budget = chunk_size - overhead + end = min(pos + budget, length) + frames.append(__build_frame(total, index, flags, transfer_string[pos:end])) + pos = end + if pos >= length: + break + 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. + + 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). + """ + 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: + 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 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 __frame_header(total, index, flags): + return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags) + + +def __build_frame(total, index, flags, payload): + return __frame_header(total, index, flags) + payload + + +def __compute_total(transfer_len, chunk_size, flags): + """Smallest frame count whose budget covers the whole transfer string. + + The budget shrinks as ``total`` gains digits (wider header), so the count + is recomputed iteratively until it converges. + """ + if chunk_size < MIN_CHUNK_SIZE: + raise QrTransferError( + "chunk size too small to hold a BAL QR frame: {}".format(chunk_size) + ) + total = 1 + while True: + overhead = len(__frame_header(total, total, flags)) + budget = chunk_size - overhead + if budget <= 0: + raise QrTransferError( + "chunk size too small for the BAL QR frame header: {}".format(chunk_size) + ) + if transfer_len <= budget * total: + return total + total += 1 diff --git a/android/app/src/main/python/balreader/__init__.py b/android/app/src/main/python/balreader/__init__.py new file mode 100644 index 0000000..ec39957 --- /dev/null +++ b/android/app/src/main/python/balreader/__init__.py @@ -0,0 +1 @@ +"""Android reader helpers built on the bundled plugin codecs.""" \ No newline at end of file diff --git a/android/app/src/main/python/balreader/bridge.py b/android/app/src/main/python/balreader/bridge.py new file mode 100644 index 0000000..2b49974 --- /dev/null +++ b/android/app/src/main/python/balreader/bridge.py @@ -0,0 +1,33 @@ +"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON. + +A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side +and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are +the same three calls the plugin's import dialog performs: + + session.resolve() -> (transfer_text, compressed) + qrtransfer.decode_transfer -> parts + balreader.payload.decode_will_payload -> ("will"|"txs", data) +""" + +import json + +from bal.core import qrtransfer as _qrtransfer +from balreader import payload as _payload + + +def finish(session): + """Run the import tail on a live ``AnimatedQrSession``. + + Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}`` + with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns + ``{"kind": "error", "payload": , "parts": []}`` so a misbehaving + session can never crash the UI thread. + """ + try: + transfer, compressed = session.resolve() + parts = list(_qrtransfer.decode_transfer(transfer, compressed)) + payload = "\n".join(parts) + kind, _data = _payload.decode_will_payload(payload) + return json.dumps({"kind": kind, "payload": payload, "parts": parts}) + except Exception as exc: # noqa: BLE001 - defensive bridge boundary + return json.dumps({"kind": "error", "payload": str(exc), "parts": []}) \ No newline at end of file diff --git a/android/app/src/main/python/balreader/payload.py b/android/app/src/main/python/balreader/payload.py new file mode 100644 index 0000000..f454b1a --- /dev/null +++ b/android/app/src/main/python/balreader/payload.py @@ -0,0 +1,33 @@ +"""Will-payload autodetection for the BAL Reader app. + +This file is a verbatim copy of ``decode_will_payload`` from +``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the +two functions result-for-result so they can never drift apart. + +Keep the function body identical to the plugin source. +""" + +import json +import re +from typing import Any + + +def decode_will_payload(text) -> tuple[Any, Any]: + """Autodetect: whole-will JSON or transaction list? + + Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON + object whose values are dicts containing a ``"tx"`` key (the whole-will + format produced by :meth:`BalWindow.export_json_file` and friends). + Otherwise returns ``("txs", [tx_strings])`` where the transaction + strings were split on commas and/or newlines. + """ + text = text.strip() + try: + data = json.loads(text) + except (json.JSONDecodeError, ValueError): + data = None + if isinstance(data, dict) and data: + if all(isinstance(v, dict) and "tx" in v for v in data.values()): + return ("will", data) + parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()] + return ("txs", parts) \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..a3043c2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b866c03 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_result.xml b/android/app/src/main/res/layout/activity_result.xml new file mode 100644 index 0000000..0100515 --- /dev/null +++ b/android/app/src/main/res/layout/activity_result.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + +