- OAuth authentication via Authentik - WebSocket connection to OpenClaw gateway - Configurable gateway URL with first-run setup - User preferences sync across devices - Multi-user support with custom assistant names - ElevenLabs TTS integration (local + remote) - FCM push notifications for alarms - Voice input via Google Speech API - No hardcoded secrets or internal IPs in tracked files
456 lines
17 KiB
Kotlin
456 lines
17 KiB
Kotlin
package com.openclaw.alfred.service
|
|
|
|
import android.app.Notification
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.app.Service
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.os.Binder
|
|
import android.os.Build
|
|
import android.os.IBinder
|
|
import android.os.PowerManager
|
|
import android.util.Log
|
|
import androidx.core.app.NotificationCompat
|
|
import com.openclaw.alfred.MainActivity
|
|
import com.openclaw.alfred.R
|
|
import com.openclaw.alfred.gateway.GatewayClient
|
|
import com.openclaw.alfred.gateway.GatewayListener
|
|
import com.openclaw.alfred.voice.WakeWordDetector
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.cancel
|
|
import kotlinx.coroutines.launch
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
|
|
/**
|
|
* Foreground service that maintains persistent WebSocket connection to OpenClaw gateway.
|
|
* Survives screen-off and Doze mode.
|
|
*/
|
|
class AlfredConnectionService : Service() {
|
|
|
|
private val TAG = "AlfredConnectionService"
|
|
private val CHANNEL_ID = "alfred_connection"
|
|
private val NOTIFICATION_ID = 1001
|
|
|
|
private var gatewayClient: GatewayClient? = null
|
|
private var wakeLock: PowerManager.WakeLock? = null
|
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
|
|
// Wake word detector for continuous listening
|
|
private var wakeWordDetector: WakeWordDetector? = null
|
|
private var wakeWordEnabled = false
|
|
|
|
// External listener that MainActivity can set
|
|
private var externalListener: GatewayListener? = null
|
|
|
|
// Track current connection state to notify late-registering listeners
|
|
private var currentConnectionState: ConnectionState = ConnectionState.DISCONNECTED
|
|
|
|
private enum class ConnectionState {
|
|
DISCONNECTED,
|
|
CONNECTING,
|
|
CONNECTED
|
|
}
|
|
|
|
// Binder for MainActivity to bind to this service
|
|
private val binder = LocalBinder()
|
|
|
|
inner class LocalBinder : Binder() {
|
|
fun getService(): AlfredConnectionService = this@AlfredConnectionService
|
|
}
|
|
|
|
override fun onBind(intent: Intent): IBinder {
|
|
Log.d(TAG, "Service bound")
|
|
return binder
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
Log.d(TAG, "Service created")
|
|
createNotificationChannel()
|
|
startForegroundService()
|
|
}
|
|
|
|
private fun startForegroundService() {
|
|
val assistantName = getAssistantName()
|
|
val channelId = "alfred_connection"
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
channelId,
|
|
"Alfred Connection",
|
|
NotificationManager.IMPORTANCE_LOW
|
|
).apply {
|
|
description = "Maintains connection to Alfred assistant"
|
|
setShowBadge(false)
|
|
}
|
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
notificationManager.createNotificationChannel(channel)
|
|
}
|
|
|
|
val notification = NotificationCompat.Builder(this, channelId)
|
|
.setContentTitle(assistantName)
|
|
.setContentText("Starting...")
|
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
|
.setOngoing(true)
|
|
.setSilent(true)
|
|
.build()
|
|
|
|
startForeground(NOTIFICATION_ID, notification)
|
|
Log.d(TAG, "Foreground service started with notification")
|
|
}
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
Log.d(TAG, "Service started")
|
|
|
|
val gatewayUrl = intent?.getStringExtra("GATEWAY_URL")
|
|
val accessToken = intent?.getStringExtra("ACCESS_TOKEN")
|
|
val userId = intent?.getStringExtra("USER_ID")
|
|
|
|
if (gatewayUrl != null && accessToken != null && userId != null) {
|
|
startForeground(NOTIFICATION_ID, createNotification("Connecting..."))
|
|
|
|
// Only connect if we don't already have a client
|
|
if (gatewayClient == null) {
|
|
Log.d(TAG, "No existing client, creating new connection")
|
|
connectToGateway(gatewayUrl, accessToken, userId)
|
|
} else {
|
|
Log.d(TAG, "Client already exists, skipping duplicate connect")
|
|
}
|
|
}
|
|
|
|
// Service will be explicitly stopped, not restarted by system
|
|
return START_NOT_STICKY
|
|
}
|
|
|
|
private fun getAssistantName(): String {
|
|
val prefs = getSharedPreferences("alfred_settings", Context.MODE_PRIVATE)
|
|
return prefs.getString("assistant_name", "Alfred") ?: "Alfred"
|
|
}
|
|
|
|
private fun createNotificationChannel() {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
CHANNEL_ID,
|
|
"Alfred Connection",
|
|
NotificationManager.IMPORTANCE_LOW
|
|
).apply {
|
|
description = "Maintains connection to Alfred assistant"
|
|
setShowBadge(false)
|
|
}
|
|
|
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
notificationManager.createNotificationChannel(channel)
|
|
}
|
|
}
|
|
|
|
private fun createNotification(status: String): Notification {
|
|
val assistantName = getAssistantName()
|
|
val notificationIntent = Intent(this, MainActivity::class.java)
|
|
val pendingIntent = PendingIntent.getActivity(
|
|
this,
|
|
0,
|
|
notificationIntent,
|
|
PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
.setContentTitle(assistantName)
|
|
.setContentText(status)
|
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
|
.setContentIntent(pendingIntent)
|
|
.setOngoing(true)
|
|
.setSilent(true)
|
|
.build()
|
|
}
|
|
|
|
private fun updateNotification(text: String) {
|
|
val assistantName = getAssistantName()
|
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
.setContentTitle(assistantName)
|
|
.setContentText(text)
|
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
|
.setOngoing(true)
|
|
.setSilent(true)
|
|
.build()
|
|
|
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
|
}
|
|
|
|
private fun createForwardingListener(): GatewayListener {
|
|
return object : GatewayListener {
|
|
override fun onConnecting() {
|
|
Log.d(TAG, "Gateway connecting")
|
|
currentConnectionState = ConnectionState.CONNECTING
|
|
updateNotification("Connecting...")
|
|
externalListener?.onConnecting()
|
|
}
|
|
|
|
override fun onConnected() {
|
|
Log.d(TAG, "Gateway connected")
|
|
currentConnectionState = ConnectionState.CONNECTED
|
|
updateNotification("Connected")
|
|
externalListener?.onConnected()
|
|
}
|
|
|
|
override fun onDisconnected() {
|
|
Log.d(TAG, "Gateway disconnected")
|
|
currentConnectionState = ConnectionState.DISCONNECTED
|
|
updateNotification("Disconnected")
|
|
externalListener?.onDisconnected()
|
|
}
|
|
|
|
override fun onReconnecting(attempt: Int, delayMs: Long) {
|
|
Log.d(TAG, "Gateway reconnecting: attempt $attempt, delay ${delayMs}ms")
|
|
updateNotification("Reconnecting...")
|
|
externalListener?.onReconnecting(attempt, delayMs)
|
|
}
|
|
|
|
override fun onError(error: String) {
|
|
Log.e(TAG, "Gateway error: $error")
|
|
updateNotification("Error")
|
|
externalListener?.onError(error)
|
|
}
|
|
|
|
override fun onEvent(event: String, payload: String) {
|
|
Log.d(TAG, "Event received in service: $event")
|
|
externalListener?.onEvent(event, payload)
|
|
}
|
|
|
|
override fun onResponse(id: String, payload: String) {
|
|
Log.d(TAG, "Response received in service: $id")
|
|
externalListener?.onResponse(id, payload)
|
|
}
|
|
|
|
override fun onMessage(sender: String, text: String) {
|
|
Log.d(TAG, "Message received in service from $sender: ${text.take(100)}")
|
|
externalListener?.onMessage(sender, text)
|
|
}
|
|
|
|
override fun onNotification(
|
|
notificationType: String,
|
|
title: String,
|
|
message: String,
|
|
priority: String,
|
|
sound: Boolean,
|
|
vibrate: Boolean,
|
|
timestamp: Long,
|
|
action: String?
|
|
) {
|
|
Log.d(TAG, "Notification received in service: $notificationType - $title")
|
|
externalListener?.onNotification(notificationType, title, message, priority, sound, vibrate, timestamp, action)
|
|
}
|
|
|
|
override fun onAlarmDismissed(alarmId: String) {
|
|
Log.d(TAG, "Alarm dismissed in service: $alarmId")
|
|
externalListener?.onAlarmDismissed(alarmId)
|
|
}
|
|
|
|
override fun onWakeWordDetected() {
|
|
Log.d(TAG, "Wake word detected (forwarding to external listener)")
|
|
externalListener?.onWakeWordDetected()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun connectToGateway(url: String, token: String, userId: String) {
|
|
Log.d(TAG, "Connecting to gateway")
|
|
|
|
gatewayClient = GatewayClient(
|
|
context = this,
|
|
accessToken = token,
|
|
listener = createForwardingListener()
|
|
)
|
|
|
|
gatewayClient?.connect()
|
|
}
|
|
|
|
/**
|
|
* Set external listener that will receive all gateway events.
|
|
* Prevents duplicate registration by checking if the same listener is already set.
|
|
* Immediately notifies new listener of current connection state.
|
|
*/
|
|
fun setListener(listener: GatewayListener?) {
|
|
if (externalListener != null && listener != null) {
|
|
Log.w(TAG, "External listener already set, clearing old one first")
|
|
externalListener = null
|
|
}
|
|
Log.d(TAG, "External listener ${if (listener != null) "set" else "cleared"}")
|
|
Log.d(TAG, "Current connection state: $currentConnectionState")
|
|
externalListener = listener
|
|
|
|
// Immediately notify new listener of current state
|
|
if (listener != null) {
|
|
when (currentConnectionState) {
|
|
ConnectionState.CONNECTING -> {
|
|
Log.d(TAG, "Notifying new listener of CONNECTING state")
|
|
listener.onConnecting()
|
|
}
|
|
ConnectionState.CONNECTED -> {
|
|
Log.d(TAG, "Notifying new listener of CONNECTED state")
|
|
listener.onConnected()
|
|
}
|
|
ConnectionState.DISCONNECTED -> {
|
|
Log.d(TAG, "Not notifying new listener (state is DISCONNECTED)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the gateway client for MainActivity to interact with.
|
|
*/
|
|
fun getGatewayClient(): GatewayClient? = gatewayClient
|
|
|
|
/**
|
|
* Reconnect with a new access token (after token refresh).
|
|
*/
|
|
fun reconnectWithToken(newToken: String) {
|
|
Log.d(TAG, "Reconnecting with new token")
|
|
gatewayClient?.disconnect()
|
|
|
|
// Recreate client with new token
|
|
gatewayClient = GatewayClient(
|
|
context = this,
|
|
accessToken = newToken,
|
|
listener = createForwardingListener()
|
|
)
|
|
|
|
gatewayClient?.connect()
|
|
}
|
|
|
|
/**
|
|
* Acquire partial wake lock for active conversation mode.
|
|
*/
|
|
fun acquireWakeLock() {
|
|
if (wakeLock?.isHeld != true) {
|
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
wakeLock = powerManager.newWakeLock(
|
|
PowerManager.PARTIAL_WAKE_LOCK,
|
|
"Alfred::ConversationWakeLock"
|
|
)
|
|
wakeLock?.acquire(10 * 60 * 1000L) // 10 minute timeout
|
|
Log.d(TAG, "Wake lock acquired")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release wake lock when conversation ends.
|
|
*/
|
|
fun releaseWakeLock() {
|
|
if (wakeLock?.isHeld == true) {
|
|
wakeLock?.release()
|
|
wakeLock = null
|
|
Log.d(TAG, "Wake lock released")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start wake word detection for continuous listening.
|
|
*/
|
|
fun startWakeWord() {
|
|
Log.d(TAG, "startWakeWord called")
|
|
|
|
if (wakeWordDetector == null) {
|
|
Log.d(TAG, "Creating wake word detector")
|
|
wakeWordDetector = WakeWordDetector(
|
|
context = this,
|
|
onWakeWordDetected = {
|
|
Log.d(TAG, "Wake word detected in service")
|
|
// IMMEDIATELY stop the wake word detector to prevent duplicate detections
|
|
wakeWordDetector?.stop()
|
|
updateNotification("Connected")
|
|
// Notify MainScreen via callback
|
|
externalListener?.onWakeWordDetected()
|
|
},
|
|
onError = { error ->
|
|
Log.w(TAG, "Wake word error (auto-restarting): $error")
|
|
// Auto-restart on error (except permissions)
|
|
if (!error.contains("permission", ignoreCase = true)) {
|
|
Handler(Looper.getMainLooper()).postDelayed({
|
|
if (wakeWordEnabled) {
|
|
Log.d(TAG, "Auto-restarting wake word after error")
|
|
wakeWordDetector?.start()
|
|
}
|
|
}, 1000)
|
|
}
|
|
},
|
|
onInitialized = {
|
|
Log.d(TAG, "Wake word initialized in service")
|
|
wakeWordDetector?.start()
|
|
updateNotification("Listening for wake word...")
|
|
}
|
|
)
|
|
|
|
// Initialize for the first time
|
|
serviceScope.launch {
|
|
wakeWordDetector?.initialize()
|
|
}
|
|
} else {
|
|
// Detector already exists, just restart it
|
|
Log.d(TAG, "Wake word detector already exists, restarting")
|
|
if (!wakeWordDetector!!.isListening()) {
|
|
wakeWordDetector?.start()
|
|
updateNotification("Listening for wake word...")
|
|
} else {
|
|
Log.d(TAG, "Wake word detector already listening")
|
|
}
|
|
}
|
|
|
|
wakeWordEnabled = true
|
|
Log.d(TAG, "Wake word enabled")
|
|
}
|
|
|
|
/**
|
|
* Stop wake word detection.
|
|
*/
|
|
fun stopWakeWord() {
|
|
Log.d(TAG, "Stopping wake word")
|
|
wakeWordDetector?.stop()
|
|
wakeWordEnabled = false
|
|
updateNotification("Connected")
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
Log.d(TAG, "Service destroyed")
|
|
wakeWordDetector?.destroy()
|
|
wakeWordDetector = null
|
|
gatewayClient?.disconnect()
|
|
gatewayClient = null
|
|
releaseWakeLock()
|
|
serviceScope.cancel()
|
|
super.onDestroy()
|
|
}
|
|
|
|
companion object {
|
|
/**
|
|
* Start the foreground service.
|
|
*/
|
|
fun start(context: Context, gatewayUrl: String, accessToken: String, userId: String) {
|
|
val intent = Intent(context, AlfredConnectionService::class.java).apply {
|
|
putExtra("GATEWAY_URL", gatewayUrl)
|
|
putExtra("ACCESS_TOKEN", accessToken)
|
|
putExtra("USER_ID", userId)
|
|
}
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
context.startForegroundService(intent)
|
|
} else {
|
|
context.startService(intent)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the foreground service.
|
|
*/
|
|
fun stop(context: Context) {
|
|
val intent = Intent(context, AlfredConnectionService::class.java)
|
|
context.stopService(intent)
|
|
}
|
|
}
|
|
}
|