android: BAL Reader app (Chaquopy decode bridge, CameraX/ML Kit scanner, result view)
This commit is contained in:
31
android/app/src/main/AndroidManifest.xml
Normal file
31
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="true" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BalReader">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ResultActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.Context
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.PyException
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Chaquopy bridge over the plugin's animated-QR codecs
|
||||
* (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under
|
||||
* ``app/src/main/python``).
|
||||
*
|
||||
* The decode tail mirrors the plugin's import dialog exactly, and runs
|
||||
* entirely inside Python (``balreader.bridge.finish``) so no container
|
||||
* conversion happens across the bridge:
|
||||
*
|
||||
* session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload()
|
||||
*
|
||||
* Kotlin only feeds frames, reads progress, and renders the JSON the bridge
|
||||
* returns.
|
||||
*/
|
||||
class BalDecoder(private val context: Context) {
|
||||
|
||||
/** Outcome of feeding one scanned frame to the session. */
|
||||
enum class AddResult {
|
||||
/** A new frame was accepted. */
|
||||
OK,
|
||||
|
||||
/** The frame was already present (duplicate); ignore. */
|
||||
DUP,
|
||||
|
||||
/** The frame was not a supported QR transfer; ignore. */
|
||||
GARBAGE,
|
||||
|
||||
/** The QR switched to a different transfer; caller should rescan. */
|
||||
CONFLICT,
|
||||
}
|
||||
|
||||
/** Fully decoded transfer, mirroring the plugin's import tail. */
|
||||
data class DecodedResult(
|
||||
val kind: String, // "will", "txs" or "error"
|
||||
val payload: String, // raw transfer text (JSON or joined tx hexes)
|
||||
val parts: List<String> // [whole-will JSON] or [tx hex strings]
|
||||
)
|
||||
|
||||
private val python: Python by lazy {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(context))
|
||||
}
|
||||
Python.getInstance()
|
||||
}
|
||||
private val animatedQr by lazy { python.getModule("bal.core.animated_qr") }
|
||||
private val bridge by lazy { python.getModule("balreader.bridge") }
|
||||
|
||||
private var session: PyObject? = null
|
||||
|
||||
/** Start a fresh receive session (clears any accumulated frames). */
|
||||
fun reset() {
|
||||
session = null
|
||||
}
|
||||
|
||||
private fun sessionOrCreate(): PyObject {
|
||||
val current = session
|
||||
if (current != null) {
|
||||
return current
|
||||
}
|
||||
return animatedQr.callAttr("AnimatedQrSession").also { session = it }
|
||||
}
|
||||
|
||||
/** Feed one scanned frame string; see [AddResult] for semantics. */
|
||||
fun add(text: String): AddResult {
|
||||
return try {
|
||||
when (sessionOrCreate().callAttr("add_part", text).toString()) {
|
||||
"dup" -> AddResult.DUP
|
||||
else -> AddResult.OK
|
||||
}
|
||||
} catch (e: PyException) {
|
||||
val msg = e.message ?: ""
|
||||
// TransferConflictError: "Switched QR format mid-import (.. -> ..)".
|
||||
if (msg.contains("Switched QR format")) {
|
||||
AddResult.CONFLICT
|
||||
} else {
|
||||
AddResult.GARBAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */
|
||||
val format: String?
|
||||
get() = runCatching {
|
||||
session?.get("format")?.toString()?.takeIf { it != "None" }
|
||||
}.getOrNull()
|
||||
|
||||
/** Number of distinct frames accepted. */
|
||||
val received: Int
|
||||
get() = session?.get("received")?.toInt() ?: 0
|
||||
|
||||
/** Total frames expected for the current transfer (0 until known). */
|
||||
val total: Int
|
||||
get() = session?.get("total")?.toInt() ?: 0
|
||||
|
||||
/** True once the whole transfer has been captured. */
|
||||
val done: Boolean
|
||||
get() = session?.get("done")?.toBoolean() ?: false
|
||||
|
||||
/**
|
||||
* Resolve the completed session into a [DecodedResult]. The decoding runs
|
||||
* in Python (``balreader.bridge.finish``) using the exact same three steps
|
||||
* as the plugin's import dialog.
|
||||
*/
|
||||
fun finish(): DecodedResult {
|
||||
val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString()
|
||||
return try {
|
||||
val obj = JSONObject(jsonText)
|
||||
val partsArray = obj.getJSONArray("parts")
|
||||
val parts = (0 until partsArray.length()).map { partsArray.getString(it) }
|
||||
DecodedResult(
|
||||
kind = obj.getString("kind"),
|
||||
payload = obj.getString("payload"),
|
||||
parts = parts,
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
DecodedResult(kind = "error", payload = jsonText, parts = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
206
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
206
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
@@ -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<String, String> by lazy {
|
||||
mapOf(
|
||||
"balqr" to getString(R.string.format_balqr),
|
||||
"ur1" to getString(R.string.format_ur1),
|
||||
"ur2" to getString(R.string.format_ur2),
|
||||
"bbqr" to getString(R.string.format_bbqr),
|
||||
)
|
||||
}
|
||||
|
||||
private val requestCameraPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
startCamera()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
decoder = BalDecoder(applicationContext)
|
||||
barcodeScanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
} else {
|
||||
requestCameraPermission.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning from the result screen starts a new scan.
|
||||
if (finished) {
|
||||
finished = false
|
||||
decoder.reset()
|
||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text = "0 / 0"
|
||||
binding.tvStatus.setText(R.string.status_waiting)
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import life.after.bitcoin.databinding.ActivityResultBinding
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
class ResultActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityResultBinding
|
||||
private var kind = "txs"
|
||||
private var payload = ""
|
||||
private var parts: List<String> = emptyList()
|
||||
|
||||
private val saveWillPicker =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? ->
|
||||
saveTo(uri)
|
||||
}
|
||||
private val saveTxsPicker =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? ->
|
||||
saveTo(uri)
|
||||
}
|
||||
|
||||
private fun saveTo(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityResultBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
kind = intent.getStringExtra(EXTRA_KIND) ?: "txs"
|
||||
payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: ""
|
||||
parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList()
|
||||
|
||||
binding.tvKind.text =
|
||||
getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs)
|
||||
binding.tvContent.text = pretty(payload)
|
||||
|
||||
binding.btnCopy.setOnClickListener {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload))
|
||||
Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnShare.setOnClickListener {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, payload)
|
||||
}
|
||||
startActivity(Intent.createChooser(send, null))
|
||||
}
|
||||
binding.btnSave.setOnClickListener { saveFile() }
|
||||
binding.btnScanAnother.setOnClickListener { finish() }
|
||||
}
|
||||
|
||||
private fun pretty(json: String): String {
|
||||
if (kind == "will") {
|
||||
try {
|
||||
return JSONObject(json).toString(2)
|
||||
} catch (_: JSONException) {
|
||||
return json
|
||||
}
|
||||
}
|
||||
// Transaction list: one numbered line per tx.
|
||||
if (parts.isNotEmpty()) {
|
||||
return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) }
|
||||
.joinToString("\n")
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private fun saveFile() {
|
||||
val name = if (kind == "will") {
|
||||
getString(R.string.save_file_will)
|
||||
} else {
|
||||
getString(R.string.save_file_txs)
|
||||
}
|
||||
// ActivityResultContracts.CreateDocument takes the suggested file name;
|
||||
// it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally.
|
||||
val picker = if (kind == "will") saveWillPicker else saveTxsPicker
|
||||
picker.launch(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_KIND = "kind"
|
||||
const val EXTRA_PAYLOAD = "payload"
|
||||
const val EXTRA_PARTS = "parts"
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/python/bal/core/__init__.py
Normal file
21
android/app/src/main/python/bal/core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
bal.core
|
||||
========
|
||||
|
||||
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
Everything in this sub-package MUST stay completely free of any GUI / Qt
|
||||
imports. The rule of thumb is:
|
||||
|
||||
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
|
||||
and validating transactions, talking to
|
||||
will-executor servers, persistence helpers).
|
||||
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
|
||||
|
||||
Keeping the two apart is the main motivation behind this rewrite: the original
|
||||
code mixed transaction-building logic and presentation inside a single
|
||||
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
|
||||
|
||||
No behaviour is changed with respect to the original plugin; the code has only
|
||||
been reorganised and documented.
|
||||
"""
|
||||
1178
android/app/src/main/python/bal/core/animated_qr.py
Normal file
1178
android/app/src/main/python/bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
212
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
212
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
@@ -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
|
||||
1
android/app/src/main/python/balreader/__init__.py
Normal file
1
android/app/src/main/python/balreader/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Android reader helpers built on the bundled plugin codecs."""
|
||||
33
android/app/src/main/python/balreader/bridge.py
Normal file
33
android/app/src/main/python/balreader/bridge.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON.
|
||||
|
||||
A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side
|
||||
and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are
|
||||
the same three calls the plugin's import dialog performs:
|
||||
|
||||
session.resolve() -> (transfer_text, compressed)
|
||||
qrtransfer.decode_transfer -> parts
|
||||
balreader.payload.decode_will_payload -> ("will"|"txs", data)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from bal.core import qrtransfer as _qrtransfer
|
||||
from balreader import payload as _payload
|
||||
|
||||
|
||||
def finish(session):
|
||||
"""Run the import tail on a live ``AnimatedQrSession``.
|
||||
|
||||
Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}``
|
||||
with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns
|
||||
``{"kind": "error", "payload": <message>, "parts": []}`` so a misbehaving
|
||||
session can never crash the UI thread.
|
||||
"""
|
||||
try:
|
||||
transfer, compressed = session.resolve()
|
||||
parts = list(_qrtransfer.decode_transfer(transfer, compressed))
|
||||
payload = "\n".join(parts)
|
||||
kind, _data = _payload.decode_will_payload(payload)
|
||||
return json.dumps({"kind": kind, "payload": payload, "parts": parts})
|
||||
except Exception as exc: # noqa: BLE001 - defensive bridge boundary
|
||||
return json.dumps({"kind": "error", "payload": str(exc), "parts": []})
|
||||
33
android/app/src/main/python/balreader/payload.py
Normal file
33
android/app/src/main/python/balreader/payload.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Will-payload autodetection for the BAL Reader app.
|
||||
|
||||
This file is a verbatim copy of ``decode_will_payload`` from
|
||||
``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the
|
||||
two functions result-for-result so they can never drift apart.
|
||||
|
||||
Keep the function body identical to the plugin source.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_will_payload(text) -> tuple[Any, Any]:
|
||||
"""Autodetect: whole-will JSON or transaction list?
|
||||
|
||||
Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON
|
||||
object whose values are dicts containing a ``"tx"`` key (the whole-will
|
||||
format produced by :meth:`BalWindow.export_json_file` and friends).
|
||||
Otherwise returns ``("txs", [tx_strings])`` where the transaction
|
||||
strings were split on commas and/or newlines.
|
||||
"""
|
||||
text = text.strip()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
data = None
|
||||
if isinstance(data, dict) and data:
|
||||
if all(isinstance(v, dict) and "tx" in v for v in data.values()):
|
||||
return ("will", data)
|
||||
parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()]
|
||||
return ("txs", parts)
|
||||
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,14h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M19,19h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M72,14h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M77,19h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,72h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M19,77h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,42h4v4h-4z M22,42h4v4h-4z M14,50h4v4h-4z M22,50h4v4h-4z M14,58h4v4h-4z M30,42h4v4h-4z M30,50h4v4h-4z M14,66h4v4h-4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M52,14h4v4h-4z M60,14h4v4h-4z M52,22h4v4h-4z M68,14h4v4h-4z M52,30h4v4h-4z M56,42h4v4h-4z M64,42h4v4h-4z M56,50h4v4h-4z M72,42h4v4h-4z M56,58h4v4h-4z M64,58h4v4h-4z M56,66h4v4h-4z M64,66h4v4h-4z M72,58h4v4h-4z M64,74h4v4h-4z M72,66h4v4h-4z" />
|
||||
</vector>
|
||||
51
android/app/src/main/res/layout/activity_main.xml
Normal file
51
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@android:color/black">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:background="#1A1A1A">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_format"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/format_placeholder"
|
||||
android:textColor="#9BE8C0"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0 / 0"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/preview_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="10dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/status_waiting"
|
||||
android:textColor="#CFCFCF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
67
android/app/src/main/res/layout/activity_result.xml
Normal file
67
android/app/src/main/res/layout/activity_result.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_kind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?android:attr/colorBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:padding="8dp" />
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_copy" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_share"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_share" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_save"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_save" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_scan_another"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_scan_another" />
|
||||
</LinearLayout>
|
||||
24
android/app/src/main/res/values/strings.xml
Normal file
24
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<resources>
|
||||
<string name="app_name">BAL Reader</string>
|
||||
|
||||
<string name="status_waiting">Point the camera at the QR screen</string>
|
||||
<string name="format_placeholder">—</string>
|
||||
<string name="progress_fmt">%1$d / %2$d</string>
|
||||
|
||||
<string name="format_balqr">BAL QR</string>
|
||||
<string name="format_ur1">BC-UR v1</string>
|
||||
<string name="format_ur2">BC-UR v2</string>
|
||||
<string name="format_bbqr">BBQR</string>
|
||||
|
||||
<string name="result_kind_will">Whole will (JSON)</string>
|
||||
<string name="result_kind_txs">Transaction list</string>
|
||||
<string name="action_copy">Copy</string>
|
||||
<string name="action_share">Share</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="action_scan_another">Scan another</string>
|
||||
<string name="copied_toast">Transfer copied to clipboard</string>
|
||||
<string name="save_file_will">will.json</string>
|
||||
<string name="save_file_txs">will_tx.txt</string>
|
||||
<string name="permission_denied">Camera permission is required to scan QR codes.</string>
|
||||
<string name="conflict_message">The QR switched to a different transfer. Let it rescan.</string>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user