Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ docs/
dev/deploy
dev/deploy.*
target/
.cargo/
AGENTS.md
CLAUDE.md
9 changes: 9 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
28 changes: 28 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- humd for Android: the native Rust daemon, hosted by a foreground
service. WiFi Direct (WifiP2pManager) is the radio underlay for
ad-hoc peer discovery; the daemon's ensemble (iroh QUIC + relay)
is the mesh overlay that reaches other humds. -->
<uses-permission android:name="android.permission.NEARBY_WIFI_PEERS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />

<uses-feature android:name="android.hardware.wifi.direct" android:required="false" />

<application
android:label="@string/app_name"
android:theme="@android:style/Theme.Material.Light"
android:allowBackup="false">

<service
android:name=".HumdService"
android:exported="false"
android:foregroundServiceType="dataSync" />

</application>
</manifest>
208 changes: 208 additions & 0 deletions android/app/src/main/java/hum/daemon/HumdService.kt
Original file line number Diff line number Diff line change
@@ -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")
}
Loading