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