From 19333f668581fe6a92ec6f6e6159d84b84a2a9b2 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 28 Feb 2026 22:28:33 +0900 Subject: [PATCH] feat: add GatewayService, EmbeddedBackendLifecycle, and BootReceiver for embedded flavor Wrap GatewayProcessManager in a foreground service with pause/resume notification actions, implement BackendLifecycle for embedded flavor, and add boot-completed auto-start receiver. Co-Authored-By: Claude Opus 4.6 --- .../clawdroid/receiver/NotificationHelper.kt | 13 +++ android/backend/loader/build.gradle.kts | 3 + .../loader/src/main/AndroidManifest.xml | 9 ++ .../clawdroid/backend/loader/BootReceiver.kt | 14 +++ .../loader/EmbeddedBackendLifecycle.kt | 26 +++++ .../backend/loader/GatewayService.kt | 96 +++++++++++++++++++ 6 files changed, 161 insertions(+) create mode 100644 android/backend/loader/src/main/java/io/clawdroid/backend/loader/BootReceiver.kt create mode 100644 android/backend/loader/src/main/java/io/clawdroid/backend/loader/EmbeddedBackendLifecycle.kt create mode 100644 android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayService.kt diff --git a/android/app/src/main/java/io/clawdroid/receiver/NotificationHelper.kt b/android/app/src/main/java/io/clawdroid/receiver/NotificationHelper.kt index 6d610528f..12a6ed7ff 100644 --- a/android/app/src/main/java/io/clawdroid/receiver/NotificationHelper.kt +++ b/android/app/src/main/java/io/clawdroid/receiver/NotificationHelper.kt @@ -15,6 +15,9 @@ object NotificationHelper { const val ASSISTANT_CHANNEL_ID = "clawdroid_assistant" private const val ASSISTANT_CHANNEL_NAME = "Assistant" + const val GATEWAY_CHANNEL_ID = "clawdroid_gateway" + private const val GATEWAY_CHANNEL_NAME = "Gateway" + fun createNotificationChannel(context: Context) { val manager = context.getSystemService(NotificationManager::class.java) @@ -36,6 +39,16 @@ object NotificationHelper { setShowBadge(false) } manager.createNotificationChannel(assistantChannel) + + val gatewayChannel = NotificationChannel( + GATEWAY_CHANNEL_ID, + GATEWAY_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Gateway backend service" + setShowBadge(false) + } + manager.createNotificationChannel(gatewayChannel) } fun showMessageNotification(context: Context, content: String) { diff --git a/android/backend/loader/build.gradle.kts b/android/backend/loader/build.gradle.kts index 01a75df9c..37bfd7ef4 100644 --- a/android/backend/loader/build.gradle.kts +++ b/android/backend/loader/build.gradle.kts @@ -15,4 +15,7 @@ android { dependencies { implementation(project(":backend:api")) implementation(libs.coroutines.android) + implementation(libs.lifecycle.service) + implementation(libs.core.ktx) + implementation(libs.koin.android) } diff --git a/android/backend/loader/src/main/AndroidManifest.xml b/android/backend/loader/src/main/AndroidManifest.xml index 44744257a..9176146f0 100644 --- a/android/backend/loader/src/main/AndroidManifest.xml +++ b/android/backend/loader/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + + + + + + diff --git a/android/backend/loader/src/main/java/io/clawdroid/backend/loader/BootReceiver.kt b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/BootReceiver.kt new file mode 100644 index 000000000..c2958fa98 --- /dev/null +++ b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/BootReceiver.kt @@ -0,0 +1,14 @@ +package io.clawdroid.backend.loader + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + val serviceIntent = Intent(context, GatewayService::class.java) + context.startForegroundService(serviceIntent) + } + } +} diff --git a/android/backend/loader/src/main/java/io/clawdroid/backend/loader/EmbeddedBackendLifecycle.kt b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/EmbeddedBackendLifecycle.kt new file mode 100644 index 000000000..98fd57b61 --- /dev/null +++ b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/EmbeddedBackendLifecycle.kt @@ -0,0 +1,26 @@ +package io.clawdroid.backend.loader + +import android.content.Context +import android.content.Intent +import io.clawdroid.backend.api.BackendLifecycle +import io.clawdroid.backend.api.BackendState +import kotlinx.coroutines.flow.StateFlow + +class EmbeddedBackendLifecycle( + private val context: Context, + private val processManager: GatewayProcessManager, +) : BackendLifecycle { + + override val isManaged: Boolean = true + + override val state: StateFlow = processManager.state + + override suspend fun start() { + val intent = Intent(context, GatewayService::class.java) + context.startForegroundService(intent) + } + + override suspend fun stop() { + context.stopService(Intent(context, GatewayService::class.java)) + } +} diff --git a/android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayService.kt b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayService.kt new file mode 100644 index 000000000..6b334720b --- /dev/null +++ b/android/backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayService.kt @@ -0,0 +1,96 @@ +package io.clawdroid.backend.loader + +import android.app.Notification +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat +import androidx.lifecycle.LifecycleService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.android.ext.android.inject + +class GatewayService : LifecycleService() { + + private val processManager: GatewayProcessManager by inject() + private lateinit var serviceScope: CoroutineScope + + override fun onCreate() { + super.onCreate() + serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + + when (intent?.action) { + ACTION_PAUSE -> { + serviceScope.launch { + processManager.stop() + updateNotification(running = false) + } + } + ACTION_RESUME -> { + serviceScope.launch { + processManager.start() + updateNotification(running = true) + } + } + else -> { + startForeground( + NOTIFICATION_ID, + buildNotification(running = true), + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + ) + serviceScope.launch { processManager.start() } + } + } + + return START_STICKY + } + + override fun onDestroy() { + runBlocking { processManager.stop() } + serviceScope.cancel() + super.onDestroy() + } + + private fun updateNotification(running: Boolean) { + val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(running)) + } + + private fun buildNotification(running: Boolean): Notification { + val actionIntent = Intent(this, GatewayService::class.java).apply { + action = if (running) ACTION_PAUSE else ACTION_RESUME + } + val pendingIntent = PendingIntent.getService( + this, 0, actionIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val actionLabel = if (running) "Pause" else "Resume" + val actionIcon = if (running) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play + val contentText = if (running) "Backend running" else "Backend paused" + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("ClawDroid") + .setContentText(contentText) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .addAction(actionIcon, actionLabel, pendingIntent) + .build() + } + + companion object { + private const val NOTIFICATION_ID = 2002 + private const val CHANNEL_ID = "clawdroid_gateway" + const val ACTION_PAUSE = "io.clawdroid.backend.loader.ACTION_PAUSE" + const val ACTION_RESUME = "io.clawdroid.backend.loader.ACTION_RESUME" + } +}