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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-28 22:28:33 +09:00
parent 834714d700
commit 19333f6685
6 changed files with 161 additions and 0 deletions

View file

@ -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) {

View file

@ -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)
}

View file

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application>
<service
@ -11,5 +12,13 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Local AI agent backend process" />
</service>
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -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)
}
}
}

View file

@ -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<BackendState> = 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))
}
}

View file

@ -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"
}
}