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
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user