diff --git a/.gitignore b/.gitignore index 56aa4d7..c514483 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ docs/ dev/deploy dev/deploy.* target/ +.cargo/ AGENTS.md CLAUDE.md diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..4cc5607 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +# cross-compiled humd binary — built by android/scripts/build-humd.sh +app/src/main/assets/humd + +# gradle / android build outputs +app/build/ +build/ +.cargo/ +*.apk +*.log diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..fd70fc1 --- /dev/null +++ b/android/README.md @@ -0,0 +1,82 @@ +--- +title: "android-humd" +description: "humd for Android — the native Rust daemon, cross-compiled and hosted by a foreground service, with WiFi Direct as the radio underlay" +--- + +# android-humd + +> _humd for Android — the actual daemon, not a thin client._ + +This is **humd itself compiled for Android**: the full Rust workspace +(`humd` + `ensemble` + `config` + `thrum-core` + `hum-paths` + …) +cross-compiled to an `aarch64-linux-android` PIE ELF and hosted by a +native foreground service. Rust was chosen because it runs everywhere — +so the portability problem is *build* engineering, not a rewrite: the +daemon boots unchanged, hum-paths resolves everything from XDG env +vars, and the host points those at app-private storage. + +No Termux, no root, no JNI. The binary ships as an asset and is +`exec`'d by the service. + +## Why this exists + +The ensemble is the mesh of cooperating humds. To bring a phone into +it without Termux, the phone must *run* humd, not just dial one. This +module is that port. Two layers stack: + +- **Overlay — the ensemble mesh**: iroh (QUIC + Noise + relay + hole-punching) reaches the machine's humd over the internet / relay, + no WiFi Direct needed. This is the "thrum is the end of TCP" path. +- **Underlay — WiFi Direct**: `WifiP2pManager` gives phones an ad-hoc + L2 link (p2p0) without a router or internet — the phone↔phone + meetup case. The native daemon's iroh runs on p2p0 when a group forms. + +## Layout + +``` +android/ + scripts/build-humd.sh NDK cross-compile of the workspace humd → assets + app/ Android app (foreground service host) + src/main/java/hum/daemon/ + HumdService.kt spawns + supervises the native humd, writes config + WifiDirectManager.kt radio underlay (WifiP2pManager discovery + groups) +``` + +## Build + +Prereqs: `rustup target add aarch64-linux-android`, an Android NDK +(`brew install --cask android-commandlinetools`, then +`sdkmanager "ndk;27d"`, or set `ANDROID_NDK_HOME`). + +```sh +# cross-compile humd and bundle it into the app +./android/scripts/build-humd.sh + +# then assemble the APK from android/ +(cd android && ./gradlew :app:assembleDebug) +``` + +## Runtime + +`HumdService` (a foreground `dataSync` service) extracts the bundled +binary, writes a minimal `hum.json` (all sections default) plus an +optional `peers.json` from `-Dhum.peerHint=humd_id,iroh:nodeid` so the +phone daemon dials the machine on boot, points XDG_* at app-private +storage, and `exec`s the daemon — supervising it and logging to +`filesDir/humd.log`. + +The phone then participates in the ensemble exactly as any humd: signed +hello, peer registry, kad, gossip — and routes prompts to whatever +worker bees register (e.g. the machine's `ollama-worker`). + +## Propensity + +| statefulness | richness | wire shape | hides | +|---|---|---|---| +| convention-stateful | lean | ensemble (iroh QUIC) + thrum (Unix socket) | tools, drone, breath | + +## See also + +- `hives/wifi-p2p` — the radio-underlay prototype (WiFi Direct forager bee) +- `ensemble/` — the mesh layer this daemon runs +- `hum-paths/` — XDG resolution that makes the port a build problem, not a code problem diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..f862726 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "hum.daemon" + compileSdk = 34 + + defaultConfig { + applicationId = "hum.daemon" + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "0.32.0" + } + + // The native humd is a PIE ELF cross-compiled from the Rust workspace + // (see android/scripts/build-humd.sh) and bundled as a plain asset. + // The foreground service extracts it to filesDir and execs it — no + // Termux, no root, no JNI. Keep it out of source control. + sourceSets["main"].assets.srcDirs("src/main/assets") + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation("androidx.core:core:1.13.1") + implementation("androidx.lifecycle:lifecycle-service:2.8.4") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..12e61a1 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/hum/daemon/HumdService.kt b/android/app/src/main/java/hum/daemon/HumdService.kt new file mode 100644 index 0000000..01968e8 --- /dev/null +++ b/android/app/src/main/java/hum/daemon/HumdService.kt @@ -0,0 +1,208 @@ +package hum.daemon + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Environment +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Hosts the native hum daemon for Android. + * + * "humd for Android" is the actual Rust `humd` from the workspace, + * cross-compiled to a PIE ELF (see android/scripts/build-humd.sh) and + * bundled as an asset. This service is the host: it extracts the binary, + * points every XDG path at app-private storage, writes a minimal + * hum.json (+ optional peers.json with the companion machine's iroh + * hint), and spawns the daemon as a supervised child process. No + * Termux, no root, no JNI — the daemon is a plain exec. + * + * humd's boot is entirely XDG-driven (hum-paths), so the Android port + * needs zero Rust changes. The ensemble mesh (iroh QUIC + relay) is + * the overlay that reaches the machine's humd; WifiDirectManager is + * the radio underlay for ad-hoc peer discovery. + */ +class HumdService : Service() { + companion object { + const val TAG = "humd.android" + const val CHANNEL = "humd" + private const val BIN_ASSET = "humd" + } + + private var proc: Process? = null + private val alive = AtomicBoolean(true) + private var wifi: WifiDirectManager? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel( + NotificationChannel(CHANNEL, "humd", NotificationManager.IMPORTANCE_LOW) + ) + startForegroundCompat() + ensureBinary() + writeConfig() + startHumd() + // Radio underlay for ad-hoc phone↔phone discovery; the ensemble + // overlay (iroh relay) needs no WiFi Direct to reach the machine. + wifi = WifiDirectManager(this).also { + it.onGroupFormed = { isGO, ip -> + Log.i(TAG, "p2p link up: go=$isGO ip=$ip") + } + if (it.start()) Log.i(TAG, "wifi-direct underlay started") + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Restart if the OS kills us; the daemon process is supervised by + // the service and does not die with it. + startForegroundCompat() + if (proc == null || !proc!!.isAlive) startHumd() + return START_STICKY + } + + override fun onDestroy() { + alive.set(false) + wifi?.stop() + proc?.destroy() + proc?.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) + super.onDestroy() + } + + // ── foreground surface ───────────────────────────────────────────────── + private fun startForegroundCompat() { + val intent = Intent(this, HumdService::class.java) + val pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val n = NotificationCompat.Builder(this, CHANNEL) + .setContentTitle(getString(R.string.notif_title)) + .setContentText(getString(R.string.notif_text)) + .setSmallIcon(android.R.drawable.stat_sys_data_paused) + .setContentIntent(pi) + .setOngoing(true) + .build() + if (android.os.Build.VERSION.SDK_INT >= 34) { + startForeground(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(1, n) + } + } + + // ── binary ───────────────────────────────────────────────────────────── + private fun ensureBinary() { + val files = filesDir + val dest = File(files, BIN_ASSET) + // Idempotent: re-extract only when the bundled asset is bigger + // than what we already placed (i.e. a fresh build shipped a new + // binary). Otherwise reuse the installed copy. + val bundled = assets.open(BIN_ASSET).use { it.available() } + if (dest.exists() && dest.length() >= bundled && dest.length() > 0) { + dest.setExecutable(true, true) + Log.i(TAG, "humd binary already bundled (${dest.length()} bytes)") + return + } + assets.open(BIN_ASSET).use { input -> + FileOutputStream(dest).use { output -> input.copyTo(output) } + } + dest.setExecutable(true, true) + Log.i(TAG, "extracted humd binary -> ${dest.absolutePath} (${dest.length()} bytes)") + } + + // ── config ───────────────────────────────────────────────────────────── + /** Write a minimal valid hum.json (all sections default) plus, if a + * companion machine's iroh hint is provided, peers.json so the phone + * daemon dials it on boot. */ + private fun writeConfig() { + val cfg = File(configDir(), "hum").apply { mkdirs() } + val humJson = File(cfg, "hum.json") + if (!humJson.exists()) { + humJson.writeText( + """ + { + "humd": { "permissionDuskMs": 60000, "driftRetentionDays": 7 }, + "fs": { "roots": [ { "path": "~/code", "mode": "rw" } ], "denied": [] }, + "nest": { "maxActiveCells": 1, "cellIdlePruneThresholdMs": 300000, "default": "" } + } + """.trimIndent() + ) + } + + // Optional bootstrap: the companion machine's humd id + iroh hint. + val peerHint = System.getProperty("hum.peerHint") // "humd_id,iroh:nodeid" + if (peerHint != null) { + val (id, hint) = peerHint.split(",", limit = 2) + val peers = File(cfg, "peers.json") + if (!peers.exists()) { + peers.writeText( + """{"peers":[{"humd_id":"$id","hints":["$hint"]}]}""" + ) + } + } + } + + // ── daemon ───────────────────────────────────────────────────────────── + private fun startHumd() { + val files = filesDir + val bin = File(files, BIN_ASSET) + if (!bin.exists()) { + Log.e(TAG, "humd binary missing — run android/scripts/build-humd.sh") + return + } + + val pb = ProcessBuilder(bin.absolutePath) + pb.directory(files) + pb.redirectErrorStream(true) + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(File(files, "humd.log"))) + + // hum-paths resolves everything from XDG env vars; pin them to + // app-private storage so the daemon's key/socket/config stay inside. + val env = pb.environment() + env["HOME"] = files.absolutePath + env["XDG_STATE_HOME"] = File(files, "state").absolutePath + env["XDG_CONFIG_HOME"] = File(files, "config").absolutePath + env["XDG_DATA_HOME"] = File(files, "data").absolutePath + env["XDG_CACHE_HOME"] = File(files, "cache").absolutePath + env["XDG_RUNTIME_DIR"] = File(files, "runtime").absolutePath + env["HUM_LOG_LEVEL"] = "info" + + try { + proc = pb.start() + Log.i(TAG, "humd spawned pid=${proc!!.pid()}") + } catch (e: Exception) { + Log.e(TAG, "spawn failed: ${e.message}") + return + } + + // Supervise: if the daemon dies unexpectedly, log and respawn + // (bounded) unless the service itself is tearing down. + Thread { + while (alive.get()) { + try { + proc!!.waitFor() + if (alive.get()) { + Log.w(TAG, "humd exited rc=${proc!!.exitValue()}; respawning") + Thread.sleep(2000) + startHumd() + } + break + } catch (e: InterruptedException) { + break + } + } + }.apply { isDaemon = true; start() } + } + + // ── path helpers ─────────────────────────────────────────────────────── + private fun configDir() = File(filesDir, "config") +} diff --git a/android/app/src/main/java/hum/daemon/WifiDirectManager.kt b/android/app/src/main/java/hum/daemon/WifiDirectManager.kt new file mode 100644 index 0000000..53af19d --- /dev/null +++ b/android/app/src/main/java/hum/daemon/WifiDirectManager.kt @@ -0,0 +1,119 @@ +package hum.daemon + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pDeviceList +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager +import android.os.Build +import android.util.Log + +/** + * WiFi Direct radio underlay for the native humd. + * + * The ensemble mesh (iroh QUIC + relay) is the *overlay* that reaches + * other humds — it works over any interface, including p2p0. WiFi + * Direct is the *underlay* that gives phones an ad-hoc L2 link without + * a router or internet: discovery, group formation, and the p2p0 IP. + * + * When a group forms, [onGroupFormed] hands the p2p0 address up to the + * host so the daemon can reach phone-to-phone peers directly (the + * meetup case). For phone-to-machine, iroh's relay + hole-punching + * needs no WiFi Direct at all. + */ +class WifiDirectManager(private val context: Context) { + companion object { + const val TAG = "humd.wifidirect" + } + + var onPeerFound: (WifiP2pDevice) -> Unit = { } + var onGroupFormed: (isGroupOwner: Boolean, p2pIp: String?) -> Unit = { _, _ -> } + + private var manager: WifiP2pManager? = null + private var channel: WifiP2pManager.Channel? = null + private var receiverRegistered = false + + fun start(): Boolean { + manager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager + ?: run { Log.w(TAG, "no WifiP2p service on this device"); return false } + channel = manager?.initialize(context, context.mainLooper, null) + ?: return false + + context.registerReceiver( + receiver, + IntentFilter().apply { + addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) + addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION) + addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) + }, + Context.RECEIVER_EXPORTED + ) + receiverRegistered = true + Log.i(TAG, "wifi-direct underlay up") + discover() + return true + } + + fun stop() { + manager?.stopPeerDiscovery(channel, null) + if (receiverRegistered) { + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + receiverRegistered = false + } + } + + private fun discover() { + val m = manager ?: return + val c = channel ?: return + m.discoverPeers(c, object : WifiP2pManager.ActionListener { + override fun onSuccess() = Log.i(TAG, "p2p discovery started") + override fun onFailure(reason: Int) = Log.w(TAG, "p2p discovery failed reason=$reason") + }) + } + + fun connectTo(device: WifiP2pDevice) { + val m = manager ?: return + val c = channel ?: return + val cfg = WifiP2pManager.WifiP2pConfig().apply { + deviceAddress = device.deviceAddress + } + m.connect(c, cfg, object : WifiP2pManager.ActionListener { + override fun onSuccess() = Log.i(TAG, "p2p connect initiated -> ${device.deviceName}") + override fun onFailure(reason: Int) = Log.w(TAG, "p2p connect failed reason=$reason") + }) + } + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.action) { + WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> { + val peers = intent.getParcelableExtra( + WifiP2pManager.EXTRA_WIFI_P2P_DEVICE_LIST + ) ?: return + for (d in peers.deviceList) { + Log.i(TAG, "found peer ${d.deviceName} @ ${d.deviceAddress}") + onPeerFound(d) + } + } + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { + val info = intent.getParcelableExtra( + WifiP2pManager.EXTRA_WIFI_P2P_INFO + ) ?: return + val ip = info.groupOwnerAddress?.hostAddress + Log.i(TAG, "group formed: go=${info.isGroupOwner} ip=$ip") + onGroupFormed(info.isGroupOwner, ip) + } + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { + val on = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) == + WifiP2pManager.WIFI_P2P_STATE_ENABLED + Log.i(TAG, "wifi-p2p radio enabled=$on") + if (on) discover() + } + } + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..eccb45d --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + humd · android + humd running + native hum daemon · ensemble mesh active + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..3c5e113 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/android/scripts/build-humd.sh b/android/scripts/build-humd.sh new file mode 100755 index 0000000..178e4a4 --- /dev/null +++ b/android/scripts/build-humd.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Cross-compile the workspace `humd` daemon for aarch64-linux-android. +# +# "humd for Android" — the actual Rust daemon, built for the Android +# target (PIE ELF), not a thin thrum client. This is the ensemble +# portability problem space: the Rust workspace cross-compiles, the +# NDK supplies the C toolchain (aws-lc / ring / quinn crypto) and the +# Bionic linker. The resulting binary is copied into the Android app +# as a bundled asset so the foreground service can exec it with no +# Termux, no root. +# +# Prereqs: +# rustup target add aarch64-linux-android +# Android NDK (r27d) installed. Resolution order: +# $ANDROID_NDK_HOME / $ANDROID_NDK_ROOT +# ~/Library/Android/sdk/ndk/ +# /opt/android-ndk +# +# Usage: +# ./android/scripts/build-humd.sh [--debug] +set -euo pipefail +cd "$(dirname "$0")/../.." # repo root + +TARGET=aarch64-linux-android +API=${ANDROID_API:-21} + +# ── locate NDK ─────────────────────────────────────────────────────────────── +ndk="" +for cand in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}" \ + "$HOME/Library/Android/sdk/ndk"/android-ndk-* \ + "$HOME/Library/Android/sdk/ndk"/r* \ + /opt/android-ndk; do + if [ -n "$cand" ] && [ -d "$cand/toolchains/llvm/prebuilt" ]; then + ndk="$cand"; break + fi +done +if [ -z "$ndk" ]; then + echo "build-humd: no NDK found. Install it (brew install --cask android-commandlinetools; sdkmanager 'ndk;27d') or set ANDROID_NDK_HOME." >&2 + exit 2 +fi + +# prebuilt host triplet: darwin-x86_64 on macOS Intel, darwin-aarch64 on AS +host="$(ls "$ndk/toolchains/llvm/prebuilt" | head -1)" +tc="$ndk/toolchains/llvm/prebuilt/$host/bin" +clang="$tc/${TARGET}${API}-clang" +if [ ! -x "$clang" ]; then + echo "build-humd: toolchain missing $clang" >&2 + exit 2 +fi + +echo "ndk: $ndk" +echo "target: $TARGET (API $API)" +echo "clang: $clang" + +# ── cargo linker + C compiler wiring ───────────────────────────────────────── +# The workspace's C deps (aws-lc-sys, ring) need a cross clang; the final +# link needs the Bionic linker. We write a repo-local .cargo/config.toml +# (gitignored) so `cargo build --target aarch64-linux-android` just works. +mkdir -p .cargo +cat > .cargo/config.toml < `-`), so use CC_aarch64_linux_android etc. +export "CC_${TARGET//-/_}"="$clang" +export "AR_${TARGET//-/_}"="$tc/llvm-ar" + +# ── build ──────────────────────────────────────────────────────────────────── +mode="release" +[ "${1:-}" = "--debug" ] && mode="debug" +echo "building humd ($mode)..." +cargo build -p humd --target "$TARGET" ${mode/release/--release} + +bin="target/$TARGET/${mode/release/release}/humd" +out="android/app/src/main/assets/humd" +mkdir -p "$(dirname "$out")" +cp -f "$bin" "$out" +chmod 755 "$out" + +echo "→ bundled to $out ($(du -h "$out" | cut -f1))" +file "$out" +echo "build-humd: done" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b78c16d --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "humd-android" +include(":app")