diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 24407a23c..16329d117 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -80,6 +80,7 @@ dependencies {
implementation(project(":backend:config"))
"termuxImplementation"(project(":backend:loader-noop"))
+ "embeddedImplementation"(project(":backend:loader"))
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
diff --git a/android/backend/loader/build.gradle.kts b/android/backend/loader/build.gradle.kts
new file mode 100644
index 000000000..01a75df9c
--- /dev/null
+++ b/android/backend/loader/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "io.clawdroid.backend.loader"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(project(":backend:api"))
+ implementation(libs.coroutines.android)
+}
diff --git a/android/backend/loader/src/main/AndroidManifest.xml b/android/backend/loader/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..44744257a
--- /dev/null
+++ b/android/backend/loader/src/main/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayProcessManager.kt b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayProcessManager.kt
new file mode 100644
index 000000000..396fad27d
--- /dev/null
+++ b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayProcessManager.kt
@@ -0,0 +1,177 @@
+package io.clawdroid.backend.loader
+
+import android.content.Context
+import android.util.Log
+import io.clawdroid.backend.api.BackendState
+import io.clawdroid.backend.api.GatewaySettingsStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.io.BufferedReader
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.UUID
+
+class GatewayProcessManager(
+ private val context: Context,
+ private val settingsStore: GatewaySettingsStore,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val _state = MutableStateFlow(BackendState.STOPPED)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var process: Process? = null
+ private var logJob: Job? = null
+ private var watchdogJob: Job? = null
+ private var healthCheckJob: Job? = null
+ private var settingsObserverJob: Job? = null
+
+ private val binaryPath: String
+ get() = context.applicationInfo.nativeLibraryDir + "/libclawdroid.so"
+
+ suspend fun start() {
+ ensureApiKey()
+ startProcess()
+ startSettingsObserver()
+ }
+
+ suspend fun stop() {
+ settingsObserverJob?.cancel()
+ settingsObserverJob = null
+ stopProcess()
+ }
+
+ private suspend fun ensureApiKey() {
+ val settings = settingsStore.settings.value
+ if (settings.apiKey.isEmpty()) {
+ settingsStore.update(settings.copy(apiKey = UUID.randomUUID().toString()))
+ }
+ }
+
+ private fun startProcess() {
+ _state.value = BackendState.STARTING
+
+ val settings = settingsStore.settings.value
+ val env = mapOf(
+ "HOME" to context.filesDir.absolutePath,
+ "CLAWDROID_GATEWAY_API_KEY" to settings.apiKey,
+ )
+
+ val pb = ProcessBuilder(binaryPath, "gateway", "run")
+ pb.environment().putAll(env)
+ pb.directory(context.filesDir)
+ pb.redirectErrorStream(true)
+
+ val proc = pb.start()
+ process = proc
+ Log.i(TAG, "Started gateway process")
+
+ logJob = scope.launch { forwardLogs(proc) }
+ watchdogJob = scope.launch { watchProcess(proc) }
+ healthCheckJob = scope.launch { pollHealth(settings.httpPort) }
+ }
+
+ private fun stopProcess() {
+ healthCheckJob?.cancel()
+ healthCheckJob = null
+ watchdogJob?.cancel()
+ watchdogJob = null
+ logJob?.cancel()
+ logJob = null
+
+ process?.let { proc ->
+ proc.destroy()
+ proc.waitFor()
+ Log.i(TAG, "Stopped gateway process")
+ }
+ process = null
+ _state.value = BackendState.STOPPED
+ }
+
+ private fun forwardLogs(proc: Process) {
+ proc.inputStream.bufferedReader().use { reader: BufferedReader ->
+ reader.forEachLine { line ->
+ Log.i(TAG, line)
+ }
+ }
+ }
+
+ private suspend fun pollHealth(port: Int) {
+ val url = "http://127.0.0.1:$port/api/config"
+ while (scope.isActive) {
+ try {
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.connectTimeout = 1000
+ conn.readTimeout = 1000
+ try {
+ if (conn.responseCode == 200) {
+ if (_state.value == BackendState.STARTING) {
+ _state.value = BackendState.RUNNING
+ Log.i(TAG, "Gateway health check passed")
+ }
+ }
+ } finally {
+ conn.disconnect()
+ }
+ } catch (_: Exception) {
+ // Not ready yet
+ }
+ delay(500)
+ }
+ }
+
+ private suspend fun watchProcess(proc: Process) {
+ var backoffMs = 1000L
+ proc.waitFor()
+ val exitCode = proc.exitValue()
+ Log.w(TAG, "Gateway process exited: code=$exitCode")
+
+ if (_state.value == BackendState.STOPPED) return
+
+ _state.value = BackendState.ERROR
+
+ while (scope.isActive && _state.value == BackendState.ERROR) {
+ Log.i(TAG, "Restarting gateway process (backoff=${backoffMs}ms)")
+ delay(backoffMs)
+ backoffMs = (backoffMs * 2).coerceAtMost(30_000)
+ try {
+ stopProcess()
+ startProcess()
+ return
+ } catch (e: Exception) {
+ Log.e(TAG, "Restart failed", e)
+ _state.value = BackendState.ERROR
+ }
+ }
+ }
+
+ private fun startSettingsObserver() {
+ settingsObserverJob = scope.launch {
+ settingsStore.settings
+ .map { it.apiKey }
+ .distinctUntilChanged()
+ .drop(1)
+ .collect { apiKey ->
+ if (process != null && apiKey.isNotEmpty()) {
+ Log.i(TAG, "API key changed, restarting gateway")
+ stopProcess()
+ startProcess()
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "GatewayProcess"
+ }
+}
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index b34dcf446..6f357e5c6 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -21,3 +21,4 @@ include(":core:ui")
include(":backend:api")
include(":backend:config")
include(":backend:loader-noop")
+include(":backend:loader")