diff --git a/android/app/src/main/java/com/suchsoftware/hashwallet/EmbeddedNode.java b/android/app/src/main/java/com/suchsoftware/hashwallet/EmbeddedNode.java new file mode 100644 index 000000000..666997405 --- /dev/null +++ b/android/app/src/main/java/com/suchsoftware/hashwallet/EmbeddedNode.java @@ -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 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 "/ (%" 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 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(); + } + } +} diff --git a/android/app/src/main/java/com/suchsoftware/hashwallet/MainActivity.java b/android/app/src/main/java/com/suchsoftware/hashwallet/MainActivity.java index b89257a3b..57d5a0c1f 100644 --- a/android/app/src/main/java/com/suchsoftware/hashwallet/MainActivity.java +++ b/android/app/src/main/java/com/suchsoftware/hashwallet/MainActivity.java @@ -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 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) { diff --git a/android/app/src/main/jniLibs/.gitignore b/android/app/src/main/jniLibs/.gitignore index af6e91061..e4a50eb2f 100644 --- a/android/app/src/main/jniLibs/.gitignore +++ b/android/app/src/main/jniLibs/.gitignore @@ -1 +1,4 @@ lib*_wallet2_api_c.so + +# embedded Wownero node daemon (provisioned at build time, like the wallet libs) +libwownerod.so diff --git a/lib/entities/embedded_node.dart b/lib/entities/embedded_node.dart new file mode 100644 index 000000000..28054930c --- /dev/null +++ b/lib/entities/embedded_node.dart @@ -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 address() async { + final port = supported ? (await _channel.invokeMethod('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 start({bool pruned = true}) async { + if (!supported) return false; + return await _channel.invokeMethod('start', {'pruned': pruned}) ?? false; + } + + static Future stop() async { + if (!supported) return; + await _channel.invokeMethod('stop'); + } + + static Future status() async { + if (!supported) return const EmbeddedNodeStatus.stopped(); + final m = await _channel.invokeMapMethod('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 logs() async { + if (!supported) return ''; + return await _channel.invokeMethod('logs') ?? ''; + } + + /// Poll status until the RPC is reachable (or [timeout]); lets a caller wait + /// before pointing the wallet at the local node. + static Future 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.delayed(const Duration(seconds: 2)); + } + return false; + } + + static Future _rpcReachable() async { + try { + final port = supported ? (await _channel.invokeMethod('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; +}