Files
alfred-mobile/app/src/main/java/com/openclaw/alfred/service/AlfredConnectionService.kt
jknapp 6d4ae2e5c3 Initial commit: Alfred Mobile - AI Assistant Android App
- 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
2026-02-09 11:12:51 -08:00

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