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