| … | … | @@ -0,0 +1,8 @@ |
| 1 | build/ | |
| 2 | .gradle/ | |
| 3 | *.apk | |
| 4 | ||
| 5 | # Large runtime-host assets are staged at build time, never committed. | |
| 6 | # See README.md "Staging the runtime host". | |
| 7 | app/src/main/assets/proot | |
| 8 | app/src/main/assets/rootfs.tar |
| … | … | @@ -0,0 +1,99 @@ |
| 1 | # celltron-node | |
| 2 | ||
| 3 | A native Android host APK that runs the **pgh / bare P2P stack** on a locked, | |
| 4 | no-root Android-7 phone (the ZenFone AR / celltron device) — replacing the | |
| 5 | Termux + Termux:Boot + adb-pushed-scripts scaffolding with a single installable | |
| 6 | app whose foreground service anchors the whole proot tree. | |
| 7 | ||
| 8 | This is **step 1 of 5**: project skeleton + first-run extractor + proot launcher | |
| 9 | that boots `pgh-sidecar`. It does *not* yet include the GUI (WebView over pgh) | |
| 10 | or identity migration. | |
| 11 | ||
| 12 | ## Why an APK instead of Termux | |
| 13 | ||
| 14 | The APK does **not** change the substrate — proot + glibc (Bionic wall), abstract | |
| 15 | sockets (SELinux pathname-UDS block), and copy-fallback (SELinux hardlink block) | |
| 16 | all remain. What it replaces is the *scaffolding*: | |
| 17 | ||
| 18 | | Termux setup | This APK | | |
| 19 | |---|---| | |
| 20 | | adb push + relocate + /sdcard bridge + run-as | one APK install; extractor writes in place | | |
| 21 | | tar-relocation breaks rocksdb CORESTORE markers | nothing is moved after creation — no breakage | | |
| 22 | | Termux:Boot debug APKs (matched signing) | own `BootReceiver` | | |
| 23 | | `supervise.sh` + no-setsid reaping trap | foreground service is the anchor (never setsid) | | |
| 24 | | adb needed once to write boot hooks | zero adb | | |
| 25 | | on-device `apt`/`dpkg-deb` for runtime libs | libs baked into `rootfs.tar` | | |
| 26 | ||
| 27 | The `net.celltron.mdns` app already proved this shell (foreground service + | |
| 28 | BootReceiver + wake) works on this exact phone. | |
| 29 | ||
| 30 | ## Architecture (step 1) | |
| 31 | ||
| 32 | ``` | |
| 33 | PghNodeService (foreground, wakelock, START_STICKY) <- the anchor | |
| 34 | └─ worker thread | |
| 35 | ├─ Extractor.ensure() assets -> filesDir (idempotent, version-stamped) | |
| 36 | └─ ProotLauncher.superviseForever() | |
| 37 | └─ ProcessBuilder: proot -0 -r rootfs ... env -i ... bare pgh-sidecar | |
| 38 | (child of the service — NOT detached, so it isn't reaped) | |
| 39 | ``` | |
| 40 | ||
| 41 | - `Extractor.kt` — copies `proot` + `tar` binaries (chmod 0755) and unpacks | |
| 42 | `rootfs.tar` into `filesDir/rootfs`, once, guarded by `.staged-host.v1`. | |
| 43 | - `ProotLauncher.kt` — mirrors the proven `celltron.sh` invocation; sets | |
| 44 | `PROOT_TMP_DIR` on proot's own env and `PGH_SIDECAR_SOCK=@pgh-node` (abstract | |
| 45 | socket) inside the guest `env -i` set; restart-with-backoff supervisor. | |
| 46 | - `PghNodeService.kt` — foreground service + partial wakelock; owns the tree. | |
| 47 | - `BootReceiver.kt` / `MainActivity.kt` — boot autostart + a status screen. | |
| 48 | ||
| 49 | ## Staging the runtime host (required before building) | |
| 50 | ||
| 51 | The APK ships three assets, none committed to git (too large / machine-built). | |
| 52 | Place them in `app/src/main/assets/`: | |
| 53 | ||
| 54 | | asset | what | source | | |
| 55 | |---|---|---| | |
| 56 | | `proot` | static aarch64 proot | `proot-v5.3.0-aarch64-static` (SourceForge proot.mirror) | | |
| 57 | | `tar` | static aarch64 tar or busybox | any static aarch64 busybox/tar (used only to unpack the rootfs) | | |
| 58 | | `rootfs.tar` | glibc Ubuntu 22.04 rootfs **with runtime libs + the pgh-sidecar bundle baked in** | see below | | |
| 59 | ||
| 60 | **Building `rootfs.tar`** (one-time, on the host): start from the celltron proot | |
| 61 | rootfs that already runs on the phone, add the runtime libs it needed | |
| 62 | (`libatomic.so.1`, extracted via `apt-get download libatomic1` + `dpkg-deb -x`), | |
| 63 | drop the arm64 sidecar bundle at `/root/pgh-sidecar-bootstrap-bare/` | |
| 64 | (`node scripts/build-bootstrap-bare.js --os linux --arch arm64` from pgh-sidecar, | |
| 65 | which vendors `bare-abstract-socket`), then | |
| 66 | `tar -C <rootfs> -cf rootfs.tar .`. | |
| 67 | ||
| 68 | The guest entrypoint `ProotLauncher.guestCmd` expects: | |
| 69 | `/root/pgh-sidecar-bootstrap-bare/runtime/bare bin/pgh-sidecar.bare.js`. | |
| 70 | ||
| 71 | > **Identity note:** this step-1 build stands up a *fresh* sidecar instance on | |
| 72 | > `@pgh-node` in the app's private sandbox — a throwaway bring-up node. It does | |
| 73 | > **not** touch the existing celltron `~/.hyperident` identity. Migrating the real | |
| 74 | > device identity + registries into the APK (with the CORESTORE-marker fix) is a | |
| 75 | > later step; do not point this at production identity yet. | |
| 76 | ||
| 77 | ## Build & install | |
| 78 | ||
| 79 | ```bash | |
| 80 | JAVA_HOME=/home/fleeky/android-tools/jdk-17.0.19+10 \ | |
| 81 | /home/fleeky/android-tools/gradle-8.7/bin/gradle assembleDebug --offline | |
| 82 | adb install -r app/build/outputs/apk/debug/app-debug.apk | |
| 83 | ``` | |
| 84 | ||
| 85 | (No gradle wrapper; `sdk.dir` is in `local.properties`. Same recipe as the mDNS | |
| 86 | app — its offline caches already hold AGP 8.5.2 + Kotlin 1.9.24.) | |
| 87 | ||
| 88 | ## Verify (step-1 success criteria) | |
| 89 | ||
| 90 | 1. Launch the app → status screen shows extraction then sidecar log lines. | |
| 91 | 2. `adb logcat -s celltron-node` shows `pgh-sidecar listening: socket: @pgh-node`. | |
| 92 | 3. Reboot → `BootReceiver` restarts the service → sidecar comes back with no adb. | |
| 93 | ||
| 94 | ## Next steps | |
| 95 | ||
| 96 | 2. Lifecycle hardening — `NetworkCallback` reconcile, reboot-to-parity test. | |
| 97 | 3. GUI (shell-out MVP) — WebView driving `pgh app` list/install/start/stop/log. | |
| 98 | 4. GUI (native) — Kotlin `LocalSocket` ABSTRACT hyper-svc client to `@pgh-node`. | |
| 99 | 5. Retire Termux; migrate the real device identity + stores. |
| … | … | @@ -0,0 +1,37 @@ |
| 1 | plugins { | |
| 2 | id 'com.android.application' | |
| 3 | id 'org.jetbrains.kotlin.android' | |
| 4 | } | |
| 5 | ||
| 6 | android { | |
| 7 | namespace 'net.celltron.node' | |
| 8 | compileSdk 34 | |
| 9 | ||
| 10 | defaultConfig { | |
| 11 | applicationId 'net.celltron.node' | |
| 12 | minSdk 24 | |
| 13 | targetSdk 30 | |
| 14 | versionCode 1 | |
| 15 | versionName '0.1' | |
| 16 | } | |
| 17 | ||
| 18 | compileOptions { | |
| 19 | sourceCompatibility JavaVersion.VERSION_17 | |
| 20 | targetCompatibility JavaVersion.VERSION_17 | |
| 21 | } | |
| 22 | kotlinOptions { | |
| 23 | jvmTarget = '17' | |
| 24 | } | |
| 25 | buildTypes { | |
| 26 | release { | |
| 27 | minifyEnabled false | |
| 28 | } | |
| 29 | } | |
| 30 | // Don't recompress the already-large runtime-host assets; keeps them | |
| 31 | // mmap-friendly and avoids AAPT choking on a big rootfs tarball. | |
| 32 | androidResources { | |
| 33 | noCompress += ['tar', 'proot'] | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | dependencies { } |
| … | … | @@ -0,0 +1,36 @@ |
| 1 | <?xml version="1.0" encoding="utf-8"?> | |
| 2 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | ||
| 4 | <uses-permission android:name="android.permission.INTERNET"/> | |
| 5 | <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> | |
| 6 | <uses-permission android:name="android.permission.WAKE_LOCK"/> | |
| 7 | <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> | |
| 8 | <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> | |
| 9 | ||
| 10 | <application | |
| 11 | android:allowBackup="false" | |
| 12 | android:label="celltron node"> | |
| 13 | ||
| 14 | <activity | |
| 15 | android:name=".MainActivity" | |
| 16 | android:exported="true"> | |
| 17 | <intent-filter> | |
| 18 | <action android:name="android.intent.action.MAIN"/> | |
| 19 | <category android:name="android.intent.category.LAUNCHER"/> | |
| 20 | </intent-filter> | |
| 21 | </activity> | |
| 22 | ||
| 23 | <service | |
| 24 | android:name=".PghNodeService" | |
| 25 | android:exported="false"/> | |
| 26 | ||
| 27 | <receiver | |
| 28 | android:name=".BootReceiver" | |
| 29 | android:exported="true"> | |
| 30 | <intent-filter> | |
| 31 | <action android:name="android.intent.action.BOOT_COMPLETED"/> | |
| 32 | </intent-filter> | |
| 33 | </receiver> | |
| 34 | ||
| 35 | </application> | |
| 36 | </manifest> |
| … | … | @@ -0,0 +1,8 @@ |
| 1 | Stage the runtime-host assets here before building (see ../../../../README.md): | |
| 2 | proot static aarch64 proot binary | |
| 3 | rootfs.tar glibc Ubuntu rootfs (minimal base for the smoke test; later the | |
| 4 | full base with runtime libs + the pgh bundle baked in) | |
| 5 | ||
| 6 | Extraction uses the platform /system/bin/toybox, so no tar binary is bundled. | |
| 7 | The project compiles without these; PghNodeService fails at runtime if they are | |
| 8 | missing. They are .gitignored (too large + machine-built). |
| … | … | @@ -0,0 +1,20 @@ |
| 1 | package net.celltron.node | |
| 2 | ||
| 3 | import android.content.BroadcastReceiver | |
| 4 | import android.content.Context | |
| 5 | import android.content.Intent | |
| 6 | import android.os.Build | |
| 7 | ||
| 8 | /** Start the node service on device boot, so the pgh stack survives reboots. */ | |
| 9 | class BootReceiver : BroadcastReceiver() { | |
| 10 | override fun onReceive(context: Context, intent: Intent) { | |
| 11 | if (intent.action == Intent.ACTION_BOOT_COMPLETED) { | |
| 12 | val i = Intent(context, PghNodeService::class.java) | |
| 13 | if (Build.VERSION.SDK_INT >= 26) { | |
| 14 | context.startForegroundService(i) | |
| 15 | } else { | |
| 16 | context.startService(i) | |
| 17 | } | |
| 18 | } | |
| 19 | } | |
| 20 | } |
| … | … | @@ -0,0 +1,109 @@ |
| 1 | package net.celltron.node | |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.system.Os | |
| 5 | import android.util.Log | |
| 6 | import java.io.File | |
| 7 | ||
| 8 | /** | |
| 9 | * First-run, idempotent extraction of the runtime host from APK assets into the | |
| 10 | * app's private filesDir. On API 24 the app_data_file domain is exec-permitted, | |
| 11 | * which is the whole premise of running proot no-root. | |
| 12 | * | |
| 13 | * Everything is written in place and never tar-moved afterwards — that is what | |
| 14 | * sidesteps the device-file / CORESTORE breakage the Termux relocation approach | |
| 15 | * hit (a tar-copy of a live rootfs invalidates every rocksdb corestore marker). | |
| 16 | * | |
| 17 | * Assets expected (staged at build time — see README): | |
| 18 | * assets/proot static aarch64 proot binary | |
| 19 | * assets/rootfs.tar glibc Ubuntu rootfs (minimal base for the harness smoke | |
| 20 | * test; later, the full base with runtime libs + the pgh | |
| 21 | * bundle baked in). Unpacked on-device with the platform's | |
| 22 | * /system/bin/toybox — verified rc=0 with symlinks + perms | |
| 23 | * intact — so no static tar binary needs bundling. | |
| 24 | */ | |
| 25 | object Extractor { | |
| 26 | const val TAG = "celltron-node" | |
| 27 | ||
| 28 | // Bump when the shipped rootfs/host changes, to force a clean re-extract. | |
| 29 | private const val STAMP = "host.v1" | |
| 30 | ||
| 31 | fun ensure(ctx: Context): Layout { | |
| 32 | val root = ctx.filesDir | |
| 33 | val layout = Layout(root) | |
| 34 | val stamp = File(root, ".staged-$STAMP") | |
| 35 | if (stamp.exists()) { | |
| 36 | Log.i(TAG, "runtime host already staged ($STAMP)") | |
| 37 | layout.tmp.mkdirs() | |
| 38 | return layout | |
| 39 | } | |
| 40 | Log.i(TAG, "staging runtime host into ${root.absolutePath}") | |
| 41 | layout.tmp.mkdirs() | |
| 42 | ||
| 43 | // 1. proot -> filesDir, executable. | |
| 44 | val proot = copyAsset(ctx, "proot", layout.proot) | |
| 45 | chmodExec(proot) | |
| 46 | ||
| 47 | // 2. unpack the rootfs with the platform toybox (present on every | |
| 48 | // Android). Verified on-device: rc=0, symlinks + perms intact. The | |
| 49 | // chown-to-0 warnings are harmless — the app can't chown, exactly like | |
| 50 | // proot -0 fakes root. | |
| 51 | layout.rootfs.mkdirs() | |
| 52 | val rootfsTar = copyAsset(ctx, "rootfs.tar", File(root, "rootfs.tar")) | |
| 53 | val rc = run( | |
| 54 | "/system/bin/toybox", "tar", "-x", "-f", rootfsTar.absolutePath, | |
| 55 | "-C", layout.rootfs.absolutePath | |
| 56 | ) | |
| 57 | // toybox tar returns non-zero on no-root Android because it can't | |
| 58 | // chown-to-0 or create hardlinks — both SELinux-denied and both | |
| 59 | // harmless here (proot -0 fakes root; hardlinked aliases like | |
| 60 | // perl5.34.0 are non-essential). Validate by sentinel, not by rc. | |
| 61 | val sentinel = File(layout.rootfs, "usr/bin/env") | |
| 62 | if (!sentinel.exists()) { | |
| 63 | throw RuntimeException("rootfs extraction incomplete (rc=$rc, missing ${sentinel.path})") | |
| 64 | } | |
| 65 | if (rc != 0) { | |
| 66 | Log.w(TAG, "toybox tar rc=$rc (expected no-root chown/hardlink denials) — sentinel present, continuing") | |
| 67 | } | |
| 68 | rootfsTar.delete() // reclaim disk; the asset stays in the APK | |
| 69 | ||
| 70 | stamp.writeText(System.currentTimeMillis().toString()) | |
| 71 | Log.i(TAG, "runtime host staging complete") | |
| 72 | return layout | |
| 73 | } | |
| 74 | ||
| 75 | private fun copyAsset(ctx: Context, name: String, dest: File): File { | |
| 76 | ctx.assets.open(name).use { input -> | |
| 77 | dest.outputStream().use { out -> input.copyTo(out, 1 shl 16) } | |
| 78 | } | |
| 79 | return dest | |
| 80 | } | |
| 81 | ||
| 82 | private fun chmodExec(f: File) { | |
| 83 | try { | |
| 84 | Os.chmod(f.absolutePath, 493) // 0755 | |
| 85 | } catch (e: Exception) { | |
| 86 | f.setExecutable(true, false) | |
| 87 | Log.w(TAG, "Os.chmod failed for ${f.name}, fell back to setExecutable: ${e.message}") | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | private fun run(vararg cmd: String): Int { | |
| 92 | val p = ProcessBuilder(*cmd).redirectErrorStream(true).start() | |
| 93 | // Drain output (required, else the pipe buffer fills and the child | |
| 94 | // blocks) but DON'T mirror it to logcat — toybox emits thousands of | |
| 95 | // harmless chown/hardlink denials that would flood and rotate the buffer. | |
| 96 | var lines = 0 | |
| 97 | p.inputStream.bufferedReader().forEachLine { lines++ } | |
| 98 | val rc = p.waitFor() | |
| 99 | Log.i(TAG, "extract: rc=$rc ($lines output lines suppressed)") | |
| 100 | return rc | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /** On-device paths under the app's private filesDir. */ | |
| 105 | class Layout(val root: File) { | |
| 106 | val rootfs = File(root, "rootfs") | |
| 107 | val tmp = File(root, "tmp") | |
| 108 | val proot = File(root, "proot") | |
| 109 | } |
| … | … | @@ -0,0 +1,47 @@ |
| 1 | package net.celltron.node | |
| 2 | ||
| 3 | import android.app.Activity | |
| 4 | import android.content.Intent | |
| 5 | import android.os.Build | |
| 6 | import android.os.Bundle | |
| 7 | import android.os.Handler | |
| 8 | import android.os.Looper | |
| 9 | import android.widget.TextView | |
| 10 | ||
| 11 | /** | |
| 12 | * Minimal launcher: starts the foreground node service and polls its last log | |
| 13 | * line so you can watch first-run extraction + sidecar boot on-device. The real | |
| 14 | * GUI (WebView over pgh) replaces this in a later step. | |
| 15 | */ | |
| 16 | class MainActivity : Activity() { | |
| 17 | ||
| 18 | private lateinit var tv: TextView | |
| 19 | private val handler = Handler(Looper.getMainLooper()) | |
| 20 | ||
| 21 | override fun onCreate(savedInstanceState: Bundle?) { | |
| 22 | super.onCreate(savedInstanceState) | |
| 23 | tv = TextView(this).apply { | |
| 24 | textSize = 14f | |
| 25 | setPadding(48, 96, 48, 48) | |
| 26 | } | |
| 27 | setContentView(tv) | |
| 28 | ||
| 29 | val i = Intent(this, PghNodeService::class.java) | |
| 30 | if (Build.VERSION.SDK_INT >= 26) startForegroundService(i) else startService(i) | |
| 31 | ||
| 32 | poll() | |
| 33 | } | |
| 34 | ||
| 35 | private fun poll() { | |
| 36 | tv.text = "celltron node\n\n" + | |
| 37 | "Runtime host: proot + glibc rootfs (extracted on first launch)\n" + | |
| 38 | "Service: pgh-sidecar on @pgh-node\n\n" + | |
| 39 | "Last log:\n${PghNodeService.lastLogSnapshot}" | |
| 40 | handler.postDelayed({ poll() }, 1000) | |
| 41 | } | |
| 42 | ||
| 43 | override fun onDestroy() { | |
| 44 | handler.removeCallbacksAndMessages(null) | |
| 45 | super.onDestroy() | |
| 46 | } | |
| 47 | } |
| … | … | @@ -0,0 +1,96 @@ |
| 1 | package net.celltron.node | |
| 2 | ||
| 3 | import android.app.Notification | |
| 4 | import android.app.NotificationChannel | |
| 5 | import android.app.NotificationManager | |
| 6 | import android.app.Service | |
| 7 | import android.content.Context | |
| 8 | import android.content.Intent | |
| 9 | import android.os.Build | |
| 10 | import android.os.IBinder | |
| 11 | import android.os.PowerManager | |
| 12 | import android.util.Log | |
| 13 | ||
| 14 | /** | |
| 15 | * Foreground service = the anchor for the whole proot/pgh process tree. | |
| 16 | * | |
| 17 | * It holds a partial WakeLock (the app-level replacement for Termux's wake-lock | |
| 18 | * plus the stay_on hack) so Doze doesn't suspend the node, extracts the runtime | |
| 19 | * host on first launch, then supervises the pgh stack. Because the proot child | |
| 20 | * is parented to this service and never setsid'd, it lives as long as the | |
| 21 | * service — the fix for the reaping that killed the Termux setup. | |
| 22 | * | |
| 23 | * Step 1 scope: bring the runtime host up and boot pgh-sidecar on @pgh-node. | |
| 24 | * The GUI bridge (WebView + LocalSocket ABSTRACT client) lands in a later step. | |
| 25 | */ | |
| 26 | class PghNodeService : Service() { | |
| 27 | ||
| 28 | private var wakeLock: PowerManager.WakeLock? = null | |
| 29 | private var launcher: ProotLauncher? = null | |
| 30 | private var worker: Thread? = null | |
| 31 | ||
| 32 | companion object { | |
| 33 | /** Last log line from the supervised guest, for the launcher UI to poll. */ | |
| 34 | @Volatile | |
| 35 | var lastLogSnapshot: String = "starting…" | |
| 36 | } | |
| 37 | ||
| 38 | override fun onCreate() { | |
| 39 | super.onCreate() | |
| 40 | startForegroundCompat() | |
| 41 | ||
| 42 | val pm = getSystemService(Context.POWER_SERVICE) as PowerManager | |
| 43 | wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "celltron-node:wl").apply { | |
| 44 | setReferenceCounted(false) | |
| 45 | acquire() | |
| 46 | } | |
| 47 | Log.i(Extractor.TAG, "wakelock held=${wakeLock?.isHeld}") | |
| 48 | ||
| 49 | worker = Thread { | |
| 50 | try { | |
| 51 | val layout = Extractor.ensure(applicationContext) | |
| 52 | launcher = ProotLauncher(layout) | |
| 53 | launcher!!.superviseForever { line -> lastLogSnapshot = line } | |
| 54 | } catch (e: Exception) { | |
| 55 | Log.e(Extractor.TAG, "node failed: ${e.message}", e) | |
| 56 | lastLogSnapshot = "ERROR: ${e.message}" | |
| 57 | } | |
| 58 | }.also { | |
| 59 | it.isDaemon = true | |
| 60 | it.name = "celltron-node-supervisor" | |
| 61 | it.start() | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY | |
| 66 | ||
| 67 | override fun onBind(intent: Intent?): IBinder? = null | |
| 68 | ||
| 69 | override fun onDestroy() { | |
| 70 | launcher?.stop() | |
| 71 | wakeLock?.let { if (it.isHeld) it.release() } | |
| 72 | super.onDestroy() | |
| 73 | } | |
| 74 | ||
| 75 | private fun startForegroundCompat() { | |
| 76 | val chId = "celltron-node" | |
| 77 | if (Build.VERSION.SDK_INT >= 26) { | |
| 78 | val ch = NotificationChannel(chId, "celltron node", NotificationManager.IMPORTANCE_LOW) | |
| 79 | (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) | |
| 80 | .createNotificationChannel(ch) | |
| 81 | } | |
| 82 | val builder = if (Build.VERSION.SDK_INT >= 26) { | |
| 83 | Notification.Builder(this, chId) | |
| 84 | } else { | |
| 85 | @Suppress("DEPRECATION") | |
| 86 | Notification.Builder(this) | |
| 87 | } | |
| 88 | val notif = builder | |
| 89 | .setContentTitle("celltron node") | |
| 90 | .setContentText("pgh stack running") | |
| 91 | .setSmallIcon(android.R.drawable.stat_notify_sync) | |
| 92 | .setOngoing(true) | |
| 93 | .build() | |
| 94 | startForeground(1, notif) | |
| 95 | } | |
| 96 | } |
| … | … | @@ -0,0 +1,104 @@ |
| 1 | package net.celltron.node | |
| 2 | ||
| 3 | import android.util.Log | |
| 4 | ||
| 5 | /** | |
| 6 | * Builds and runs the proot invocation that boots the pgh stack inside the glibc | |
| 7 | * rootfs, and supervises it. | |
| 8 | * | |
| 9 | * The proot process is a DIRECT CHILD of the host service process — never | |
| 10 | * detached (no setsid). That anchoring is deliberate and load-bearing: on | |
| 11 | * Android a detached process tree gets reaped (the celltron no-setsid lesson — | |
| 12 | * a `setsid supervise.sh &` had the OS reap the whole tree in ~18 min while the | |
| 13 | * launcher survived). Keeping proot parented to the foreground service is what | |
| 14 | * makes the tree live exactly as long as the service. | |
| 15 | * | |
| 16 | * Mirrors the proven celltron.sh launcher: | |
| 17 | * proot -0 -r <rootfs> -b /dev -b /proc -b /sys -b <tmp>:/tmp -w /root \ | |
| 18 | * /usr/bin/env -i HOME=/root ... PATH=... <guest command> | |
| 19 | * with PROOT_TMP_DIR set on proot's OWN environment (proot reads it there, not | |
| 20 | * from the guest `env -i` set). | |
| 21 | */ | |
| 22 | class ProotLauncher(private val layout: Layout) { | |
| 23 | ||
| 24 | @Volatile private var proc: Process? = null | |
| 25 | @Volatile private var stopped = false | |
| 26 | ||
| 27 | /** | |
| 28 | * Step-1 harness smoke test: run a trivial guest command to prove the | |
| 29 | * extract -> proot -> env -> supervise loop works on-device, decoupled from | |
| 30 | * the full sidecar rootfs. Flip to false once the full rootfs.tar (with the | |
| 31 | * pgh-sidecar bundle baked in) is staged. | |
| 32 | */ | |
| 33 | private val smokeTest = true | |
| 34 | ||
| 35 | /** | |
| 36 | * Guest command to run under proot. Smoke: print uname + a marker, then hold | |
| 37 | * (like a daemon would) so the supervisor doesn't hot-loop. Real: boot the | |
| 38 | * pgh-sidecar on an abstract socket (pathname UDS is SELinux-denied no-root; | |
| 39 | * @-names go through bare-abstract-socket, baked into the sidecar bundle). | |
| 40 | */ | |
| 41 | private val guestCmd: List<String> = if (smokeTest) { | |
| 42 | listOf("sh", "-c", "uname -a; echo celltron-node-smoke-ok; sleep 86400") | |
| 43 | } else { | |
| 44 | listOf( | |
| 45 | "/root/pgh-sidecar-bootstrap-bare/runtime/bare", | |
| 46 | "/root/pgh-sidecar-bootstrap-bare/bin/pgh-sidecar.bare.js" | |
| 47 | ) | |
| 48 | } | |
| 49 | ||
| 50 | fun command(): List<String> = buildList { | |
| 51 | add(layout.proot.absolutePath) | |
| 52 | add("-0") // fake root | |
| 53 | add("-r"); add(layout.rootfs.absolutePath) // new root | |
| 54 | add("-b"); add("/dev") | |
| 55 | add("-b"); add("/proc") | |
| 56 | add("-b"); add("/sys") | |
| 57 | add("-b"); add("${layout.tmp.absolutePath}:/tmp") | |
| 58 | add("-w"); add("/root") | |
| 59 | add("/usr/bin/env"); add("-i") // clean guest env | |
| 60 | add("HOME=/root") | |
| 61 | add("TERM=xterm") | |
| 62 | add("LANG=C.UTF-8") | |
| 63 | add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") | |
| 64 | add("PGH_SIDECAR_SOCK=@pgh-node") // abstract UDS (SELinux-safe) | |
| 65 | addAll(guestCmd) | |
| 66 | } | |
| 67 | ||
| 68 | /** Blocking supervise loop — call on a background thread. Restarts on exit. */ | |
| 69 | fun superviseForever(onLine: (String) -> Unit) { | |
| 70 | var backoff = 1000L | |
| 71 | while (!stopped) { | |
| 72 | val startedAt = System.currentTimeMillis() | |
| 73 | try { | |
| 74 | val pb = ProcessBuilder(command()).redirectErrorStream(true) | |
| 75 | // proot reads PROOT_TMP_DIR from its own env (Android has no /tmp). | |
| 76 | pb.environment()["PROOT_TMP_DIR"] = layout.tmp.absolutePath | |
| 77 | Log.i(Extractor.TAG, "launch: ${command().joinToString(" ")}") | |
| 78 | val p = pb.start().also { proc = it } | |
| 79 | p.inputStream.bufferedReader().forEachLine { line -> | |
| 80 | Log.i(Extractor.TAG, line) | |
| 81 | onLine(line) | |
| 82 | } | |
| 83 | val rc = p.waitFor() | |
| 84 | Log.w(Extractor.TAG, "guest exited rc=$rc") | |
| 85 | } catch (e: Exception) { | |
| 86 | Log.e(Extractor.TAG, "supervise error: ${e.message}") | |
| 87 | } | |
| 88 | if (stopped) break | |
| 89 | // Reset backoff after a healthy run (>60s); otherwise grow it so a | |
| 90 | // crash-on-startup doesn't hot-loop. | |
| 91 | backoff = if (System.currentTimeMillis() - startedAt > 60_000L) { | |
| 92 | 1000L | |
| 93 | } else { | |
| 94 | (backoff * 2).coerceAtMost(30_000L) | |
| 95 | } | |
| 96 | try { Thread.sleep(backoff) } catch (e: InterruptedException) { break } | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | fun stop() { | |
| 101 | stopped = true | |
| 102 | try { proc?.destroy() } catch (e: Exception) {} | |
| 103 | } | |
| 104 | } |
| … | … | @@ -0,0 +1,4 @@ |
| 1 | plugins { | |
| 2 | id 'com.android.application' version '8.5.2' apply false | |
| 3 | id 'org.jetbrains.kotlin.android' version '1.9.24' apply false | |
| 4 | } |
| … | … | @@ -0,0 +1,4 @@ |
| 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 | |
| 2 | android.useAndroidX=false | |
| 3 | kotlin.code.style=official | |
| 4 | org.gradle.caching=true |
| … | … | @@ -0,0 +1 @@ |
| 1 | sdk.dir=/home/fleeky/android-tools/sdk |
| … | … | @@ -0,0 +1,16 @@ |
| 1 | pluginManagement { | |
| 2 | repositories { | |
| 3 | google() | |
| 4 | mavenCentral() | |
| 5 | gradlePluginPortal() | |
| 6 | } | |
| 7 | } | |
| 8 | dependencyResolutionManagement { | |
| 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) | |
| 10 | repositories { | |
| 11 | google() | |
| 12 | mavenCentral() | |
| 13 | } | |
| 14 | } | |
| 15 | rootProject.name = 'celltron-node' | |
| 16 | include ':app' |