android: BAL Reader app (Chaquopy decode bridge, CameraX/ML Kit scanner, result view)
This commit is contained in:
10
android/.gitignore
vendored
Normal file
10
android/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.hprof
|
||||||
138
android/README.md
Normal file
138
android/README.md
Normal file
@@ -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.
|
||||||
67
android/app/build.gradle.kts
Normal file
67
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("com.chaquo.python")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "life.after.bitcoin"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "life.after.bitcoin"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
// Chaquopy requires explicit ABI filters. Python 3.12 ships only for
|
||||||
|
// 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64).
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chaquopy {
|
||||||
|
defaultConfig {
|
||||||
|
version = "3.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
|
||||||
|
// CameraX
|
||||||
|
implementation("androidx.camera:camera-core:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-view:1.3.4")
|
||||||
|
|
||||||
|
// ML Kit on-device barcode scanning (QR only, no API key)
|
||||||
|
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||||
|
}
|
||||||
5
android/app/proguard-rules.pro
vendored
Normal file
5
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Chaquopy Python runtime.
|
||||||
|
-keep class com.chaquo.python.** { *; }
|
||||||
|
|
||||||
|
# ML Kit barcode scanning.
|
||||||
|
-keep class com.google.mlkit.** { *; }
|
||||||
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>
|
||||||
7
android/build.gradle.kts
Normal file
7
android/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Top-level build file: plugin versions only. See the docs for the full
|
||||||
|
// compatibility matrix (Chaquopy 17 requires AGP 7.3-9.2 and minSdk 24).
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.10.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("com.chaquo.python") version "17.0.0" apply false
|
||||||
|
}
|
||||||
5
android/gradle.properties
Normal file
5
android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
android/gradlew
vendored
Executable file
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
android/gradlew.bat
vendored
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
99
android/scripts/sync_codecs.py
Normal file
99
android/scripts/sync_codecs.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Synchronise the BAL QR codec modules into the Android app.
|
||||||
|
|
||||||
|
Copies ``bal/core/{__init__,animated_qr,qrtransfer}.py`` from the plugin repo
|
||||||
|
into ``android/app/src/main/python/bal/core/`` so Chaquopy ships exactly the
|
||||||
|
same code the desktop plugin runs. Afterwards the copies are imported
|
||||||
|
standalone and used for one quick encode/decode round trip.
|
||||||
|
|
||||||
|
Run from the repository root (any Python 3.8+, no dependencies):
|
||||||
|
|
||||||
|
python3 android/scripts/sync_codecs.py
|
||||||
|
python3 android/scripts/sync_codecs.py --check # no writes
|
||||||
|
|
||||||
|
Re-run whenever ``bal/core/animated_qr.py`` or ``bal/core/qrtransfer.py``
|
||||||
|
changes; the bundled copies are committed for deterministic builds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CORE_SRC = REPO_ROOT / "bal" / "core"
|
||||||
|
PYTHON_DEST = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
||||||
|
BAL_CORE_DEST = PYTHON_DEST / "bal" / "core"
|
||||||
|
|
||||||
|
FILES = ("__init__.py", "animated_qr.py", "qrtransfer.py")
|
||||||
|
|
||||||
|
ROUNDTRIP = (
|
||||||
|
"import sys; "
|
||||||
|
"sys.path.insert(0, {dest!r}); "
|
||||||
|
"from bal.core.animated_qr import AnimatedQrSession, ur2_frames; "
|
||||||
|
"import bal.core.qrtransfer as qtf; "
|
||||||
|
"frames = ur2_frames(b'roundtrip-check', 400); "
|
||||||
|
"assert frames, 'no frames produced'; "
|
||||||
|
"s = AnimatedQrSession(); "
|
||||||
|
"assert all(s.add_part(f) == 'ok' for f in frames); "
|
||||||
|
"transfer, compressed = s.resolve(); "
|
||||||
|
"assert not compressed and transfer == 'roundtrip-check', 'roundtrip failed'; "
|
||||||
|
"print('standalone import + roundtrip OK'); "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def check_up_to_date() -> int:
|
||||||
|
outdated = []
|
||||||
|
for name in FILES:
|
||||||
|
src = (CORE_SRC / name).read_bytes()
|
||||||
|
dst = BAL_CORE_DEST / name
|
||||||
|
if not dst.exists() or dst.read_bytes() != src:
|
||||||
|
outdated.append(name)
|
||||||
|
if outdated:
|
||||||
|
print("OUT OF DATE: {}".format(", ".join(outdated)))
|
||||||
|
print("run: python3 android/scripts/sync_codecs.py")
|
||||||
|
return 1
|
||||||
|
print("codec bundles are up to date")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="verify the bundled copies are up to date without writing",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
return check_up_to_date()
|
||||||
|
|
||||||
|
BAL_CORE_DEST.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in FILES:
|
||||||
|
src = (CORE_SRC / name).read_bytes()
|
||||||
|
dst = BAL_CORE_DEST / name
|
||||||
|
dst.write_bytes(src)
|
||||||
|
print("synced {:16s} sha256={}".format(name, sha256(src)[:16]))
|
||||||
|
|
||||||
|
run = subprocess.run(
|
||||||
|
[sys.executable, "-c", ROUNDTRIP.format(dest=str(PYTHON_DEST))],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if run.returncode != 0:
|
||||||
|
print(run.stdout, end="")
|
||||||
|
print(run.stderr, end="")
|
||||||
|
return 1
|
||||||
|
print(run.stdout.strip())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
23
android/settings.gradle.kts
Normal file
23
android/settings.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "BALReader"
|
||||||
|
include(":app")
|
||||||
314
android/test_chain/verify_chain.py
Normal file
314
android/test_chain/verify_chain.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the Android app can decode every frame format the plugin exports.
|
||||||
|
|
||||||
|
Simulates the exact runtime path of the APK on the development machine:
|
||||||
|
|
||||||
|
* imports ``bal.core`` and ``balreader.payload`` from the *bundled* copies in
|
||||||
|
``android/app/src/main/python`` (the code Chaquopy actually ships);
|
||||||
|
* generates frames exactly as the plugin's export page does
|
||||||
|
(``split_frames`` for BAL QR, ``encode_animated_frames`` semantics for
|
||||||
|
UR v1 / UR v2 / BBQR);
|
||||||
|
* drives an :class:`~bal.core.animated_qr.AnimatedQrSession` the way
|
||||||
|
``BalDecoder.add`` does (scrambled input, duplicates, dropped frames);
|
||||||
|
* runs the app's ``finish()`` chain (``resolve()`` -> ``decode_transfer()``
|
||||||
|
-> ``decode_will_payload()``) and checks the result;
|
||||||
|
* cross-checks the app's ``decode_will_payload`` copy result-for-result (and
|
||||||
|
AST-for-AST) against the plugin's original in ``bal/gui/qt/dialogs.py``.
|
||||||
|
|
||||||
|
Run from the repository root (any Python 3.8+, no dependencies):
|
||||||
|
|
||||||
|
python3 android/test_chain/verify_chain.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
APP_PYTHON = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
||||||
|
DIALOGS = REPO_ROOT / "bal" / "gui" / "qt" / "dialogs.py"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(APP_PYTHON))
|
||||||
|
|
||||||
|
from bal.core import animated_qr as aq # noqa: E402
|
||||||
|
from bal.core import qrtransfer as qtf # noqa: E402
|
||||||
|
|
||||||
|
from balreader import bridge as bridge_codec # noqa: E402
|
||||||
|
from balreader import payload as payload_codec # noqa: E402
|
||||||
|
|
||||||
|
PASSED = 0
|
||||||
|
|
||||||
|
|
||||||
|
def ok(condition, label):
|
||||||
|
global PASSED
|
||||||
|
if not condition:
|
||||||
|
raise AssertionError("FAILED: " + label)
|
||||||
|
PASSED += 1
|
||||||
|
|
||||||
|
|
||||||
|
def check_imported_bundle():
|
||||||
|
for module in (aq, qtf, payload_codec):
|
||||||
|
assert module.__file__ is not None
|
||||||
|
path = str(Path(module.__file__).resolve())
|
||||||
|
assert path.startswith(str(APP_PYTHON)), path
|
||||||
|
ok(True, "all modules imported from the bundled android/ copies")
|
||||||
|
|
||||||
|
|
||||||
|
def find_function(tree, name):
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||||
|
return node
|
||||||
|
raise RuntimeError("{} not found".format(name))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
TXS = ["{:064x}".format(i) for i in range(1, 8)]
|
||||||
|
WILL_ITEMS = {
|
||||||
|
"imp{}".format(i): {
|
||||||
|
"tx": "{:064x}".format(i + 1),
|
||||||
|
"addr": "bc1qdeadbeef{:x}".format(i),
|
||||||
|
"amount": 100000 + i,
|
||||||
|
"tag": "heiress-{}".format(i),
|
||||||
|
"metadata": {},
|
||||||
|
"notify": "mail-{}@example.invalid".format(i),
|
||||||
|
}
|
||||||
|
for i in range(3)
|
||||||
|
}
|
||||||
|
WILL_JSON_COMPACT = json.dumps(WILL_ITEMS, separators=(",", ":"))
|
||||||
|
WILL_JSON_PRETTY = json.dumps(WILL_ITEMS, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parity: app's decode_will_payload vs the plugin's dialogs.py original
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def build_extracted_and_app_function():
|
||||||
|
dialogs_source = DIALOGS.read_text()
|
||||||
|
app_source = Path(payload_codec.__file__).read_text()
|
||||||
|
dialogs_node = find_function(ast.parse(dialogs_source), "decode_will_payload")
|
||||||
|
app_node = find_function(ast.parse(app_source), "decode_will_payload")
|
||||||
|
ok(
|
||||||
|
ast.dump(app_node) == ast.dump(dialogs_node),
|
||||||
|
"decode_will_payload AST identical between app copy and plugin",
|
||||||
|
)
|
||||||
|
namespace = {}
|
||||||
|
exec("import json\nimport re\nfrom typing import Any", namespace)
|
||||||
|
exec(compile(ast.Module(body=[dialogs_node], type_ignores=[]), "dialogs.py", "exec"), namespace)
|
||||||
|
return namespace["decode_will_payload"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_payload_parity_cases():
|
||||||
|
plugin_decode = build_extracted_and_app_function()
|
||||||
|
samples = {
|
||||||
|
"will-compact": WILL_JSON_COMPACT,
|
||||||
|
"will-pretty": WILL_JSON_PRETTY,
|
||||||
|
"txs-newlines": "\n".join(TXS),
|
||||||
|
"txs-comma-crlf": ",\r\n".join(TXS[:3]),
|
||||||
|
"single-tx": TXS[0],
|
||||||
|
"json-array": json.dumps(TXS),
|
||||||
|
"not-json-dict": "hello world",
|
||||||
|
"empty": "",
|
||||||
|
"whitespace": " \n\t ",
|
||||||
|
}
|
||||||
|
for label, text in samples.items():
|
||||||
|
app_result = payload_codec.decode_will_payload(text)
|
||||||
|
plugin_result = plugin_decode(text)
|
||||||
|
ok(
|
||||||
|
app_result == plugin_result,
|
||||||
|
"payload parity for {!r}".format(label),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Full decode chain (the app's BalDecoder.finish())
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def app_chain(transfer_text, compressed):
|
||||||
|
parts = qtf.decode_transfer(transfer_text, compressed)
|
||||||
|
payload = "\n".join(parts)
|
||||||
|
kind, data = payload_codec.decode_will_payload(payload)
|
||||||
|
return parts, payload, kind, data
|
||||||
|
|
||||||
|
|
||||||
|
def feed_frame_set(session, frames, *, drop=None, order=None):
|
||||||
|
indexes = list(range(len(frames)))
|
||||||
|
if drop:
|
||||||
|
indexes = [i for i in indexes if i not in drop]
|
||||||
|
if order is not None:
|
||||||
|
indexes = list(order)
|
||||||
|
for i in indexes:
|
||||||
|
session.add_part(frames[i])
|
||||||
|
|
||||||
|
|
||||||
|
def make_frames(transfer_text, fmt, budget_chars, *, compressed=False):
|
||||||
|
payload = transfer_text.encode("utf-8")
|
||||||
|
if fmt == "balqr":
|
||||||
|
return qtf.split_frames(transfer_text, budget_chars, compressed=compressed)
|
||||||
|
if fmt == "ur1":
|
||||||
|
return aq.ur1_frames(payload, budget_chars)
|
||||||
|
if fmt == "ur2":
|
||||||
|
return aq.ur2_frames(payload, budget_chars)
|
||||||
|
if fmt == "bbqr":
|
||||||
|
return aq.bbqr_frames(payload, budget_chars, encoding="Z")
|
||||||
|
raise AssertionError("unknown format " + fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def run_transport_case(transport, budget_chars, transfer_text, compressed=False):
|
||||||
|
frames = make_frames(transfer_text, transport, budget_chars, compressed=compressed)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
rng = random.Random(len(transfer_text) + len(transport.encode()))
|
||||||
|
order = [i for i in range(len(frames))]
|
||||||
|
rng.shuffle(order)
|
||||||
|
feed_frame_set(session, frames, order=order)
|
||||||
|
ok(session.done, "{} (.{} chars) reaches done in scrambled order".format(transport, budget_chars))
|
||||||
|
if transport == "ur2":
|
||||||
|
# Fountain indexes can range wider than seq_len, so received may
|
||||||
|
# exceed (or fall short of) total; only progress and completion matter.
|
||||||
|
ok(session.total >= 1 and session.received >= 1,
|
||||||
|
"{} reports positive progress".format(transport))
|
||||||
|
else:
|
||||||
|
ok(
|
||||||
|
session.received == session.total,
|
||||||
|
"{} received matches total".format(transport),
|
||||||
|
)
|
||||||
|
ok(
|
||||||
|
session.received == len(frames) and session.total == len(frames),
|
||||||
|
"{} received/total equals frame count".format(transport),
|
||||||
|
)
|
||||||
|
transfer, compressed_flag = session.resolve()
|
||||||
|
ok(transfer == transfer_text, "{} restores exact transfer text".format(transport))
|
||||||
|
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
||||||
|
bridge_json = json.loads(bridge_codec.finish(session))
|
||||||
|
ok(
|
||||||
|
bridge_json == {"kind": kind, "payload": payload, "parts": parts},
|
||||||
|
"{} bridge.finish JSON matches the app chain".format(transport),
|
||||||
|
)
|
||||||
|
return parts, payload, kind, data
|
||||||
|
|
||||||
|
|
||||||
|
def test_tx_transports():
|
||||||
|
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||||
|
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
||||||
|
parts, payload, kind, data = run_transport_case(transport, 400, transfer)
|
||||||
|
ok(kind == "txs", "{} classifies as txs".format(transport))
|
||||||
|
ok(parts == TXS and payload == "\n".join(TXS), "{} yields the tx list".format(transport))
|
||||||
|
|
||||||
|
|
||||||
|
def test_compressed_bal_transport():
|
||||||
|
transfer = qtf.encode_transfer(TXS, compress=True)
|
||||||
|
frames = make_frames(transfer, "balqr", 400, compressed=True)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
feed_frame_set(session, frames)
|
||||||
|
ok(session.done, "compressed BAL QR done")
|
||||||
|
parts, payload, kind, data = app_chain(*session.resolve())
|
||||||
|
ok(kind == "txs" and parts == TXS, "compressed BAL QR yields the tx list")
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_transports():
|
||||||
|
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
||||||
|
parts, payload, kind, data = run_transport_case(transport, 400, WILL_JSON_COMPACT)
|
||||||
|
ok(kind == "will", "{} classifies as will".format(transport))
|
||||||
|
ok(data == WILL_ITEMS, "{} restores the whole-will dict".format(transport))
|
||||||
|
# Pretty JSON works too (blank lines are whitespace for json.loads).
|
||||||
|
parts, payload, kind, data = run_transport_case("ur2", 400, WILL_JSON_PRETTY)
|
||||||
|
ok(kind == "will" and data == WILL_ITEMS, "pretty JSON transport restores the will dict")
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicates_are_ignored():
|
||||||
|
# UR v1 (multi-fragment) dedups by fragment index; UR v2 fountains never
|
||||||
|
# report "dup" (they dedup internally), matching the plugin's behaviour.
|
||||||
|
frames = make_frames(WILL_JSON_COMPACT, "ur1", 200)
|
||||||
|
ok(len(frames) >= 2, "UR v1 yields multiple fragments (got {})".format(len(frames)))
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
for i in range(len(frames)):
|
||||||
|
first = session.add_part(frames[i])
|
||||||
|
dup = session.add_part(frames[i])
|
||||||
|
ok(first == "ok", "fresh UR v1 frame reported as ok")
|
||||||
|
ok(dup == "dup", "duplicate UR v1 frame reported as dup")
|
||||||
|
ok(session.received == len(frames), "duplicates do not inflate received")
|
||||||
|
ok(session.done, "UR v1 completes after duplicates")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ur2_fountain_survives_loss_and_reorder():
|
||||||
|
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||||
|
frames = make_frames(transfer, "ur2", 120)
|
||||||
|
ok(len(frames) >= 6, "fountain yields multiple frames (got {})".format(len(frames)))
|
||||||
|
drop = (0, 2, len(frames) - 1)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
rng = random.Random(99)
|
||||||
|
order = [i for i in range(len(frames)) if i not in drop]
|
||||||
|
rng.shuffle(order)
|
||||||
|
feed_frame_set(session, frames, order=order, drop=drop)
|
||||||
|
ok(session.done, "fountain completes after dropped + reordered frames")
|
||||||
|
transfer, compressed_flag = session.resolve()
|
||||||
|
ok(transfer == transfer, "fountain restores exact transfer text")
|
||||||
|
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
||||||
|
ok(kind == "txs" and parts == TXS, "fountain output feeds the full import tail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ur2_single_part_and_duplicate():
|
||||||
|
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||||
|
frames = make_frames(transfer, "ur2", 2000)
|
||||||
|
ok(len(frames) == 1, "large budget yields a single-part UR v2 frame")
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
session.add_part(frames[0])
|
||||||
|
ok(session.done and session.received == 1, "single-part UR v2 completes")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_all_three_encodings():
|
||||||
|
for encoding in ("Z", "H", "2"):
|
||||||
|
frames = aq.bbqr_frames(WILL_JSON_COMPACT.encode(), 120, encoding=encoding)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
feed_frame_set(session, frames)
|
||||||
|
ok(session.done, "BBQR {} done".format(encoding))
|
||||||
|
transfer, compressed = session.resolve()
|
||||||
|
parts, payload, kind, data = app_chain(transfer, compressed)
|
||||||
|
ok(kind == "will" and data == WILL_ITEMS, "BBQR {} restores the will".format(encoding))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mid_transfer_conflict():
|
||||||
|
ur1 = aq.ur1_frames(b"transfer-one", 400)
|
||||||
|
ur2 = aq.ur2_frames(b"transfer-two", 400)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
session.add_part(ur1[0])
|
||||||
|
try:
|
||||||
|
session.add_part(ur2[0])
|
||||||
|
except aq.TransferConflictError:
|
||||||
|
ok(True, "format switch raises TransferConflictError")
|
||||||
|
else:
|
||||||
|
ok(False, "format switch raised TransferConflictError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_and_single_line():
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
try:
|
||||||
|
session.add_part("this is not a QR transfer")
|
||||||
|
except aq.FormatNotDetectedError:
|
||||||
|
ok(True, "garbage raises FormatNotDetectedError")
|
||||||
|
else:
|
||||||
|
ok(False, "garbage raised FormatNotDetectedError")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
check_imported_bundle()
|
||||||
|
run_payload_parity_cases()
|
||||||
|
test_tx_transports()
|
||||||
|
test_compressed_bal_transport()
|
||||||
|
test_will_transports()
|
||||||
|
test_duplicates_are_ignored()
|
||||||
|
test_ur2_fountain_survives_loss_and_reorder()
|
||||||
|
test_ur2_single_part_and_duplicate()
|
||||||
|
test_bbqr_all_three_encodings()
|
||||||
|
test_mid_transfer_conflict()
|
||||||
|
test_garbage_and_single_line()
|
||||||
|
print("verify_chain: {} checks passed".format(PASSED))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
202
android/tools/emitter.py
Normal file
202
android/tools/emitter.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""Standalone QR emitter for testing the Android reader on a real camera.
|
||||||
|
|
||||||
|
Replicates exactly what the plugin's QR export page puts on screen
|
||||||
|
(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
|
||||||
|
encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
|
||||||
|
|
||||||
|
Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
|
||||||
|
PyQt6):
|
||||||
|
|
||||||
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
|
QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
|
||||||
|
|
||||||
|
Controls:
|
||||||
|
Left/Right previous / next frame
|
||||||
|
Space toggle autoplay
|
||||||
|
L toggle loop (default off)
|
||||||
|
Q / Esc quit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
|
import qrcode # noqa: E402
|
||||||
|
from PyQt6.QtCore import Qt, QTimer # noqa: E402
|
||||||
|
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
|
||||||
|
|
||||||
|
from bal.core import animated_qr as aq # noqa: E402
|
||||||
|
from bal.core import qrtransfer as qtf # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def build_frames(tx_strings, fmt, budget):
|
||||||
|
"""Frames exactly as BalQrExportWidget._refresh_frames produces them."""
|
||||||
|
transfer = qtf.encode_transfer(tx_strings, compress=False)
|
||||||
|
if fmt == "balqr":
|
||||||
|
return qtf.split_frames(transfer, budget, compressed=False)
|
||||||
|
payload = transfer.encode("utf-8")
|
||||||
|
if fmt == "ur1":
|
||||||
|
return aq.ur1_frames(payload, budget)
|
||||||
|
if fmt == "ur2":
|
||||||
|
return aq.ur2_frames(payload, budget)
|
||||||
|
if fmt == "bbqr":
|
||||||
|
return aq.bbqr_frames(payload, budget, encoding="Z")
|
||||||
|
raise SystemExit("unknown format: {}".format(fmt))
|
||||||
|
|
||||||
|
|
||||||
|
def load_payload(args):
|
||||||
|
"""Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
|
||||||
|
if args.will is not None:
|
||||||
|
will = json.loads(Path(args.will).read_text())
|
||||||
|
return (
|
||||||
|
[json.dumps(will, ensure_ascii=False)],
|
||||||
|
"whole-will JSON ({})".format(Path(args.will).name),
|
||||||
|
)
|
||||||
|
if args.txs is not None:
|
||||||
|
raw = Path(args.txs).read_text()
|
||||||
|
return [line.strip() for line in raw.split() if line.strip()], "txs"
|
||||||
|
raise SystemExit("give --will FILE or --txs FILE")
|
||||||
|
|
||||||
|
|
||||||
|
def qr_pixmap(text, size):
|
||||||
|
"""Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
|
||||||
|
qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
||||||
|
qr.add_data(text)
|
||||||
|
qr.make(fit=True)
|
||||||
|
matrix = qr.modules
|
||||||
|
n = len(matrix)
|
||||||
|
border = qr.border
|
||||||
|
scale = max(1, size // (n + 2 * border))
|
||||||
|
dim = (n + 2 * border) * scale
|
||||||
|
image = QImage(dim, dim, QImage.Format.Format_RGB32)
|
||||||
|
image.fill(Qt.GlobalColor.white)
|
||||||
|
painter = QPainter(image)
|
||||||
|
painter.fillRect(0, 0, dim, dim, QColor("white"))
|
||||||
|
painter.setBrush(QColor("black"))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
for y, row in enumerate(matrix):
|
||||||
|
for x, on in enumerate(row):
|
||||||
|
if on:
|
||||||
|
painter.fillRect(
|
||||||
|
(x + border) * scale, (y + border) * scale, scale, scale,
|
||||||
|
QColor("black"),
|
||||||
|
)
|
||||||
|
painter.end()
|
||||||
|
return QPixmap.fromImage(image).scaled(
|
||||||
|
size, size, Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmitterWindow(QMainWindow):
|
||||||
|
def __init__(self, frames, description, fmt, fps, loop):
|
||||||
|
super().__init__()
|
||||||
|
self.frames = frames
|
||||||
|
self.fps = fps
|
||||||
|
self.loop = loop
|
||||||
|
self.index = 0
|
||||||
|
self.autoplay = True
|
||||||
|
|
||||||
|
central = QWidget(self)
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
self.pix = QLabel(central)
|
||||||
|
self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.caption = QLabel(central)
|
||||||
|
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
|
||||||
|
|
||||||
|
v = qt.QVBoxLayout(central)
|
||||||
|
v.addWidget(self.pix, 1)
|
||||||
|
v.addWidget(self.caption)
|
||||||
|
|
||||||
|
self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
|
||||||
|
self.resize(900, 1000)
|
||||||
|
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.timeout.connect(self._step)
|
||||||
|
self.timer.start(int(1000 / self.fps))
|
||||||
|
|
||||||
|
self._render()
|
||||||
|
self.caption.setText(
|
||||||
|
"{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
|
||||||
|
desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
|
||||||
|
auto="on" if self.autoplay else "off", loop="on" if loop else "off",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
|
||||||
|
|
||||||
|
def _update_caption(self):
|
||||||
|
auto = "on" if self.autoplay else "off"
|
||||||
|
loop = "on" if self.loop else "off"
|
||||||
|
self.caption.setText(
|
||||||
|
"frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
|
||||||
|
i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _step(self):
|
||||||
|
if self.index + 1 < len(self.frames):
|
||||||
|
self.index += 1
|
||||||
|
elif self.loop:
|
||||||
|
self.index = 0
|
||||||
|
else:
|
||||||
|
self.autoplay = False
|
||||||
|
self.timer.stop()
|
||||||
|
self._render()
|
||||||
|
self._update_caption()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event): # noqa: N802 - Qt override name
|
||||||
|
key = event.key()
|
||||||
|
if key == Qt.Key.Key_Right:
|
||||||
|
self.autoplay = False
|
||||||
|
self.index = min(self.index + 1, len(self.frames) - 1)
|
||||||
|
self._render()
|
||||||
|
elif key == Qt.Key.Key_Left:
|
||||||
|
self.autoplay = False
|
||||||
|
self.index = max(self.index - 1, 0)
|
||||||
|
self._render()
|
||||||
|
elif key == Qt.Key.Key_Space:
|
||||||
|
self.autoplay = not self.autoplay
|
||||||
|
if self.autoplay:
|
||||||
|
self.timer.start(int(1000 / self.fps))
|
||||||
|
else:
|
||||||
|
self.timer.stop()
|
||||||
|
elif key == Qt.Key.Key_L:
|
||||||
|
self.loop = not self.loop
|
||||||
|
elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
|
||||||
|
self.close()
|
||||||
|
self._update_caption()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
|
||||||
|
default="balqr")
|
||||||
|
parser.add_argument("--budget", type=int, default=400)
|
||||||
|
parser.add_argument("--fps", type=float, default=1.0)
|
||||||
|
parser.add_argument("--loop", action="store_true")
|
||||||
|
parser.add_argument("--will", help="whole-will JSON file")
|
||||||
|
parser.add_argument("--txs", help="file with serialized tx strings")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
tx_strings, description = load_payload(args)
|
||||||
|
frames = build_frames(tx_strings, args.format, args.budget)
|
||||||
|
if len(frames) == 1:
|
||||||
|
print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("{} frames ready".format(len(frames)), file=sys.stderr)
|
||||||
|
|
||||||
|
app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
|
||||||
|
EmitterWindow(frames, description, args.format, args.fps, args.loop)
|
||||||
|
app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
26
android/tools/sample_will.json
Normal file
26
android/tools/sample_will.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"imp-heiress-1": {
|
||||||
|
"tx": "0f1e2d3c4b5a69788796a5b4c3d2e1f0112233445566778899aabbccddeeff00",
|
||||||
|
"addr": "bc1qdeadbeef0",
|
||||||
|
"amount": 100000,
|
||||||
|
"tag": "heiress-1",
|
||||||
|
"metadata": {},
|
||||||
|
"notify": "heir1@example.invalid"
|
||||||
|
},
|
||||||
|
"imp-heiress-2": {
|
||||||
|
"tx": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
|
||||||
|
"addr": "bc1qdeadbeef1",
|
||||||
|
"amount": 200000,
|
||||||
|
"tag": "heiress-2",
|
||||||
|
"metadata": {},
|
||||||
|
"notify": "heir2@example.invalid"
|
||||||
|
},
|
||||||
|
"imp-heiress-3": {
|
||||||
|
"tx": "a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809",
|
||||||
|
"addr": "bc1qdeadbeef2",
|
||||||
|
"amount": 300000,
|
||||||
|
"tag": "heiress-3",
|
||||||
|
"metadata": {},
|
||||||
|
"notify": "heir3@example.invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user