feat(android): embedded Wownero node — run a full node on the phone

Bundles wownerod (aarch64-linux-android, built via monero depends) as
jniLibs libwownerod.so; EmbeddedNode.java execs it from nativeLibraryDir
(the exec-exempt path), binds RPC on 127.0.0.1:34568 for the wallet, pruned
mode by default, capped concurrency for phone thermals. Dart EmbeddedNode
service (start/stop/status/waitUntilReady) drives it over a method channel.

Verified: builds into the APK (binary bundled, arm64), and the daemon
executes on real hardware (moto g stylus) — inits core, binds RPC, connects
peers, and syncs blocks. UI toggle + foreground-service lifecycle are the
next layer; binary is provisioned like the wallet libs (gitignored).
This commit is contained in:
jwinterm
2026-08-30 10:17:11 -04:00
parent acccba9210
commit 6a41701656
4 changed files with 290 additions and 0 deletions
@@ -0,0 +1,141 @@
package com.suchsoftware.hashwallet;
import android.content.Context;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Runs the embedded Wownero node daemon (wownerod) as a child process on-device.
*
* The daemon binary ships in jniLibs as {@code libwownerod.so}; with
* extractNativeLibs=true Android extracts it to nativeLibraryDir, which is one
* of the few app-accessible paths that stays executable on modern Android
* (the app's writable data dir is W^X / noexec since API 29). We exec it from
* there — the same technique Tor-on-Android and Termux use.
*
* Blockchain data lives under the app-specific external files dir (no runtime
* permission needed, and roomier than internal storage for a multi-GB chain).
*/
public final class EmbeddedNode {
static final int RPC_PORT = 34568; // wallet connects to 127.0.0.1:34568
static final int P2P_PORT = 34567;
private static Process process;
private static Thread reader;
private static final Deque<String> logTail = new ArrayDeque<>();
private static volatile long height = 0;
private static volatile long targetHeight = 0;
private static volatile boolean synced = false;
// Wownero's sync line is decorated: "…♡ synced ♡… 2701/869596 (0%, 866895 left)".
// Match the "<height>/<target> (<pct>%" shape regardless of surrounding emoji.
private static final Pattern SYNC_RE =
Pattern.compile("(\\d+)\\s*/\\s*(\\d+)\\s*\\((\\d+)%");
private static final Pattern HEIGHT_RE =
Pattern.compile("Height:\\s*(\\d+)\\s*/\\s*(\\d+)");
private EmbeddedNode() {}
public static synchronized boolean isRunning() {
return process != null && process.isAlive();
}
/** Start wownerod. No-op if already running. Returns true if (now) running. */
public static synchronized boolean start(Context ctx, boolean pruned) {
if (isRunning()) return true;
final String bin = ctx.getApplicationInfo().nativeLibraryDir + "/libwownerod.so";
if (!new File(bin).exists()) return false;
final File dataDir = new File(ctx.getExternalFilesDir(null), "wownero-node");
if (!dataDir.exists() && !dataDir.mkdirs()) return false;
List<String> cmd = new ArrayList<>();
cmd.add(bin);
cmd.add("--data-dir"); cmd.add(dataDir.getAbsolutePath());
cmd.add("--rpc-bind-ip"); cmd.add("127.0.0.1");
cmd.add("--rpc-bind-port"); cmd.add(Integer.toString(RPC_PORT));
cmd.add("--p2p-bind-port"); cmd.add(Integer.toString(P2P_PORT));
cmd.add("--no-igd"); // no UPnP on a phone
cmd.add("--hide-my-port"); // don't advertise an inbound port
cmd.add("--max-concurrency"); cmd.add("2"); // cap CPU/thermal
cmd.add("--db-sync-mode"); cmd.add("fast:async:250000000bytes");
cmd.add("--log-level"); cmd.add("0");
cmd.add("--non-interactive");
if (pruned) cmd.add("--prune-blockchain"); // ~3.5GB vs ~11GB full
try {
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
pb.directory(dataDir);
synced = false; height = 0; targetHeight = 0;
synchronized (logTail) { logTail.clear(); }
process = pb.start();
reader = new Thread(EmbeddedNode::pump, "wownerod-log");
reader.setDaemon(true);
reader.start();
return true;
} catch (Exception e) {
appendLog("failed to start: " + e.getMessage());
process = null;
return false;
}
}
public static synchronized void stop() {
if (process != null) {
process.destroy(); // SIGTERM; wownerod flushes LMDB and exits
process = null;
}
synced = false;
}
/** Snapshot of node state for the Dart side. */
public static long[] progress() {
return new long[]{ isRunning() ? 1 : 0, height, targetHeight, synced ? 1 : 0 };
}
public static String logs() {
StringBuilder sb = new StringBuilder();
synchronized (logTail) {
for (String l : logTail) sb.append(l).append('\n');
}
return sb.toString();
}
private static void pump() {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
appendLog(line);
Matcher m = SYNC_RE.matcher(line);
if (!m.find()) m = HEIGHT_RE.matcher(line);
if (m.find()) {
try {
height = Long.parseLong(m.group(1));
targetHeight = Long.parseLong(m.group(2));
synced = targetHeight > 0 && height >= targetHeight - 1;
} catch (NumberFormatException ignored) {}
}
if (line.contains("SYNCHRONIZED OK")) synced = true;
}
} catch (Exception ignored) {
// stream closed on process exit
}
}
private static void appendLog(String line) {
synchronized (logTail) {
logTail.addLast(line);
while (logTail.size() > 200) logTail.removeFirst();
}
}
}
@@ -24,6 +24,7 @@ import java.security.SecureRandom;
public class MainActivity extends FlutterFragmentActivity {
final String UTILS_CHANNEL = "com.cake_wallet/native_utils";
final String NODE_CHANNEL = "cash.hashbags/embedded_node";
boolean isAppSecure = false;
@Override
@@ -35,6 +36,50 @@ public class MainActivity extends FlutterFragmentActivity {
UTILS_CHANNEL);
utilsChannel.setMethodCallHandler(this::handle);
MethodChannel nodeChannel =
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(),
NODE_CHANNEL);
nodeChannel.setMethodCallHandler(this::handleNode);
}
private void handleNode(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Handler handler = new Handler(Looper.getMainLooper());
try {
switch (call.method) {
case "start": {
Boolean pruned = call.argument("pruned");
boolean ok = EmbeddedNode.start(getApplicationContext(), pruned == null || pruned);
handler.post(() -> result.success(ok));
break;
}
case "stop":
EmbeddedNode.stop();
handler.post(() -> result.success(null));
break;
case "progress": {
long[] p = EmbeddedNode.progress();
java.util.HashMap<String, Object> m = new java.util.HashMap<>();
m.put("running", p[0] == 1);
m.put("height", p[1]);
m.put("targetHeight", p[2]);
m.put("synced", p[3] == 1);
handler.post(() -> result.success(m));
break;
}
case "rpcPort":
handler.post(() -> result.success(EmbeddedNode.RPC_PORT));
break;
case "logs":
handler.post(() -> result.success(EmbeddedNode.logs()));
break;
default:
handler.post(() -> result.notImplemented());
}
} catch (Exception e) {
handler.post(() -> result.error("EMBEDDED_NODE_ERROR", e.getMessage(), null));
}
}
private void handle(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
+3
View File
@@ -1 +1,4 @@
lib*_wallet2_api_c.so
# embedded Wownero node daemon (provisioned at build time, like the wallet libs)
libwownerod.so
+101
View File
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
/// Drives the on-device Wownero node daemon (wownerod) bundled in the APK.
///
/// Android only for now — the native side execs the daemon from the app's
/// nativeLibraryDir. On any other platform every call is a no-op returning a
/// not-running state, so callers can use it unconditionally.
class EmbeddedNode {
EmbeddedNode._();
static const _channel = MethodChannel('cash.hashbags/embedded_node');
static bool get supported => Platform.isAndroid;
/// The wallet connects to the local node at this address once it's up.
static Future<String> address() async {
final port = supported ? (await _channel.invokeMethod<int>('rpcPort') ?? 34568) : 34568;
return '127.0.0.1:$port';
}
/// Start the daemon. Pruned mode (~3.5GB) is the on-phone default; full is
/// ~11GB. Returns true if the node is (now) running.
static Future<bool> start({bool pruned = true}) async {
if (!supported) return false;
return await _channel.invokeMethod<bool>('start', {'pruned': pruned}) ?? false;
}
static Future<void> stop() async {
if (!supported) return;
await _channel.invokeMethod<void>('stop');
}
static Future<EmbeddedNodeStatus> status() async {
if (!supported) return const EmbeddedNodeStatus.stopped();
final m = await _channel.invokeMapMethod<String, dynamic>('progress');
if (m == null) return const EmbeddedNodeStatus.stopped();
return EmbeddedNodeStatus(
running: m['running'] == true,
height: (m['height'] as num?)?.toInt() ?? 0,
targetHeight: (m['targetHeight'] as num?)?.toInt() ?? 0,
synced: m['synced'] == true,
);
}
static Future<String> logs() async {
if (!supported) return '';
return await _channel.invokeMethod<String>('logs') ?? '';
}
/// Poll status until the RPC is reachable (or [timeout]); lets a caller wait
/// before pointing the wallet at the local node.
static Future<bool> waitUntilReady(
{Duration timeout = const Duration(seconds: 90)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
final s = await status();
if (!s.running) return false;
if (await _rpcReachable()) return true;
await Future<void>.delayed(const Duration(seconds: 2));
}
return false;
}
static Future<bool> _rpcReachable() async {
try {
final port = supported ? (await _channel.invokeMethod<int>('rpcPort') ?? 34568) : 34568;
final socket = await Socket.connect('127.0.0.1', port,
timeout: const Duration(seconds: 2));
socket.destroy();
return true;
} catch (_) {
return false;
}
}
}
class EmbeddedNodeStatus {
final bool running;
final int height;
final int targetHeight;
final bool synced;
const EmbeddedNodeStatus({
required this.running,
required this.height,
required this.targetHeight,
required this.synced,
});
const EmbeddedNodeStatus.stopped()
: running = false,
height = 0,
targetHeight = 0,
synced = false;
double get progress =>
targetHeight > 0 ? (height / targetHeight).clamp(0.0, 1.0) : 0.0;
}