From 834714d700bfcbfead2ef46cce2df8021852f1d8 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 28 Feb 2026 21:22:24 +0900 Subject: [PATCH] feat: add :backend:loader module with GatewayProcessManager for embedded flavor Go binary (libclawdroid.so) process lifecycle manager that handles process start/stop, health check polling, log forwarding, API key generation, exponential backoff restart, and settings observation. Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle.kts | 1 + android/backend/loader/build.gradle.kts | 18 ++ .../loader/src/main/AndroidManifest.xml | 15 ++ .../backend/loader/GatewayProcessManager.kt | 177 ++++++++++++++++++ android/settings.gradle.kts | 1 + 5 files changed, 212 insertions(+) create mode 100644 android/backend/loader/build.gradle.kts create mode 100644 android/backend/loader/src/main/AndroidManifest.xml create mode 100644 android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayProcessManager.kt 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")