mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-20 06:08:34 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
lib*_wallet2_api_c.so
|
||||
|
||||
# embedded Wownero node daemon (provisioned at build time, like the wallet libs)
|
||||
libwownerod.so
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user