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:
2026-02-09 11:12:51 -08:00
commit 6d4ae2e5c3
92 changed files with 15173 additions and 0 deletions

148
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,148 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
kotlin("kapt")
}
android {
namespace = "com.openclaw.alfred"
compileSdk = 34
defaultConfig {
applicationId = "com.openclaw.alfred"
minSdk = 26
targetSdk = 34
versionCode = 35
versionName = "1.4.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// Load secrets from secrets.properties
val secretsFile = rootProject.file("secrets.properties")
val secrets = Properties()
if (secretsFile.exists()) {
secrets.load(FileInputStream(secretsFile))
}
// Inject into BuildConfig (NOT committed to git)
buildConfigField("String", "AUTHENTIK_URL", "\"${secrets.getProperty("AUTHENTIK_URL", "")}\"")
buildConfigField("String", "AUTHENTIK_CLIENT_ID", "\"${secrets.getProperty("AUTHENTIK_CLIENT_ID", "")}\"")
buildConfigField("String", "OAUTH_REDIRECT_URI", "\"${secrets.getProperty("OAUTH_REDIRECT_URI", "")}\"")
buildConfigField("String", "GATEWAY_URL", "\"${secrets.getProperty("GATEWAY_URL", "")}\"")
buildConfigField("String", "ELEVENLABS_API_KEY", "\"${secrets.getProperty("ELEVENLABS_API_KEY", "")}\"")
buildConfigField("String", "ELEVENLABS_VOICE_ID", "\"${secrets.getProperty("ELEVENLABS_VOICE_ID", "")}\"")
// Manifest placeholders for OAuth redirect
manifestPlaceholders["appAuthRedirectScheme"] = "alfredmobile"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
// Jetpack Compose
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// Hilt Dependency Injection
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Retrofit for HTTP
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Room Database
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
kapt("androidx.room:room-compiler:$roomVersion")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// WorkManager for background tasks
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// OAuth2 Authentication
implementation("net.openid:appauth:0.11.1")
// Firebase Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
// Vosk speech recognition for wake word
implementation("com.alphacephei:vosk-android:0.3.47")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
// Allow references to generated code
kapt {
correctErrorTypes = true
}

27
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,27 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Keep data classes for Retrofit/Gson
-keep class com.openclaw.alfred.data.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
# Retrofit
-dontwarn retrofit2.**
-keep class retrofit2.** { *; }
# OkHttp
-dontwarn okhttp3.**
-keep class okhttp3.** { *; }
# Gson
-keep class com.google.gson.** { *; }
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".AlfredApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Alfred"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Alfred"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- OAuth Callback Activity - IMPORTANT! -->
<activity
android:name=".auth.OAuthCallbackActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="alfredmobile"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
<!-- Alarm Activity - Full screen alarm -->
<activity
android:name=".alarm.AlarmActivity"
android:exported="false"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:theme="@style/Theme.Alfred" />
<!-- Alarm Dismiss Receiver -->
<receiver
android:name=".alarm.AlarmDismissReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.openclaw.alfred.DISMISS_ALARM" />
</intent-filter>
</receiver>
<!-- Firebase Cloud Messaging Service -->
<service
android:name=".fcm.AlfredFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Alfred Connection Foreground Service -->
<service
android:name=".service.AlfredConnectionService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -0,0 +1,9 @@
US English model for mobile Vosk applications
Copyright 2020 Alpha Cephei Inc
Accuracy: 10.38 (tedlium test) 9.85 (librispeech test-clean)
Speed: 0.11xRT (desktop)
Latency: 0.15s (right context)

Binary file not shown.

View File

@@ -0,0 +1,7 @@
--sample-frequency=16000
--use-energy=false
--num-mel-bins=40
--num-ceps=40
--low-freq=20
--high-freq=7600
--allow-downsample=true

View File

@@ -0,0 +1,10 @@
--min-active=200
--max-active=3000
--beam=10.0
--lattice-beam=2.0
--acoustic-scale=1.0
--frame-subsampling-factor=3
--endpoint.silence-phones=1:2:3:4:5:6:7:8:9:10
--endpoint.rule2.min-trailing-silence=0.5
--endpoint.rule3.min-trailing-silence=0.75
--endpoint.rule4.min-trailing-silence=1.0

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,17 @@
10015
10016
10017
10018
10019
10020
10021
10022
10023
10024
10025
10026
10027
10028
10029
10030
10031

View File

@@ -0,0 +1,166 @@
1 nonword
2 begin
3 end
4 internal
5 singleton
6 nonword
7 begin
8 end
9 internal
10 singleton
11 begin
12 end
13 internal
14 singleton
15 begin
16 end
17 internal
18 singleton
19 begin
20 end
21 internal
22 singleton
23 begin
24 end
25 internal
26 singleton
27 begin
28 end
29 internal
30 singleton
31 begin
32 end
33 internal
34 singleton
35 begin
36 end
37 internal
38 singleton
39 begin
40 end
41 internal
42 singleton
43 begin
44 end
45 internal
46 singleton
47 begin
48 end
49 internal
50 singleton
51 begin
52 end
53 internal
54 singleton
55 begin
56 end
57 internal
58 singleton
59 begin
60 end
61 internal
62 singleton
63 begin
64 end
65 internal
66 singleton
67 begin
68 end
69 internal
70 singleton
71 begin
72 end
73 internal
74 singleton
75 begin
76 end
77 internal
78 singleton
79 begin
80 end
81 internal
82 singleton
83 begin
84 end
85 internal
86 singleton
87 begin
88 end
89 internal
90 singleton
91 begin
92 end
93 internal
94 singleton
95 begin
96 end
97 internal
98 singleton
99 begin
100 end
101 internal
102 singleton
103 begin
104 end
105 internal
106 singleton
107 begin
108 end
109 internal
110 singleton
111 begin
112 end
113 internal
114 singleton
115 begin
116 end
117 internal
118 singleton
119 begin
120 end
121 internal
122 singleton
123 begin
124 end
125 internal
126 singleton
127 begin
128 end
129 internal
130 singleton
131 begin
132 end
133 internal
134 singleton
135 begin
136 end
137 internal
138 singleton
139 begin
140 end
141 internal
142 singleton
143 begin
144 end
145 internal
146 singleton
147 begin
148 end
149 internal
150 singleton
151 begin
152 end
153 internal
154 singleton
155 begin
156 end
157 internal
158 singleton
159 begin
160 end
161 internal
162 singleton
163 begin
164 end
165 internal
166 singleton

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,3 @@
[
1.682383e+11 -1.1595e+10 -1.521733e+10 4.32034e+09 -2.257938e+10 -1.969666e+10 -2.559265e+10 -1.535687e+10 -1.276854e+10 -4.494483e+09 -1.209085e+10 -5.64008e+09 -1.134847e+10 -3.419512e+09 -1.079542e+10 -4.145463e+09 -6.637486e+09 -1.11318e+09 -3.479773e+09 -1.245932e+08 -1.386961e+09 6.560655e+07 -2.436518e+08 -4.032432e+07 4.620046e+08 -7.714964e+07 9.551484e+08 -4.119761e+08 8.208582e+08 -7.117156e+08 7.457703e+08 -4.3106e+08 1.202726e+09 2.904036e+08 1.231931e+09 3.629848e+08 6.366939e+08 -4.586172e+08 -5.267629e+08 -3.507819e+08 1.679838e+09
1.741141e+13 8.92488e+11 8.743834e+11 8.848896e+11 1.190313e+12 1.160279e+12 1.300066e+12 1.005678e+12 9.39335e+11 8.089614e+11 7.927041e+11 6.882427e+11 6.444235e+11 5.151451e+11 4.825723e+11 3.210106e+11 2.720254e+11 1.772539e+11 1.248102e+11 6.691599e+10 3.599804e+10 1.207574e+10 1.679301e+09 4.594778e+08 5.821614e+09 1.451758e+10 2.55803e+10 3.43277e+10 4.245286e+10 4.784859e+10 4.988591e+10 4.925451e+10 5.074584e+10 4.9557e+10 4.407876e+10 3.421443e+10 3.138606e+10 2.539716e+10 1.948134e+10 1.381167e+10 0 ]

View File

@@ -0,0 +1 @@
# configuration file for apply-cmvn-online, used in the script ../local/run_online_decoding.sh

View File

@@ -0,0 +1,2 @@
--left-context=3
--right-context=3

View File

@@ -0,0 +1,16 @@
package com.openclaw.alfred
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* Main application class for Alfred Mobile.
* Annotated with @HiltAndroidApp to enable Hilt dependency injection.
*/
@HiltAndroidApp
class AlfredApplication : Application() {
override fun onCreate() {
super.onCreate()
// Application-level initialization
}
}

View File

@@ -0,0 +1,330 @@
package com.openclaw.alfred
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.openclaw.alfred.auth.AuthManager
import com.openclaw.alfred.notifications.NotificationHelper
import com.openclaw.alfred.service.AlfredConnectionService
import com.openclaw.alfred.ui.screens.LoginScreen
import com.openclaw.alfred.ui.screens.MainScreen
import com.openclaw.alfred.ui.theme.AlfredTheme
import dagger.hilt.android.AndroidEntryPoint
/**
* Main entry point for the Alfred Mobile app.
* Handles OAuth authentication flow.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val TAG = "MainActivity"
private lateinit var authManager: AuthManager
private var isLoggedIn = mutableStateOf(false)
private var accessToken = mutableStateOf("")
// Service binding
private var connectionService = mutableStateOf<AlfredConnectionService?>(null)
private var serviceBound = mutableStateOf(false)
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "Service connected")
val binder = service as AlfredConnectionService.LocalBinder
connectionService.value = binder.getService()
serviceBound.value = true
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d(TAG, "Service disconnected")
connectionService.value = null
serviceBound.value = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate called")
// Initialize notification channel
NotificationHelper.createNotificationChannel(this)
authManager = AuthManager(this)
// Check if token needs refresh
checkAndRefreshToken()
setContent {
AlfredTheme {
// Check if this is first run (no gateway URL configured)
val prefs = getSharedPreferences("alfred_settings", Context.MODE_PRIVATE)
val gatewayUrl = prefs.getString("gateway_url", null)
var showSetup = remember { mutableStateOf(gatewayUrl == null) }
when {
showSetup.value -> {
// First run - show setup dialog
FirstRunSetup(
onComplete = { url ->
prefs.edit().putString("gateway_url", url).apply()
showSetup.value = false
}
)
}
isLoggedIn.value -> {
// Show main app UI
MainScreen(
connectionService = connectionService.value,
serviceBound = serviceBound.value,
onLogout = {
stopService()
authManager.logout()
isLoggedIn.value = false
},
onAuthError = {
// Connection got 401 - try to refresh token
Log.w(TAG, "Auth error from connection, attempting token refresh")
refreshTokenOrLogout()
}
)
}
else -> {
// Show login screen
LoginScreen(
onLoginClick = {
authManager.startLogin(this) { success, _ ->
if (success) {
isLoggedIn.value = true
}
}
}
)
}
}
}
}
}
private fun checkAndRefreshToken() {
if (!authManager.isLoggedIn()) {
Log.d(TAG, "Not logged in")
stopService()
isLoggedIn.value = false
accessToken.value = ""
return
}
if (authManager.needsRefresh()) {
Log.d(TAG, "Token needs refresh, refreshing...")
refreshTokenOrLogout()
} else {
Log.d(TAG, "Token is still valid")
val token = authManager.getAccessToken() ?: ""
accessToken.value = token
isLoggedIn.value = true
// Start/bind service if not already bound
if (!serviceBound.value && token.isNotEmpty()) {
startAndBindService(token)
}
}
}
private fun refreshTokenOrLogout() {
authManager.refreshToken(
onSuccess = {
Log.d(TAG, "Token refresh successful")
val newToken = authManager.getAccessToken() ?: ""
accessToken.value = newToken
isLoggedIn.value = true
// Update service with new token
if (serviceBound.value && newToken.isNotEmpty()) {
connectionService.value?.reconnectWithToken(newToken)
} else if (newToken.isNotEmpty()) {
startAndBindService(newToken)
}
},
onError = { error ->
Log.e(TAG, "Token refresh failed: $error - logging out")
stopService()
authManager.logout()
accessToken.value = ""
isLoggedIn.value = false
}
)
}
private fun startAndBindService(token: String) {
Log.d(TAG, "Starting and binding to connection service")
// Start foreground service
AlfredConnectionService.start(
context = this,
gatewayUrl = "ws://192.168.1.190:18790", // Proxy URL
accessToken = token,
userId = "shadow" // This will be extracted from JWT by service
)
// Bind to service
val intent = Intent(this, AlfredConnectionService::class.java)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun stopService() {
Log.d(TAG, "Stopping connection service")
if (serviceBound.value) {
unbindService(serviceConnection)
serviceBound.value = false
}
AlfredConnectionService.stop(this)
connectionService.value = null
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume called")
// Check if we just logged in (after OAuth callback) or if token needs refresh
checkAndRefreshToken()
}
override fun onDestroy() {
super.onDestroy()
// Unbind from service (but don't stop it - it continues in background)
if (serviceBound.value) {
unbindService(serviceConnection)
serviceBound.value = false
}
authManager.dispose()
}
}
/**
* First-run setup screen to configure gateway URL.
* Automatically adds wss:// or ws:// based on hostname.
*/
@Composable
fun FirstRunSetup(onComplete: (String) -> Unit) {
var hostname by remember { mutableStateOf("") }
var useInsecure by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
// Compute the full URL
val fullUrl = remember(hostname, useInsecure) {
if (hostname.isBlank()) {
""
} else {
val cleaned = hostname.trim()
.removePrefix("ws://")
.removePrefix("wss://")
.removePrefix("http://")
.removePrefix("https://")
val protocol = if (useInsecure) "ws://" else "wss://"
"$protocol$cleaned"
}
}
AlertDialog(
onDismissRequest = { /* Can't dismiss - required setup */ },
title = {
Text(
text = "Welcome to Alfred Mobile",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Enter your OpenClaw Gateway hostname or IP address.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = hostname,
onValueChange = {
hostname = it
errorMessage = ""
},
label = { Text("Gateway Hostname") },
placeholder = { Text("alfred.yourdomain.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = errorMessage.isNotEmpty()
)
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
androidx.compose.material3.Checkbox(
checked = useInsecure,
onCheckedChange = { useInsecure = it }
)
Text(
text = "Use insecure connection (ws://)",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp)
)
}
if (fullUrl.isNotEmpty()) {
Text(
text = "Will connect to: $fullUrl",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
}
if (errorMessage.isNotEmpty()) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
}
Text(
text = "Example: alfred.yourdomain.com or 192.168.1.169:18790",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
},
confirmButton = {
Button(
onClick = {
val trimmed = hostname.trim()
if (trimmed.isEmpty()) {
errorMessage = "Hostname is required"
} else {
onComplete(fullUrl)
}
}
) {
Text("Continue")
}
}
)
}

View File

@@ -0,0 +1,218 @@
package com.openclaw.alfred.alarm
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.lifecycleScope
import com.openclaw.alfred.BuildConfig
import com.openclaw.alfred.ui.theme.AlfredTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* Full-screen alarm activity that displays when an alarm fires.
* Requires user to dismiss the alarm before continuing.
*/
class AlarmActivity : ComponentActivity() {
private val httpClient = OkHttpClient()
companion object {
const val EXTRA_ALARM_ID = "alarm_id"
const val EXTRA_TITLE = "title"
const val EXTRA_MESSAGE = "message"
const val EXTRA_TIMESTAMP = "timestamp"
private const val TAG = "AlarmActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get alarm details from intent
val alarmId = intent.getStringExtra(EXTRA_ALARM_ID) ?: "unknown"
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Alarm"
val message = intent.getStringExtra(EXTRA_MESSAGE) ?: "Alarm!"
val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, System.currentTimeMillis())
// Show on lock screen and turn screen on (API 27+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
// Additional flags for lock screen display
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
Log.d("AlarmActivity", "AlarmActivity created: id=$alarmId title=$title message=$message")
setContent {
AlfredTheme {
AlarmScreen(
title = title,
message = message,
onDismiss = {
Log.d(TAG, "Dismiss button clicked for alarm: $alarmId")
// Stop ALL active alarms (handles duplicates from WebSocket + FCM)
val alarmManager = AlarmManager.getInstance(this)
Log.d(TAG, "Calling dismissAll() to clear all active alarms")
alarmManager.dismissAll()
// Broadcast dismissal to all devices via proxy
broadcastDismissal(alarmId)
Log.d(TAG, "Finishing activity")
// Close activity
finish()
}
)
}
}
}
/**
* Broadcast alarm dismissal to all user's devices via proxy.
*/
private fun broadcastDismissal(alarmId: String) {
lifecycleScope.launch(Dispatchers.IO) {
try {
val userId = getUserId()
val proxyUrl = getProxyUrl()
if (userId == null) {
Log.w(TAG, "Cannot broadcast dismissal: userId not available")
return@launch
}
Log.d(TAG, "Broadcasting dismissal for alarm $alarmId to user $userId")
val json = JSONObject().apply {
put("userId", userId)
put("alarmId", alarmId)
}
val body = json.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$proxyUrl/api/alarm/dismiss")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Dismissal broadcast successful")
} else {
Log.e(TAG, "Dismissal broadcast failed: ${response.code}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to broadcast dismissal", e)
// Local dismissal still worked, just log the error
}
}
}
/**
* Get userId from SharedPreferences.
*/
private fun getUserId(): String? {
val prefs = getSharedPreferences("alfred_auth", MODE_PRIVATE)
return prefs.getString("user_id", null)
}
/**
* Get proxy URL from BuildConfig.
*/
private fun getProxyUrl(): String {
return BuildConfig.GATEWAY_URL.replace("wss://", "https://").replace("ws://", "http://")
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlarmScreen(
title: String,
message: String,
onDismiss: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("$title") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Large alarm icon
Icon(
imageVector = Icons.Default.Alarm,
contentDescription = "Alarm",
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(32.dp))
// Alarm message
Text(
text = message,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
fontSize = 28.sp
)
Spacer(modifier = Modifier.height(48.dp))
// Large dismiss button
Button(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text(
text = "DISMISS ALARM",
style = MaterialTheme.typography.titleLarge,
fontSize = 24.sp
)
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.openclaw.alfred.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* BroadcastReceiver for handling alarm dismiss actions from notifications.
*/
class AlarmDismissReceiver : BroadcastReceiver() {
private val TAG = "AlarmDismissReceiver"
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent == null) return
when (intent.action) {
"com.openclaw.alfred.DISMISS_ALARM" -> {
val alarmId = intent.getStringExtra("alarm_id")
if (alarmId != null) {
Log.d(TAG, "Dismissing alarm: $alarmId")
// Get AlarmManager singleton and dismiss the alarm
val alarmManager = AlarmManager.getInstance(context)
alarmManager.dismissAlarm(alarmId)
Log.d(TAG, "Alarm dismissed: $alarmId")
}
}
}
}
}

View File

@@ -0,0 +1,255 @@
package com.openclaw.alfred.alarm
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Manages alarm playback with repeating sound and vibration.
* Singleton pattern so BroadcastReceiver can access the same instance.
*/
class AlarmManager private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: AlarmManager? = null
fun getInstance(context: Context): AlarmManager {
return instance ?: synchronized(this) {
instance ?: AlarmManager(context.applicationContext).also { instance = it }
}
}
}
private val TAG = "AlarmManager"
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var vibrateJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
// Callback for when alarms are dismissed
var onAlarmDismissed: ((String) -> Unit)? = null
data class ActiveAlarm(
val id: String,
val title: String,
val message: String,
val timestamp: Long
)
private val activeAlarms = mutableMapOf<String, ActiveAlarm>()
/**
* Start an alarm with repeating sound and vibration.
*/
fun startAlarm(
alarmId: String,
title: String,
message: String,
enableSound: Boolean = true,
enableVibrate: Boolean = true
) {
Log.d(TAG, "Starting alarm: $alarmId - $title: $message")
// Store active alarm
activeAlarms[alarmId] = ActiveAlarm(alarmId, title, message, System.currentTimeMillis())
// Check user preferences
val prefs = context.getSharedPreferences("alfred_settings", android.content.Context.MODE_PRIVATE)
val soundEnabled = prefs.getBoolean("alarm_sound_enabled", true)
val vibrateEnabled = prefs.getBoolean("alarm_vibrate_enabled", true)
// Start sound if enabled in both function param and settings
if (enableSound && soundEnabled) {
startAlarmSound()
}
// Start vibration if enabled in both function param and settings
if (enableVibrate && vibrateEnabled) {
startAlarmVibration()
}
}
/**
* Start repeating alarm sound.
*/
private fun startAlarmSound() {
try {
// Stop any existing playback
stopAlarmSound()
// Get alarm sound URI from preferences, or use default
val prefs = context.getSharedPreferences("alfred_settings", android.content.Context.MODE_PRIVATE)
val customUriString = prefs.getString("alarm_sound_uri", null)
val alarmUri = if (customUriString != null) {
try {
Uri.parse(customUriString)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse custom alarm URI, using default", e)
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
}
} else {
// Get default alarm sound URI
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
}
// Create MediaPlayer
mediaPlayer = MediaPlayer().apply {
setDataSource(context, alarmUri)
// Set audio attributes for alarm
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
// Loop the sound
isLooping = true
// Prepare and start
prepare()
start()
}
Log.d(TAG, "Alarm sound started: $alarmUri")
} catch (e: Exception) {
Log.e(TAG, "Failed to start alarm sound", e)
}
}
/**
* Start repeating alarm vibration.
*/
private fun startAlarmVibration() {
try {
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
if (vibrator?.hasVibrator() == true) {
// Vibrate pattern: [delay, vibrate, sleep, vibrate, sleep]
// 0ms delay, 500ms vibrate, 500ms sleep, repeat
val pattern = longArrayOf(0, 500, 500)
// Create vibration effect with repeating pattern
val effect = VibrationEffect.createWaveform(pattern, 0) // 0 = repeat from index 0
vibrator?.vibrate(effect)
Log.d(TAG, "Alarm vibration started")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start alarm vibration", e)
}
}
/**
* Stop alarm sound.
*/
private fun stopAlarmSound() {
try {
mediaPlayer?.apply {
if (isPlaying) {
stop()
}
release()
}
mediaPlayer = null
Log.d(TAG, "Alarm sound stopped")
} catch (e: Exception) {
Log.e(TAG, "Failed to stop alarm sound", e)
}
}
/**
* Stop alarm vibration.
*/
private fun stopAlarmVibration() {
try {
vibrateJob?.cancel()
vibrateJob = null
vibrator?.cancel()
vibrator = null
Log.d(TAG, "Alarm vibration stopped")
} catch (e: Exception) {
Log.e(TAG, "Failed to stop alarm vibration", e)
}
}
/**
* Dismiss a specific alarm.
*/
fun dismissAlarm(alarmId: String) {
Log.d(TAG, "Dismissing alarm: $alarmId")
activeAlarms.remove(alarmId)
// Cancel the notification
com.openclaw.alfred.notifications.NotificationHelper.cancelAlarmNotification(context, alarmId)
// Notify callback (for cross-device sync)
onAlarmDismissed?.invoke(alarmId)
// If no more active alarms, stop sound and vibration
if (activeAlarms.isEmpty()) {
stopAll()
}
}
/**
* Dismiss all active alarms.
*/
fun dismissAll() {
Log.d(TAG, "Dismissing all alarms")
// Notify callback for each alarm (for cross-device sync)
activeAlarms.keys.forEach { alarmId ->
onAlarmDismissed?.invoke(alarmId)
}
activeAlarms.clear()
// Cancel ALL alarm notifications (handles any lingering notifications)
com.openclaw.alfred.notifications.NotificationHelper.cancelAllAlarmNotifications(context)
stopAll()
}
/**
* Stop all alarm sounds and vibrations.
*/
private fun stopAll() {
stopAlarmSound()
stopAlarmVibration()
}
/**
* Check if there are any active alarms.
*/
fun hasActiveAlarms(): Boolean = activeAlarms.isNotEmpty()
/**
* Get all active alarms.
*/
fun getActiveAlarms(): List<ActiveAlarm> = activeAlarms.values.toList()
/**
* Cleanup resources.
*/
fun destroy() {
dismissAll()
}
}

View File

@@ -0,0 +1,264 @@
package com.openclaw.alfred.auth
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.activity.ComponentActivity
import net.openid.appauth.*
/**
* Manages OAuth authentication flow with Authentik.
* Handles login, token storage, and token refresh.
*/
class AuthManager(private val context: Context) {
private val TAG = "AuthManager"
private val authService: AuthorizationService = AuthorizationService(context)
private val serviceConfig = AuthorizationServiceConfiguration(
OAuthConfig.AUTHORIZATION_ENDPOINT,
OAuthConfig.TOKEN_ENDPOINT
)
private val prefs = context.getSharedPreferences(OAuthConfig.PREFS_NAME, Context.MODE_PRIVATE)
/**
* Check if user is currently logged in (has valid token).
*/
fun isLoggedIn(): Boolean {
val token = prefs.getString(OAuthConfig.KEY_ACCESS_TOKEN, null)
val expiry = prefs.getLong(OAuthConfig.KEY_TOKEN_EXPIRY, 0)
Log.d(TAG, "isLoggedIn check: token=${token?.take(10)}..., expiry=$expiry")
if (token.isNullOrEmpty()) {
Log.d(TAG, "isLoggedIn: false (no token)")
return false
}
// Check if token is expired (with 30 second buffer for safety)
val now = System.currentTimeMillis()
val bufferMs = 30 * 1000 // 30 second buffer (reduced from 60s for longer persistence)
val isValid = expiry > (now + bufferMs)
Log.d(TAG, "isLoggedIn: $isValid (now=$now, expiry=$expiry, diff=${expiry - now}ms, buffer=${bufferMs}ms)")
return isValid
}
/**
* Get the current access token (if logged in).
*/
fun getAccessToken(): String? {
return if (isLoggedIn()) {
prefs.getString(OAuthConfig.KEY_ACCESS_TOKEN, null)
} else {
null
}
}
/**
* Start the OAuth login flow.
* Opens browser for Authentik authentication.
*/
fun startLogin(activity: ComponentActivity, onComplete: (Boolean, String?) -> Unit) {
Log.d(TAG, "Starting OAuth login flow")
val authRequest = AuthorizationRequest.Builder(
serviceConfig,
OAuthConfig.CLIENT_ID,
ResponseTypeValues.CODE,
OAuthConfig.REDIRECT_URI
)
.setScope(OAuthConfig.SCOPE)
.build()
// Store the request for later validation
val authRequestJson = authRequest.jsonSerializeString()
prefs.edit().putString("pending_auth_request", authRequestJson).apply()
val authIntent = authService.getAuthorizationRequestIntent(authRequest)
activity.startActivity(authIntent)
}
/**
* Handle OAuth callback after user authorizes.
* Called from OAuthCallbackActivity.
*/
fun handleAuthorizationResponse(
intent: Intent,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
// Try AppAuth's standard parsing first
var response = AuthorizationResponse.fromIntent(intent)
var exception = AuthorizationException.fromIntent(intent)
// If that fails, manually parse the redirect URI
if (response == null && exception == null) {
val data = intent.data
if (data != null) {
Log.d(TAG, "Manually parsing OAuth response from: $data")
// Retrieve the stored auth request
val authRequestJson = prefs.getString("pending_auth_request", null)
if (authRequestJson != null) {
try {
val authRequest = AuthorizationRequest.jsonDeserialize(authRequestJson)
response = AuthorizationResponse.Builder(authRequest)
.fromUri(data)
.build()
// Clear the pending request
prefs.edit().remove("pending_auth_request").apply()
} catch (e: Exception) {
Log.e(TAG, "Failed to deserialize auth request", e)
exception = AuthorizationException.fromTemplate(
AuthorizationException.GeneralErrors.JSON_DESERIALIZATION_ERROR,
e
)
}
} else {
Log.e(TAG, "No pending auth request found")
exception = AuthorizationException.GeneralErrors.PROGRAM_CANCELED_AUTH_FLOW
}
}
}
when {
response != null -> {
Log.d(TAG, "Authorization successful, exchanging code for token")
exchangeCodeForToken(response, onSuccess, onError)
}
exception != null -> {
Log.e(TAG, "Authorization failed: ${exception.message}")
onError("Authorization failed: ${exception.message}")
}
else -> {
Log.e(TAG, "Authorization failed: Unknown error")
onError("Authorization failed: Unknown error")
}
}
}
/**
* Exchange authorization code for access token.
*/
private fun exchangeCodeForToken(
authResponse: AuthorizationResponse,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val tokenRequest = authResponse.createTokenExchangeRequest()
authService.performTokenRequest(tokenRequest) { tokenResponse, exception ->
when {
tokenResponse != null -> {
Log.d(TAG, "Token exchange successful")
saveTokens(tokenResponse)
onSuccess()
}
exception != null -> {
Log.e(TAG, "Token exchange failed: ${exception.message}")
onError("Token exchange failed: ${exception.message}")
}
else -> {
Log.e(TAG, "Token exchange failed: Unknown error")
onError("Token exchange failed: Unknown error")
}
}
}
}
/**
* Save tokens to SharedPreferences.
*/
private fun saveTokens(tokenResponse: TokenResponse) {
val expiresIn = tokenResponse.accessTokenExpirationTime ?: 0L
Log.d(TAG, "Saving tokens: access=${tokenResponse.accessToken?.take(10)}..., expiry=$expiresIn")
prefs.edit().apply {
putString(OAuthConfig.KEY_ACCESS_TOKEN, tokenResponse.accessToken)
putString(OAuthConfig.KEY_REFRESH_TOKEN, tokenResponse.refreshToken)
putString(OAuthConfig.KEY_ID_TOKEN, tokenResponse.idToken)
putLong(OAuthConfig.KEY_TOKEN_EXPIRY, expiresIn)
apply()
}
Log.d(TAG, "Tokens saved successfully, verifying...")
// Verify the save
val saved = prefs.getString(OAuthConfig.KEY_ACCESS_TOKEN, null)
val savedExpiry = prefs.getLong(OAuthConfig.KEY_TOKEN_EXPIRY, 0)
Log.d(TAG, "Verification: token=${saved?.take(10)}..., expiry=$savedExpiry")
}
/**
* Check if token needs refresh (expired or expiring soon).
*/
fun needsRefresh(): Boolean {
val expiry = prefs.getLong(OAuthConfig.KEY_TOKEN_EXPIRY, 0)
val now = System.currentTimeMillis()
val bufferMs = 5 * 60 * 1000 // 5 minute buffer - refresh before expiry
val needsRefresh = expiry < (now + bufferMs)
Log.d(TAG, "needsRefresh: $needsRefresh (expiry in ${(expiry - now) / 1000}s)")
return needsRefresh
}
/**
* Refresh the access token using the refresh token.
* @return true if refresh successful, false if refresh token is invalid/missing
*/
fun refreshToken(onSuccess: () -> Unit, onError: (String) -> Unit) {
val refreshToken = prefs.getString(OAuthConfig.KEY_REFRESH_TOKEN, null)
if (refreshToken.isNullOrEmpty()) {
Log.w(TAG, "No refresh token available, cannot refresh")
onError("No refresh token available")
return
}
Log.d(TAG, "Refreshing access token using refresh token")
val tokenRequest = TokenRequest.Builder(serviceConfig, OAuthConfig.CLIENT_ID)
.setGrantType(GrantTypeValues.REFRESH_TOKEN)
.setRefreshToken(refreshToken)
.build()
authService.performTokenRequest(tokenRequest) { tokenResponse, exception ->
when {
tokenResponse != null -> {
Log.d(TAG, "Token refresh successful")
saveTokens(tokenResponse)
onSuccess()
}
exception != null -> {
Log.e(TAG, "Token refresh failed: ${exception.message}")
// Clear tokens on refresh failure (refresh token is invalid)
logout()
onError("Token refresh failed: ${exception.message}")
}
else -> {
Log.e(TAG, "Token refresh failed: Unknown error")
logout()
onError("Token refresh failed: Unknown error")
}
}
}
}
/**
* Log out user and clear stored tokens.
*/
fun logout() {
Log.d(TAG, "Logging out user")
prefs.edit().clear().apply()
}
/**
* Clean up resources.
*/
fun dispose() {
authService.dispose()
}
}

View File

@@ -0,0 +1,14 @@
package com.openclaw.alfred.auth
/**
* Result of authentication
*/
sealed class AuthResult {
data class Success(
val accessToken: String,
val refreshToken: String?,
val idToken: String?
) : AuthResult()
data class Error(val message: String) : AuthResult()
}

View File

@@ -0,0 +1,54 @@
package com.openclaw.alfred.auth
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import com.openclaw.alfred.MainActivity
/**
* Handles OAuth redirect callback from Authentik.
* This activity is launched when the user completes authentication.
*/
class OAuthCallbackActivity : ComponentActivity() {
private val TAG = "OAuthCallback"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "OAuth callback received")
Log.d(TAG, "Intent action: ${intent.action}")
Log.d(TAG, "Intent data: ${intent.data}")
Log.d(TAG, "Intent extras: ${intent.extras}")
val authManager = AuthManager(this)
authManager.handleAuthorizationResponse(
intent = intent,
onSuccess = {
Log.d(TAG, "Login successful!")
Toast.makeText(this, "Login successful!", Toast.LENGTH_SHORT).show()
// Navigate back to MainActivity
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(mainIntent)
finish()
},
onError = { error ->
Log.e(TAG, "Login failed: $error")
Toast.makeText(this, "Login failed: $error", Toast.LENGTH_LONG).show()
// Navigate back to MainActivity (will show login screen)
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(mainIntent)
finish()
}
)
}
}

View File

@@ -0,0 +1,33 @@
package com.openclaw.alfred.auth
import android.net.Uri
import com.openclaw.alfred.BuildConfig
/**
* OAuth configuration for Authentik authentication.
* Values injected from secrets.properties via BuildConfig.
*/
object OAuthConfig {
// Authentik OAuth endpoints
val AUTHORIZATION_ENDPOINT = Uri.parse("${BuildConfig.AUTHENTIK_URL}/application/o/authorize/")
val TOKEN_ENDPOINT = Uri.parse("${BuildConfig.AUTHENTIK_URL}/application/o/token/")
val USER_INFO_ENDPOINT = Uri.parse("${BuildConfig.AUTHENTIK_URL}/application/o/userinfo/")
// Client configuration
const val CLIENT_ID = BuildConfig.AUTHENTIK_CLIENT_ID
val REDIRECT_URI = Uri.parse(BuildConfig.OAUTH_REDIRECT_URI)
// OAuth scopes
const val SCOPE = "openid profile email"
// Gateway configuration
const val GATEWAY_URL = BuildConfig.GATEWAY_URL
// Token storage keys
const val PREFS_NAME = "alfred_auth"
const val KEY_ACCESS_TOKEN = "access_token"
const val KEY_REFRESH_TOKEN = "refresh_token"
const val KEY_ID_TOKEN = "id_token"
const val KEY_TOKEN_EXPIRY = "token_expiry"
}

View File

@@ -0,0 +1,163 @@
package com.openclaw.alfred.fcm
import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.openclaw.alfred.alarm.AlarmActivity
import com.openclaw.alfred.alarm.AlarmManager
import com.openclaw.alfred.notifications.NotificationHelper
/**
* Firebase Cloud Messaging service for handling push notifications.
* Used to wake the device when alarms trigger while screen is off.
*/
class AlfredFirebaseMessagingService : FirebaseMessagingService() {
private val TAG = "FCM"
/**
* Called when a new FCM token is generated.
* This happens on first install and periodically thereafter.
*/
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "New FCM token: $token")
// Store token for sending to proxy when connected
val prefs = getSharedPreferences("alfred_prefs", MODE_PRIVATE)
prefs.edit()
.putString("fcm_token", token)
.putBoolean("fcm_token_needs_sync", true)
.apply()
Log.d(TAG, "FCM token saved to preferences")
}
/**
* Called when a push notification is received while app is in foreground.
* For background/killed app, the system tray notification is shown automatically.
*/
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
Log.d(TAG, "Message received from: ${message.from}")
Log.d(TAG, "Message data: ${message.data}")
// Extract notification data
val messageType = message.data["type"]
val notificationType = message.data["notificationType"] ?: "alert"
val title = message.data["title"] ?: "AI Assistant"
val messageText = message.data["message"] ?: ""
val alarmId = message.data["alarmId"]
Log.d(TAG, "Notification: type=$notificationType title=$title message=$messageText")
// Handle alarm dismissal broadcast
if (messageType == "alarm_dismiss" && alarmId != null) {
Log.d(TAG, "Received alarm dismissal via FCM: $alarmId")
val alarmManager = AlarmManager.getInstance(this)
alarmManager.dismissAlarm(alarmId)
return
}
if (messageText.isNotEmpty()) {
if (notificationType == "alarm") {
Log.d(TAG, "FCM Alarm received - launching full-screen alarm activity")
// Generate alarm ID
val timestamp = System.currentTimeMillis()
val generatedAlarmId = alarmId ?: "fcm-alarm-$timestamp"
// Start alarm sound/vibration
val alarmManager = AlarmManager.getInstance(this)
alarmManager.startAlarm(
alarmId = generatedAlarmId,
title = title,
message = messageText,
enableSound = true,
enableVibrate = true
)
// Create intent for full-screen alarm activity
val alarmIntent = Intent(this, AlarmActivity::class.java).apply {
putExtra(AlarmActivity.EXTRA_ALARM_ID, generatedAlarmId)
putExtra(AlarmActivity.EXTRA_TITLE, title)
putExtra(AlarmActivity.EXTRA_MESSAGE, messageText)
putExtra(AlarmActivity.EXTRA_TIMESTAMP, timestamp)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
// Don't launch activity directly from background service (Android 13 restriction)
// Instead, rely on the notification's full-screen intent to show the alarm
// Create notification with full-screen intent for lock screen
val fullScreenPendingIntent = PendingIntent.getActivity(
this,
generatedAlarmId.hashCode(),
alarmIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Create dismiss action
val dismissIntent = Intent("com.openclaw.alfred.DISMISS_ALARM").apply {
putExtra("alarm_id", generatedAlarmId)
setPackage(packageName)
}
val dismissPendingIntent = PendingIntent.getBroadcast(
this,
(generatedAlarmId.hashCode() + 1),
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Show high-priority notification with full-screen intent
NotificationHelper.showAlarmNotification(
context = this,
alarmId = generatedAlarmId,
title = title,
message = messageText,
fullScreenIntent = fullScreenPendingIntent,
dismissAction = dismissPendingIntent
)
} else {
// For other notifications, show them directly
NotificationHelper.showNotification(
context = this,
title = title,
message = messageText,
autoCancel = true
)
}
}
}
/**
* Called when message couldn't be delivered within TTL.
*/
override fun onDeletedMessages() {
super.onDeletedMessages()
Log.w(TAG, "Messages deleted (exceeded TTL)")
}
/**
* Called when FCM server sends an error.
*/
override fun onMessageSent(msgId: String) {
super.onMessageSent(msgId)
Log.d(TAG, "Message sent: $msgId")
}
/**
* Called when sending a message failed.
*/
override fun onSendError(msgId: String, exception: Exception) {
super.onSendError(msgId, exception)
Log.e(TAG, "Send error for message $msgId", exception)
}
}

View File

@@ -0,0 +1,682 @@
package com.openclaw.alfred.gateway
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.openclaw.alfred.BuildConfig
import okhttp3.*
import java.util.concurrent.TimeUnit
/**
* WebSocket client for OpenClaw Gateway connection.
* Handles protocol handshake, message framing, and reconnection.
*/
class GatewayClient(
private val context: Context,
private val accessToken: String,
private val listener: GatewayListener
) {
private val TAG = "GatewayClient"
private val gson = Gson()
// Get gateway URL from preferences, fallback to BuildConfig
private fun getGatewayUrl(): String {
val prefs = context.getSharedPreferences("alfred_settings", Context.MODE_PRIVATE)
return prefs.getString("gateway_url", BuildConfig.GATEWAY_URL) ?: BuildConfig.GATEWAY_URL
}
private var webSocket: WebSocket? = null
private var isConnected = false
private var requestId = 0
// Extract user ID from JWT for session key
private val userId: String by lazy {
try {
// JWT format: header.payload.signature
val parts = accessToken.split(".")
if (parts.size >= 2) {
val payload = String(android.util.Base64.decode(parts[1], android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP))
val json = gson.fromJson(payload, JsonObject::class.java)
// Prefer username over email over sub for consistent, readable session keys
val username = json.get("preferred_username")?.asString
val email = json.get("email")?.asString
val sub = json.get("sub")?.asString
when {
!username.isNullOrEmpty() -> username
!email.isNullOrEmpty() -> email
!sub.isNullOrEmpty() -> sub
else -> "mobile"
}
} else {
"mobile"
}
} catch (e: Exception) {
Log.e(TAG, "Failed to extract user ID from token", e)
"mobile"
}
}
// Reconnection state
private var shouldReconnect = true
private var reconnectAttempts = 0
private val maxReconnectAttempts = 10
private val baseReconnectDelayMs = 1000L // Start with 1 second
private val maxReconnectDelayMs = 30000L // Max 30 seconds
private var reconnectHandler: android.os.Handler? = null
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // No timeout for WebSocket
.build()
/**
* Connect to the OpenClaw Gateway.
*/
fun connect() {
if (isConnected) {
Log.w(TAG, "Already connected")
return
}
// Close any existing WebSocket before creating a new one
webSocket?.let { existingWs ->
Log.d(TAG, "Closing existing WebSocket before reconnect")
try {
existingWs.close(1000, "Reconnecting")
} catch (e: Exception) {
Log.e(TAG, "Error closing existing WebSocket: ${e.message}")
}
webSocket = null
}
// Enable reconnection and reset state
shouldReconnect = true
val gatewayUrl = getGatewayUrl()
Log.d(TAG, "Connecting to $gatewayUrl")
val request = Request.Builder()
.url(gatewayUrl)
.addHeader("Authorization", "Bearer $accessToken")
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket opened")
listener.onConnecting()
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "<<< Received TEXT: $text")
handleMessage(text)
}
override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) {
val text = bytes.utf8()
Log.d(TAG, "<<< Received BINARY (converted): $text")
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closing: code=$code reason='$reason'")
isConnected = false
listener.onDisconnected()
webSocket.close(1000, null)
// Attempt reconnection unless explicitly disconnected
if (shouldReconnect) {
scheduleReconnect()
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: code=$code reason='$reason'")
isConnected = false
// Check for authentication failures (code 1008 = Policy Violation)
if (code == 1008 || reason.contains("Authentication", ignoreCase = true) ||
reason.contains("Invalid token", ignoreCase = true)) {
Log.w(TAG, "Connection closed due to authentication failure")
listener.onError("Authentication failed: $reason")
// Don't auto-reconnect on auth failures - let app handle token refresh
shouldReconnect = false
return
}
// Attempt reconnection unless explicitly disconnected
if (shouldReconnect) {
scheduleReconnect()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failure: ${t.message}, response code: ${response?.code}")
isConnected = false
// Check for authentication failures (401 Unauthorized)
if (response?.code == 401 || response?.code == 403) {
Log.w(TAG, "Connection failed due to authentication (${response.code})")
listener.onError("Authentication failed (401 Unauthorized)")
// Don't auto-reconnect on auth failures - let app handle token refresh
shouldReconnect = false
return
}
listener.onError(t.message ?: "Connection failed")
// Attempt reconnection unless explicitly disconnected
if (shouldReconnect) {
scheduleReconnect()
}
}
})
}
/**
* Schedule a reconnection attempt with exponential backoff.
*/
private fun scheduleReconnect() {
// Check if we've exceeded max attempts
if (reconnectAttempts >= maxReconnectAttempts) {
Log.e(TAG, "Max reconnection attempts ($maxReconnectAttempts) reached. Giving up.")
listener.onError("Connection lost - max retries exceeded")
shouldReconnect = false
return
}
// Check network availability
if (!isNetworkAvailable()) {
Log.w(TAG, "Network unavailable, waiting longer before retry...")
// Use longer delay when network is unavailable (10 seconds)
// Don't increment reconnectAttempts - we're not actually trying to connect
val delay = 10000L
Log.d(TAG, "Network unavailable - will check again in ${delay}ms (not counting as retry attempt)")
listener.onReconnecting(reconnectAttempts, delay)
// Cancel any pending reconnection
reconnectHandler?.removeCallbacksAndMessages(null)
// Schedule reconnection
reconnectHandler = android.os.Handler(android.os.Looper.getMainLooper())
reconnectHandler?.postDelayed({
if (shouldReconnect && !isConnected) {
// Check network again before attempting
if (isNetworkAvailable()) {
Log.d(TAG, "Network restored, attempting reconnection")
connect()
} else {
Log.d(TAG, "Network still unavailable, rescheduling...")
scheduleReconnect()
}
}
}, delay)
return
}
// Calculate delay with exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max)
val delay = minOf(
baseReconnectDelayMs * (1 shl reconnectAttempts), // 2^n backoff
maxReconnectDelayMs
)
reconnectAttempts++
Log.d(TAG, "Scheduling reconnect attempt $reconnectAttempts in ${delay}ms")
listener.onReconnecting(reconnectAttempts, delay)
// Cancel any pending reconnection
reconnectHandler?.removeCallbacksAndMessages(null)
// Schedule reconnection
reconnectHandler = android.os.Handler(android.os.Looper.getMainLooper())
reconnectHandler?.postDelayed({
if (shouldReconnect && !isConnected) {
Log.d(TAG, "Attempting reconnection (attempt $reconnectAttempts)")
connect()
}
}, delay)
}
/**
* Reset reconnection state on successful connection.
*/
private fun resetReconnectionState() {
reconnectAttempts = 0
reconnectHandler?.removeCallbacksAndMessages(null)
reconnectHandler = null
}
/**
* Check if device has network connectivity.
*/
private fun isNetworkAvailable(): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
if (connectivityManager == null) {
Log.w(TAG, "ConnectivityManager not available")
return true // Assume available if we can't check
}
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
/**
* Handle incoming WebSocket messages.
*/
private fun handleMessage(text: String) {
try {
val json = gson.fromJson(text, JsonObject::class.java)
val type = json.get("type")?.asString
when (type) {
"event" -> handleEvent(json)
"res" -> handleResponse(json)
"alarm_dismiss" -> {
// Handle alarm dismissal broadcast from proxy
val alarmId = json.get("alarmId")?.asString
if (alarmId != null) {
Log.d(TAG, "Received alarm dismiss broadcast: $alarmId")
listener.onAlarmDismissed(alarmId)
}
}
else -> Log.w(TAG, "Unknown message type: $type")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
}
}
/**
* Handle gateway events.
*/
private fun handleEvent(json: JsonObject) {
val event = json.get("event")?.asString
val payload = json.getAsJsonObject("payload")
Log.d(TAG, "handleEvent: event=$event")
when (event) {
"connect.challenge" -> {
// Gateway sent challenge, respond with connect request
val nonce = payload?.get("nonce")?.asString
Log.d(TAG, "Received connect challenge with nonce: $nonce")
sendConnectRequest(nonce)
}
"chat" -> {
// Handle chat message events
handleChatEvent(payload)
}
"mobile.notification" -> {
// Handle mobile notification events
handleNotificationEvent(payload)
}
"mobile.alarm.dismissed" -> {
// Handle alarm dismiss broadcast from other devices
val alarmId = safeGetString(payload, "alarmId")
if (alarmId != null) {
Log.d(TAG, "Received alarm dismiss broadcast: $alarmId")
listener.onAlarmDismissed(alarmId)
}
}
"agent" -> {
// Agent events can be logged but not shown to user
Log.d(TAG, "Agent event received")
}
else -> {
Log.d(TAG, "Received event: $event")
listener.onEvent(event ?: "unknown", payload?.toString() ?: "{}")
}
}
}
/**
* Safely get a string from JsonObject, handling JsonNull.
*/
private fun safeGetString(obj: JsonObject, key: String): String? {
val element = obj.get(key) ?: return null
return if (element.isJsonNull) null else element.asString
}
/**
* Handle notification-specific events.
*/
private fun handleNotificationEvent(payload: JsonObject?) {
if (payload == null) {
Log.w(TAG, "Notification event with no payload")
return
}
try {
val notificationType = safeGetString(payload, "notificationType") ?: "alert"
val title = safeGetString(payload, "title") ?: "AI Assistant"
val message = safeGetString(payload, "message")
val priority = safeGetString(payload, "priority") ?: "default"
val sound = payload.get("sound")?.asBoolean ?: true
val vibrate = payload.get("vibrate")?.asBoolean ?: true
val timestamp = payload.get("timestamp")?.asLong ?: System.currentTimeMillis()
val action = safeGetString(payload, "action")
if (message != null && !message.isEmpty()) {
Log.d(TAG, "Got notification: type=$notificationType title=$title message=$message")
listener.onNotification(notificationType, title, message, priority, sound, vibrate, timestamp, action)
} else {
Log.w(TAG, "Notification event with no message")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse notification event", e)
}
}
/**
* Handle chat-specific events.
*/
private fun handleChatEvent(payload: JsonObject?) {
if (payload == null) return
// Extract state and message object
val state = payload.get("state")?.asString
val messageObj = payload.getAsJsonObject("message")
if (messageObj == null) {
Log.w(TAG, "Chat event with no message object")
return
}
// Extract role and content array
val role = messageObj.get("role")?.asString ?: "assistant"
val contentArray = messageObj.getAsJsonArray("content")
if (contentArray == null || contentArray.size() == 0) {
Log.w(TAG, "Chat event with empty content array")
return
}
// Loop through all content blocks to find text (thinking blocks come first)
var foundText: String? = null
for (i in 0 until contentArray.size()) {
val contentBlock = contentArray.get(i).asJsonObject
val contentType = contentBlock.get("type")?.asString
// Only extract text blocks, skip thinking/toolCall/etc
if (contentType == "text") {
foundText = contentBlock.get("text")?.asString
if (foundText != null && foundText.isNotEmpty()) {
break
}
}
}
if (foundText != null && foundText.isNotEmpty()) {
// Only show the final message to avoid duplicates with streaming
if (state == "final") {
Log.d(TAG, "Got final message: $foundText")
listener.onMessage("Alfred", foundText)
} else {
Log.d(TAG, "Skipping delta state, waiting for final")
}
} else {
Log.d(TAG, "Chat event with no text content blocks")
}
}
/**
* Handle gateway responses.
*/
private fun handleResponse(json: JsonObject) {
val id = json.get("id")?.asString
val ok = json.get("ok")?.asBoolean ?: false
val payload = json.getAsJsonObject("payload")
if (ok) {
val payloadType = payload?.get("type")?.asString
if (payloadType == "hello-ok") {
Log.d(TAG, "Connect successful!")
isConnected = true
resetReconnectionState() // Reset reconnection state on successful connection
// Extract and save user preferences if present
val userPrefs = payload?.getAsJsonObject("userPreferences")
if (userPrefs != null) {
Log.d(TAG, "Received user preferences from server: $userPrefs")
val prefs = context.getSharedPreferences("alfred_settings", Context.MODE_PRIVATE)
val editor = prefs.edit()
// Update assistant name if present
if (userPrefs.has("assistantName")) {
val assistantName = userPrefs.get("assistantName").asString
editor.putString("assistant_name", assistantName)
Log.d(TAG, "Updated assistant name from server: $assistantName")
}
// Update voice ID if present
if (userPrefs.has("voiceId")) {
val voiceId = userPrefs.get("voiceId").asString
editor.putString("tts_voice_id", voiceId)
Log.d(TAG, "Updated voice ID from server: $voiceId")
}
editor.apply()
} else {
Log.d(TAG, "No user preferences in connect response")
}
listener.onConnected()
} else {
listener.onResponse(id ?: "unknown", payload?.toString() ?: "{}")
}
} else {
val error = json.getAsJsonObject("error")
val errorMsg = error?.get("message")?.asString ?: "Unknown error"
Log.e(TAG, "Request failed: $errorMsg")
listener.onError(errorMsg)
}
}
/**
* Send connect request to gateway.
*/
private fun sendConnectRequest(nonce: String?) {
val connectMsg = mapOf(
"type" to "req",
"id" to "connect-${requestId++}",
"method" to "connect",
"params" to mapOf(
"minProtocol" to 3,
"maxProtocol" to 3,
"client" to mapOf(
"id" to "cli",
"version" to BuildConfig.VERSION_NAME,
"platform" to "android",
"mode" to "webchat"
),
"role" to "operator",
"scopes" to listOf("operator.read", "operator.write"),
"caps" to emptyList<String>(),
"commands" to emptyList<String>(),
"permissions" to emptyMap<String, Boolean>(),
"auth" to mapOf("token" to accessToken),
"locale" to "en-US",
"userAgent" to "alfred-mobile/${BuildConfig.VERSION_NAME}"
)
)
val json = gson.toJson(connectMsg)
Log.d(TAG, ">>> Sending connect request: $json")
val sent = webSocket?.send(json)
Log.d(TAG, "Send result: $sent")
}
/**
* Send a message to the gateway.
*/
fun sendMessage(message: String) {
if (!isConnected) {
Log.w(TAG, "Not connected, cannot send message")
listener.onError("Not connected")
return
}
val idempotencyKey = "msg-${System.currentTimeMillis()}-${requestId++}"
val msgObj = mapOf(
"type" to "req",
"id" to "chat-${requestId++}",
"method" to "chat.send",
"params" to mapOf(
"sessionKey" to userId,
"message" to message,
"idempotencyKey" to idempotencyKey
)
)
val json = gson.toJson(msgObj)
Log.d(TAG, "Sending message: $message")
webSocket?.send(json)
}
/**
* Send alarm dismiss event to notify other devices.
*/
fun dismissAlarm(alarmId: String) {
if (!isConnected) {
Log.w(TAG, "Not connected, cannot send alarm dismiss")
return
}
val msgObj = mapOf(
"type" to "alarm.dismiss",
"alarmId" to alarmId,
"timestamp" to System.currentTimeMillis()
)
val json = gson.toJson(msgObj)
Log.d(TAG, "Sending alarm dismiss: $alarmId")
webSocket?.send(json)
}
/**
* Send FCM token to proxy for push notifications.
*/
fun sendFCMToken(fcmToken: String) {
if (!isConnected) {
Log.w(TAG, "Not connected, cannot send FCM token")
return
}
val msgObj = mapOf(
"type" to "fcm.register",
"token" to fcmToken,
"timestamp" to System.currentTimeMillis()
)
val json = gson.toJson(msgObj)
Log.d(TAG, "Sending FCM token: ${fcmToken.take(20)}...")
webSocket?.send(json)
}
/**
* Update user preferences on server.
*/
fun updatePreferences(preferences: Map<String, Any>) {
if (!isConnected) {
Log.w(TAG, "Not connected, cannot update preferences")
return
}
val msgObj = mapOf(
"type" to "req",
"id" to "prefs-update-${requestId++}",
"method" to "user.preferences.update",
"params" to preferences
)
val json = gson.toJson(msgObj)
Log.d(TAG, "Updating preferences: $preferences")
webSocket?.send(json)
}
/**
* Get user preferences from server.
*/
fun getPreferences() {
if (!isConnected) {
Log.w(TAG, "Not connected, cannot get preferences")
return
}
val msgObj = mapOf(
"type" to "req",
"id" to "prefs-get-${requestId++}",
"method" to "user.preferences.get",
"params" to emptyMap<String, Any>()
)
val json = gson.toJson(msgObj)
Log.d(TAG, "Requesting preferences")
webSocket?.send(json)
}
/**
* Disconnect from the gateway.
*/
fun disconnect() {
Log.d(TAG, "Disconnecting")
// Disable automatic reconnection
shouldReconnect = false
// Cancel any pending reconnection attempts
reconnectHandler?.removeCallbacksAndMessages(null)
reconnectHandler = null
// Close WebSocket
isConnected = false
webSocket?.close(1000, "Client disconnect")
webSocket = null
// Reset reconnection state
resetReconnectionState()
}
/**
* Check if currently connected.
*/
fun isConnected(): Boolean = isConnected
}
/**
* Listener for gateway events.
*/
interface GatewayListener {
fun onConnecting()
fun onConnected()
fun onDisconnected()
fun onReconnecting(attempt: Int, delayMs: Long)
fun onError(error: String)
fun onEvent(event: String, payload: String)
fun onResponse(id: String, payload: String)
fun onMessage(sender: String, text: String)
fun onNotification(
notificationType: String,
title: String,
message: String,
priority: String,
sound: Boolean,
vibrate: Boolean,
timestamp: Long,
action: String?
)
fun onAlarmDismissed(alarmId: String)
fun onWakeWordDetected()
}

View File

@@ -0,0 +1,215 @@
package com.openclaw.alfred.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.openclaw.alfred.R
import com.openclaw.alfred.MainActivity
/**
* Helper for managing Alfred notifications.
*/
object NotificationHelper {
private const val CHANNEL_ID = "alfred_messages"
private const val CHANNEL_NAME = "Alfred Messages"
private const val CHANNEL_DESC = "Notifications from Alfred assistant"
private const val ALARM_CHANNEL_ID = "alfred_alarms"
private const val ALARM_CHANNEL_NAME = "Alfred Alarms"
private const val ALARM_CHANNEL_DESC = "Time-sensitive alarm notifications from Alfred"
private const val NOTIFICATION_ID_COUNTER_START = 1000
private var notificationIdCounter = NOTIFICATION_ID_COUNTER_START
/**
* Create notification channel (required for Android 8.0+).
* Call this once on app startup.
*/
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Regular messages channel
val messagesChannel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
description = CHANNEL_DESC
enableVibration(true)
enableLights(true)
}
notificationManager.createNotificationChannel(messagesChannel)
// High-priority alarm channel
val alarmChannel = NotificationChannel(ALARM_CHANNEL_ID, ALARM_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH).apply {
description = ALARM_CHANNEL_DESC
enableVibration(true)
enableLights(true)
setBypassDnd(true) // Bypass Do Not Disturb
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
notificationManager.createNotificationChannel(alarmChannel)
}
}
/**
* Show a notification from Alfred.
*/
fun showNotification(
context: Context,
title: String,
message: String,
autoCancel: Boolean = true,
dismissAction: PendingIntent? = null
) {
// Check notification permission (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
// Permission not granted, can't show notification
return
}
}
// Intent to open app when notification is tapped
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
// Build notification
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(autoCancel)
// Add dismiss action if provided
if (dismissAction != null) {
builder.addAction(
R.drawable.ic_launcher_foreground, // Icon
"Dismiss", // Button text
dismissAction // PendingIntent
)
}
val notification = builder.build()
// Show notification
NotificationManagerCompat.from(context).notify(getNextNotificationId(), notification)
}
/**
* Show a notification when Alfred finishes processing in background.
*/
fun showBackgroundWorkComplete(context: Context, message: String) {
showNotification(
context = context,
title = "AI Assistant",
message = message,
autoCancel = true
)
}
/**
* Show an alarm notification with full-screen intent and dismiss button.
*/
fun showAlarmNotification(
context: Context,
alarmId: String,
title: String,
message: String,
fullScreenIntent: PendingIntent,
dismissAction: PendingIntent
) {
// Check notification permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
}
// Build alarm notification with full-screen intent using high-priority alarm channel
val notification = NotificationCompat.Builder(context, ALARM_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("⏰ ALARM: $title")
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setFullScreenIntent(fullScreenIntent, true) // Show full-screen on lock screen
.setOngoing(true) // Can't swipe away
.setAutoCancel(false) // Require dismissal
.addAction(
R.drawable.ic_launcher_foreground,
"Dismiss",
dismissAction
)
.build()
// Show notification
NotificationManagerCompat.from(context).notify(alarmId.hashCode(), notification)
}
/**
* Cancel a specific alarm notification.
*/
fun cancelAlarmNotification(context: Context, alarmId: String) {
NotificationManagerCompat.from(context).cancel(alarmId.hashCode())
}
/**
* Cancel all alarm notifications.
*/
fun cancelAllAlarmNotifications(context: Context) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.activeNotifications.forEach { statusBarNotification ->
if (statusBarNotification.notification.channelId == ALARM_CHANNEL_ID) {
NotificationManagerCompat.from(context).cancel(statusBarNotification.id)
}
}
}
/**
* Check if notification permission is granted (Android 13+).
*/
fun hasNotificationPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
// No permission needed before Android 13
true
}
}
/**
* Get next notification ID (auto-incrementing).
*/
private fun getNextNotificationId(): Int {
return notificationIdCounter++
}
}

View File

@@ -0,0 +1,42 @@
package com.openclaw.alfred.permissions
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
/**
* Helper for managing app permissions.
*/
object PermissionHelper {
/**
* Check if microphone permission is granted.
*/
fun hasMicrophonePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}
/**
* Request microphone permission.
* Returns a launcher that should be called to request permission.
*/
fun createMicrophonePermissionLauncher(
activity: ComponentActivity,
onGranted: () -> Unit,
onDenied: () -> Unit
) = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
onGranted()
} else {
onDenied()
}
}
}

View File

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

View File

@@ -0,0 +1,90 @@
package com.openclaw.alfred.storage
import android.content.Context
import android.content.SharedPreferences
import com.openclaw.alfred.ui.screens.ChatMessage
import org.json.JSONArray
import org.json.JSONObject
/**
* Persist conversation messages to SharedPreferences.
*/
object ConversationStorage {
private const val PREFS_NAME = "alfred_conversation"
private const val KEY_MESSAGES = "messages"
private const val MAX_MESSAGES = 100 // Keep last 100 messages
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
/**
* Save messages to storage.
*/
fun saveMessages(context: Context, messages: List<ChatMessage>) {
try {
val jsonArray = JSONArray()
// Keep only the last MAX_MESSAGES
val messagesToSave = if (messages.size > MAX_MESSAGES) {
messages.takeLast(MAX_MESSAGES)
} else {
messages
}
for (message in messagesToSave) {
val jsonObject = JSONObject().apply {
put("sender", message.sender)
put("text", message.text)
put("isSystem", message.isSystem)
}
jsonArray.put(jsonObject)
}
getPrefs(context).edit()
.putString(KEY_MESSAGES, jsonArray.toString())
.apply()
} catch (e: Exception) {
android.util.Log.e("ConversationStorage", "Failed to save messages", e)
}
}
/**
* Load messages from storage.
*/
fun loadMessages(context: Context): List<ChatMessage> {
try {
val json = getPrefs(context).getString(KEY_MESSAGES, null) ?: return emptyList()
val jsonArray = JSONArray(json)
val messages = mutableListOf<ChatMessage>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
messages.add(
ChatMessage(
sender = jsonObject.getString("sender"),
text = jsonObject.getString("text"),
isSystem = jsonObject.getBoolean("isSystem")
)
)
}
return messages
} catch (e: Exception) {
android.util.Log.e("ConversationStorage", "Failed to load messages", e)
return emptyList()
}
}
/**
* Clear all stored messages.
*/
fun clearMessages(context: Context) {
getPrefs(context).edit()
.remove(KEY_MESSAGES)
.apply()
}
}

View File

@@ -0,0 +1,142 @@
package com.openclaw.alfred.storage
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
/**
* Stores notification history for in-app display.
*/
class NotificationStorage(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("alfred_notifications", Context.MODE_PRIVATE)
data class StoredNotification(
val id: String,
val type: String,
val title: String,
val message: String,
val timestamp: Long,
val read: Boolean = false
)
/**
* Add a notification to history.
*/
fun addNotification(type: String, title: String, message: String, timestamp: Long = System.currentTimeMillis()): String {
val notifications = getNotifications().toMutableList()
val id = "notif_${timestamp}_${notifications.size}"
notifications.add(0, StoredNotification(
id = id,
type = type,
title = title,
message = message,
timestamp = timestamp,
read = false
))
// Keep only last 50 notifications
if (notifications.size > 50) {
notifications.subList(50, notifications.size).clear()
}
saveNotifications(notifications)
return id
}
/**
* Get all notifications (newest first).
*/
fun getNotifications(): List<StoredNotification> {
return try {
val json = prefs.getString("notifications", null) ?: return emptyList()
val array = JSONArray(json)
val result = mutableListOf<StoredNotification>()
for (i in 0 until array.length()) {
try {
val obj = array.getJSONObject(i)
result.add(StoredNotification(
id = obj.getString("id"),
type = obj.getString("type"),
title = obj.getString("title"),
message = obj.getString("message"),
timestamp = obj.getLong("timestamp"),
read = obj.optBoolean("read", false)
))
} catch (e: Exception) {
// Skip corrupted notification entries
android.util.Log.e("NotificationStorage", "Failed to parse notification at index $i: ${e.message}")
}
}
result
} catch (e: Exception) {
// If JSON parsing fails completely, clear corrupted data and return empty
android.util.Log.e("NotificationStorage", "Failed to parse notifications JSON, clearing: ${e.message}")
prefs.edit().remove("notifications").apply()
emptyList()
}
}
/**
* Mark notification as read.
*/
fun markAsRead(id: String) {
val notifications = getNotifications().map {
if (it.id == id) it.copy(read = true) else it
}
saveNotifications(notifications)
}
/**
* Mark all notifications as read.
*/
fun markAllAsRead() {
val notifications = getNotifications().map { it.copy(read = true) }
saveNotifications(notifications)
}
/**
* Get unread count.
*/
fun getUnreadCount(): Int {
return getNotifications().count { !it.read }
}
/**
* Delete a specific notification by timestamp.
*/
fun deleteNotification(timestamp: Long) {
val notifications = getNotifications().filter { it.timestamp != timestamp }
saveNotifications(notifications)
}
/**
* Clear all notifications.
*/
fun clearAll() {
prefs.edit().remove("notifications").apply()
}
private fun saveNotifications(notifications: List<StoredNotification>) {
try {
val array = JSONArray()
notifications.forEach { notif ->
val obj = JSONObject().apply {
put("id", notif.id)
put("type", notif.type)
put("title", notif.title)
put("message", notif.message)
put("timestamp", notif.timestamp)
put("read", notif.read)
}
array.put(obj)
}
prefs.edit().putString("notifications", array.toString()).apply()
} catch (e: Exception) {
android.util.Log.e("NotificationStorage", "Failed to save notifications: ${e.message}")
}
}
}

View File

@@ -0,0 +1,81 @@
package com.openclaw.alfred.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Login screen - shows before user authenticates.
* Displays login button that starts OAuth flow.
*/
@Composable
fun LoginScreen(
onLoginClick: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Alfred emoji
Text(
text = "🤵",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(24.dp))
// App title
Text(
text = "AI Assistant",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Tagline
Text(
text = "Your AI assistant, always with you",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(48.dp))
// Login button
Button(
onClick = onLoginClick,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text(
text = "Sign In with Authentik",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(16.dp))
// Info text
Text(
text = "Secure authentication via OAuth 2.0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
package com.openclaw.alfred.ui.theme
import androidx.compose.ui.graphics.Color
// Light theme colors
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// Dark theme colors
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// Alfred brand colors (butler theme - elegant blacks and grays)
val AlfredPrimary = Color(0xFF1A1A1A)
val AlfredSecondary = Color(0xFF4A4A4A)
val AlfredTertiary = Color(0xFF6B6B6B)
val AlfredAccent = Color(0xFF2196F3)

View File

@@ -0,0 +1,60 @@
package com.openclaw.alfred.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun AlfredTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package com.openclaw.alfred.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,319 @@
package com.openclaw.alfred.voice
import android.content.Context
import android.media.MediaPlayer
import android.speech.tts.TextToSpeech
import android.util.Log
import com.openclaw.alfred.BuildConfig
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Manages Text-to-Speech using ElevenLabs API with extended timeout.
*/
class TTSManager(private val context: Context) {
private val TAG = "TTSManager"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS) // Extended for long responses
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private var mediaPlayer: MediaPlayer? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val apiKey = BuildConfig.ELEVENLABS_API_KEY
private val baseUrl = "https://api.elevenlabs.io/v1"
// Read voice ID from preferences (default: Finn - vBKc2FfBKJfcZNyEt1n6)
private fun getVoiceId(): String {
val prefs = context.getSharedPreferences("alfred_settings", Context.MODE_PRIVATE)
return prefs.getString("tts_voice_id", BuildConfig.ELEVENLABS_VOICE_ID)
?: BuildConfig.ELEVENLABS_VOICE_ID
}
// Fallback Android TTS
private var androidTTS: TextToSpeech? = null
private var ttsReady = false
init {
// Initialize Android TTS as fallback
androidTTS = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
androidTTS?.language = Locale.US
ttsReady = true
Log.d(TAG, "Android TTS initialized successfully")
} else {
Log.e(TAG, "Android TTS initialization failed")
}
}
}
/**
* Sanitize text for TTS by removing markdown and special characters.
*/
private fun sanitizeTextForSpeech(text: String): String {
var cleaned = text
// Remove markdown formatting
cleaned = cleaned.replace(Regex("\\*\\*([^*]+)\\*\\*"), "$1") // Bold: **text**
cleaned = cleaned.replace(Regex("\\*([^*]+)\\*"), "$1") // Italic: *text*
cleaned = cleaned.replace(Regex("__([^_]+)__"), "$1") // Bold: __text__
cleaned = cleaned.replace(Regex("_([^_]+)_"), "$1") // Italic: _text_
cleaned = cleaned.replace(Regex("~~([^~]+)~~"), "$1") // Strikethrough: ~~text~~
cleaned = cleaned.replace(Regex("`([^`]+)`"), "$1") // Inline code: `text`
// Remove code blocks
cleaned = cleaned.replace(Regex("```[\\s\\S]*?```"), "") // Code blocks
// Remove links but keep link text
cleaned = cleaned.replace(Regex("\\[([^]]+)]\\([^)]+\\)"), "$1") // [text](url)
cleaned = cleaned.replace(Regex("https?://\\S+"), "") // Plain URLs
// Remove list markers
cleaned = cleaned.replace(Regex("^[\\s]*[-*+•]\\s+", RegexOption.MULTILINE), "") // List bullets
cleaned = cleaned.replace(Regex("^[\\s]*\\d+\\.\\s+", RegexOption.MULTILINE), "") // Numbered lists
// Remove headers
cleaned = cleaned.replace(Regex("^#+\\s+", RegexOption.MULTILINE), "") // # Headers
// Remove blockquotes
cleaned = cleaned.replace(Regex("^>\\s+", RegexOption.MULTILINE), "")
// Remove emoji shortcodes
cleaned = cleaned.replace(Regex(":[a-z_]+:"), "")
// Remove brackets and parentheses (but keep content)
cleaned = cleaned.replace(Regex("[\\[\\]()]"), "")
// Remove multiple punctuation marks (e.g., "..." -> ".")
cleaned = cleaned.replace(Regex("([.!?]){2,}"), "$1")
// Remove special characters but keep basic punctuation
cleaned = cleaned.replace(Regex("[^a-zA-Z0-9\\s.,!?;:'-]"), "")
// Clean up whitespace
cleaned = cleaned.replace(Regex("\\s+"), " ")
cleaned = cleaned.trim()
Log.d(TAG, "Sanitized for TTS: '$text' -> '$cleaned'")
return cleaned
}
/**
* Convert text to speech and play it.
*/
fun speak(text: String, onComplete: () -> Unit = {}, onError: (String) -> Unit = {}) {
if (apiKey.isEmpty()) {
Log.w(TAG, "ElevenLabs API key not configured, using Android TTS")
speakWithAndroidTTS(text, onComplete, onError)
return
}
scope.launch {
try {
// Sanitize text before sending to TTS
val cleanText = sanitizeTextForSpeech(text)
if (cleanText.isBlank()) {
Log.w(TAG, "Text became empty after sanitization, skipping TTS")
withContext(Dispatchers.Main) { onComplete() }
return@launch
}
Log.d(TAG, "Converting text to speech: ${cleanText.take(50)}...")
// Call TTS proxy endpoint
val voiceId = getVoiceId()
val audioUrl = callTTSProxy(cleanText, voiceId)
if (audioUrl == null) {
// Fallback to Android TTS
Log.w(TAG, "TTS proxy failed, falling back to Android TTS")
withContext(Dispatchers.Main) {
speakWithAndroidTTS(cleanText, onComplete, onError)
}
return@launch
}
Log.d(TAG, "TTS audio URL: $audioUrl")
// Play audio on main thread
withContext(Dispatchers.Main) {
val baseUrl = BuildConfig.GATEWAY_URL.replace("wss://", "https://").replace("ws://", "http://")
playStreamingAudio("$baseUrl$audioUrl", onComplete, onError)
}
} catch (e: Exception) {
Log.e(TAG, "TTS error, falling back to Android TTS", e)
// Use sanitized text for fallback too
val cleanText = sanitizeTextForSpeech(text)
withContext(Dispatchers.Main) {
speakWithAndroidTTS(cleanText, onComplete, onError)
}
}
}
}
/**
* Call TTS proxy and get audio URL.
*/
private fun callTTSProxy(text: String, voiceId: String): String? {
try {
val baseUrl = BuildConfig.GATEWAY_URL.replace("wss://", "https://").replace("ws://", "http://")
val proxyUrl = "$baseUrl/api/tts"
val json = JSONObject().apply {
put("text", text)
put("voiceId", voiceId)
}
val requestBody = json.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(proxyUrl)
.post(requestBody)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "no body"
Log.e(TAG, "TTS proxy error: ${response.code} ${response.message}")
Log.e(TAG, "Error body: $errorBody")
return null
}
val responseBody = response.body?.string() ?: return null
val responseJson = JSONObject(responseBody)
return responseJson.getString("audioUrl")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to call TTS proxy", e)
return null
}
}
/**
* Speak using Android built-in TTS.
*/
private fun speakWithAndroidTTS(text: String, onComplete: () -> Unit, onError: (String) -> Unit) {
if (!ttsReady || androidTTS == null) {
onError("Android TTS not ready")
return
}
try {
androidTTS?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
Log.d(TAG, "Android TTS started")
}
override fun onDone(utteranceId: String?) {
Log.d(TAG, "Android TTS completed")
onComplete()
}
override fun onError(utteranceId: String?) {
Log.e(TAG, "Android TTS error")
onError("Android TTS error")
}
})
androidTTS?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "alfred-${System.currentTimeMillis()}")
Log.d(TAG, "Speaking with Android TTS")
} catch (e: Exception) {
Log.e(TAG, "Failed to use Android TTS", e)
onError("Android TTS failed: ${e.message}")
}
}
/**
* Play streaming audio from URL.
*/
private fun playStreamingAudio(streamUrl: String, onComplete: () -> Unit, onError: (String) -> Unit) {
try {
// Stop any existing playback
stopPlayback()
mediaPlayer = MediaPlayer().apply {
setDataSource(streamUrl)
setOnPreparedListener {
Log.d(TAG, "Stream prepared, starting playback")
start()
}
setOnCompletionListener {
Log.d(TAG, "Playback completed")
stopPlayback()
onComplete()
}
setOnErrorListener { _, what, extra ->
Log.e(TAG, "MediaPlayer error: what=$what extra=$extra")
stopPlayback()
// Fallback to Android TTS on streaming error
Log.w(TAG, "Streaming failed, falling back to Android TTS")
// We can't easily get the original text here, so just call the error handler
onError("Streaming error, using fallback")
true
}
setOnInfoListener { _, what, extra ->
Log.d(TAG, "MediaPlayer info: what=$what extra=$extra")
false
}
// Prepare async to avoid blocking
prepareAsync()
}
Log.d(TAG, "Streaming audio from: $streamUrl")
} catch (e: Exception) {
Log.e(TAG, "Failed to stream audio", e)
onError("Failed to stream audio: ${e.message}")
}
}
/**
* Stop current playback.
*/
fun stopPlayback() {
// Stop MediaPlayer (ElevenLabs)
mediaPlayer?.let {
if (it.isPlaying) {
it.stop()
}
it.release()
}
mediaPlayer = null
// Stop Android TTS
androidTTS?.stop()
}
/**
* Check if currently playing.
*/
fun isPlaying(): Boolean {
return mediaPlayer?.isPlaying == true || androidTTS?.isSpeaking == true
}
/**
* Cleanup resources.
*/
fun destroy() {
stopPlayback()
androidTTS?.shutdown()
androidTTS = null
scope.cancel()
}
}

View File

@@ -0,0 +1,86 @@
package com.openclaw.alfred.voice
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Helper to manage ElevenLabs voices.
* Voice list is hardcoded from SAG CLI output (since Android can't execute Linux commands).
*/
object VoiceHelper {
private const val TAG = "VoiceHelper"
data class Voice(
val id: String,
val name: String,
val category: String
)
/**
* Hardcoded voice list from SAG CLI.
* Updated: 2025-02-08
*/
private val VOICES = listOf(
Voice("vBKc2FfBKJfcZNyEt1n6", "Finn - Youthful, Eager and Energetic", "professional"),
Voice("EXAVITQu4vr4xnSDxMaL", "Sarah - Mature, Reassuring, Confident", "premade"),
Voice("CwhRBWXzGAHq8TQ4Fs17", "Roger - Laid-Back, Casual, Resonant", "premade"),
Voice("FGY2WhTYpPnrIDTdsKH5", "Laura - Enthusiast, Quirky Attitude", "premade"),
Voice("IKne3meq5aSn9XLyUdCD", "Charlie - Deep, Confident, Energetic", "premade"),
Voice("JBFqnCBsd6RMkjVDRZzb", "George - Warm, Captivating Storyteller", "premade"),
Voice("N2lVS1w4EtoT3dr4eOWO", "Callum - Husky Trickster", "premade"),
Voice("SAz9YHcvj6GT2YYXdXww", "River - Relaxed, Neutral, Informative", "premade"),
Voice("SOYHLrjzK2X1ezoPC6cr", "Harry - Fierce Warrior", "premade"),
Voice("TX3LPaxmHKxFdv7VOQHJ", "Liam - Energetic, Social Media Creator", "premade"),
Voice("Xb7hH8MSUJpSbSDYk0k2", "Alice - Clear, Engaging Educator", "premade"),
Voice("XrExE9yKIg1WjnnlVkGX", "Matilda - Knowledgable, Professional", "premade"),
Voice("bIHbv24MWmeRgasZH58o", "Will - Relaxed Optimist", "premade"),
Voice("cgSgspJ2msm6clMCkdW9", "Jessica - Playful, Bright, Warm", "premade"),
Voice("cjVigY5qzO86Huf0OWal", "Eric - Smooth, Trustworthy", "premade"),
Voice("hpp4J3VqNfWAUOO0d1Us", "Bella - Professional, Bright, Warm", "premade"),
Voice("iP95p4xoKVk53GoZ742B", "Chris - Charming, Down-to-Earth", "premade"),
Voice("nPczCjzI2devNBz1zQrb", "Brian - Deep, Resonant and Comforting", "premade"),
Voice("onwK4e9ZLuTAKqWW03F9", "Daniel - Steady Broadcaster", "premade"),
Voice("pFZP5JQG7iQjIQuC4Bku", "Lily - Velvety Actress", "premade"),
Voice("pNInz6obpgDQGcFmaJgB", "Adam - Dominant, Firm", "premade"),
Voice("pqHfZKP75CvOlQylNhV4", "Bill - Wise, Mature, Balanced", "premade"),
Voice("5Xx8kcjjamcaKohQT5wv", "Joe - Conversational Storyteller", "professional"),
Voice("5l5f8iK3YPeGga21rQIX", "Adeline", "professional"),
Voice("7p1Ofvcwsv7UBPoFNcpI", "Julian - deep rich mature British voice", "professional"),
Voice("BZgkqPqms7Kj9ulSkVzn", "Eve", "professional"),
Voice("DMyrgzQFny3JI1Y1paM5", "Donovan", "professional"),
Voice("Dslrhjl3ZpzrctukrQSN", "Hey Its Brad - Clear Narrator for Documentary", "professional"),
Voice("IsEXLHzSvLH9UMB6SLHj", "Mellow Matt", "professional"),
Voice("M7ya1YbaeFaPXljg9BpK", "Hannah the natural Australian Voice", "professional"),
Voice("NNl6r8mD7vthiJatiJt1", "Bradford", "professional"),
Voice("ROMJ9yK1NAMuu1ggrjDW", "Relaxing Rachel - Calm & Soothing", "professional"),
Voice("Sq93GQT4X1lKDXsQcixO", "Felix - Warm, positive & contemporary RP", "professional"),
Voice("UgBBYS2sOqTuMpoF3BR0", "Mark - Natural Conversations", "professional"),
Voice("WdZjiN0nNcik2LBjOHiv", "David - Wise & Knowledgeable", "professional"),
Voice("c6SfcYrb2t09NHXiT80T", "Jarnathan - Confident and Versatile", "professional"),
Voice("gfRt6Z3Z8aTbpLfexQ7N", "Boyd", "professional"),
Voice("giAoKpl5weRTCJK7uB9b", "Owen - Engaging British Storyteller", "professional"),
Voice("goT3UYdM9bhm0n2lmKQx", "Edward - British, Dark, Seductive, Low", "professional"),
Voice("jbEI5QkrMSKWeDlP27MV", "Ryan", "professional"),
Voice("pVnrL6sighQX7hVz89cp", "Soothing Narrator", "professional"),
Voice("scOwDtmlUjD3prqpp97I", "Sam - Support Agent & Audiobooks", "professional"),
Voice("y1adqrqs4jNaANXsIZnD", "David Boles", "professional"),
Voice("yr43K8H5LoTp6S1QFSGg", "Matt", "professional")
)
/**
* Get available voices (returns hardcoded list).
*/
suspend fun fetchVoices(): List<Voice> = withContext(Dispatchers.IO) {
Log.d(TAG, "Returning ${VOICES.size} hardcoded voices")
VOICES
}
/**
* Get voice name by ID (from cached list).
*/
fun getVoiceName(voices: List<Voice>, voiceId: String): String {
return voices.find { it.id == voiceId }?.name ?: voiceId
}
}

View File

@@ -0,0 +1,207 @@
package com.openclaw.alfred.voice
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import java.util.*
/**
* Manages on-device voice-to-text using Android SpeechRecognizer.
*/
class VoiceInputManager(
private val context: Context,
private val onResult: (String) -> Unit,
private val onError: (String) -> Unit,
private val onListening: (Boolean) -> Unit
) {
private val TAG = "VoiceInputManager"
private var speechRecognizer: SpeechRecognizer? = null
private var isListening = false
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
/**
* Create RecognitionListener for SpeechRecognizer.
*/
private fun createRecognitionListener() = object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
Log.d(TAG, "Ready for speech")
isListening = true
onListening(true)
}
override fun onBeginningOfSpeech() {
Log.d(TAG, "Speech started")
}
override fun onRmsChanged(rmsdB: Float) {
// Audio level changed - could show visual feedback
}
override fun onBufferReceived(buffer: ByteArray?) {
// Partial audio buffer
}
override fun onEndOfSpeech() {
Log.d(TAG, "Speech ended")
isListening = false
onListening(false)
}
override fun onError(error: Int) {
Log.e(TAG, "Recognition error: $error")
isListening = false
onListening(false)
val errorMsg = when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio recording error (microphone busy or unavailable)"
SpeechRecognizer.ERROR_CLIENT -> "Client error (recognizer not ready - try again)"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Missing permissions"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "No speech detected - try again"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Microphone busy - please wait and try again"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout"
11 -> "Recognizer initialization error (try again in a moment)"
else -> "Unknown error: $error"
}
onError(errorMsg)
}
override fun onResults(results: Bundle?) {
Log.d(TAG, "Got results")
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
val text = matches[0]
Log.d(TAG, "Recognized: $text")
onResult(text)
}
isListening = false
onListening(false)
}
override fun onPartialResults(partialResults: Bundle?) {
// Partial recognition results (if enabled)
}
override fun onEvent(eventType: Int, params: Bundle?) {
// Recognition event
}
}
init {
if (SpeechRecognizer.isRecognitionAvailable(context)) {
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
speechRecognizer?.setRecognitionListener(createRecognitionListener())
} else {
Log.e(TAG, "Speech recognition not available on this device")
onError("Speech recognition not available")
}
}
/**
* Start listening for voice input.
*/
fun startListening() {
if (isListening) {
Log.w(TAG, "Already listening")
return
}
// Destroy previous SpeechRecognizer instance
try {
speechRecognizer?.destroy()
speechRecognizer = null
} catch (e: Exception) {
Log.w(TAG, "Error destroying previous recognizer", e)
}
// Add delay to ensure Android speech service has fully released resources
// This prevents error 11 (initialization error) caused by race condition
handler.postDelayed({
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
Log.e(TAG, "Speech recognition not available on this device")
onError("Speech recognition not available")
return@postDelayed
}
// Create new SpeechRecognizer instance
try {
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
speechRecognizer?.setRecognitionListener(createRecognitionListener())
} catch (e: Exception) {
Log.e(TAG, "Failed to create speech recognizer", e)
onError("Failed to initialize: ${e.message}")
return@postDelayed
}
// Create intent with extended timeouts
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false)
// Extend silence detection timeouts for longer pauses
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 6500L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 5000L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 12000L)
}
// Start listening
try {
speechRecognizer?.startListening(intent)
Log.d(TAG, "Started listening")
} catch (e: Exception) {
Log.e(TAG, "Failed to start listening", e)
isListening = false
onListening(false)
onError("Failed to start: ${e.message}")
}
}, 150) // 150ms delay to avoid race condition
}
/**
* Stop listening.
*/
fun stopListening() {
if (isListening) {
speechRecognizer?.stopListening()
isListening = false
onListening(false)
Log.d(TAG, "Stopped listening")
}
}
/**
* Cancel listening.
*/
fun cancel() {
if (isListening) {
speechRecognizer?.cancel()
isListening = false
onListening(false)
Log.d(TAG, "Cancelled listening")
}
}
/**
* Cleanup resources.
*/
fun destroy() {
speechRecognizer?.destroy()
speechRecognizer = null
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "Destroyed")
}
/**
* Check if currently listening.
*/
fun isListening(): Boolean = isListening
}

View File

@@ -0,0 +1,430 @@
package com.openclaw.alfred.voice
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.RecognitionListener
import org.vosk.android.SpeechService
import java.io.File
import java.io.IOException
/**
* Unified Vosk recognition manager for both wake word detection and full transcription.
*
* Modes:
* - WAKE_WORD: Listens for "hey alfred" or "alfred" (lightweight grammar)
* - FULL_RECOGNITION: Transcribes full sentences (full vocabulary)
*/
class VoskRecognitionManager(
private val context: Context,
private val onWakeWordDetected: () -> Unit,
private val onTranscriptionResult: (text: String, confidence: Float) -> Unit,
private val onError: (String) -> Unit,
private val onInitialized: () -> Unit = {}
) {
private val TAG = "VoskRecognitionManager"
private var model: Model? = null
private var speechService: SpeechService? = null
private var currentMode: RecognitionMode = RecognitionMode.STOPPED
private var audioBuffer: MutableList<ByteArray> = mutableListOf()
private var isRecordingAudio = false
/**
* Get wake words from preferences.
*/
private fun getWakeWords(): Set<String> {
val prefs = context.getSharedPreferences("alfred_settings", android.content.Context.MODE_PRIVATE)
val customWord = prefs.getString("wake_word", "alfred") ?: "alfred"
return setOf(
customWord.lowercase().trim(),
"hey ${customWord.lowercase().trim()}",
"ok ${customWord.lowercase().trim()}"
)
}
enum class RecognitionMode {
STOPPED,
WAKE_WORD,
FULL_RECOGNITION
}
/**
* Initialize the Vosk model (must be called before start).
* Copies model from assets and loads it.
*/
suspend fun initialize() {
withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Initializing Vosk model...")
// Target directory in app's internal storage
val modelDir = File(context.filesDir, "vosk-model")
// Copy model from assets if not already there
if (!modelDir.exists() || !File(modelDir, "am").exists()) {
Log.d(TAG, "Copying model from assets...")
copyModelFromAssets(modelDir)
Log.d(TAG, "Model copied successfully")
}
// Load the model
Log.d(TAG, "Loading model from ${modelDir.absolutePath}")
model = Model(modelDir.absolutePath)
Log.d(TAG, "Model loaded successfully")
// Notify success on main thread
withContext(Dispatchers.Main) {
onInitialized()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize model", e)
withContext(Dispatchers.Main) {
onError("Recognition setup failed: ${e.message}")
}
}
}
}
/**
* Start wake word detection mode.
*/
fun startWakeWordMode() {
if (currentMode != RecognitionMode.STOPPED) {
Log.w(TAG, "Already running in mode: $currentMode")
return
}
val currentModel = model
if (currentModel == null) {
onError("Model not initialized. Call initialize() first.")
return
}
try {
Log.d(TAG, "=== Starting wake word mode ===")
// Create recognizer for wake word detection (partial results only)
val recognizer = Recognizer(currentModel, 16000.0f)
recognizer.setMaxAlternatives(0)
recognizer.setWords(false)
Log.d(TAG, "Recognizer created for wake word, starting listener...")
startListening(recognizer, RecognitionMode.WAKE_WORD)
Log.d(TAG, "Wake word listener started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start wake word mode", e)
onError("Failed to start wake word detection: ${e.message}")
}
}
/**
* Start full recognition mode (transcribe full sentences).
*/
fun startFullRecognitionMode() {
if (currentMode != RecognitionMode.STOPPED) {
stop()
}
val currentModel = model
if (currentModel == null) {
onError("Model not initialized. Call initialize() first.")
return
}
try {
Log.d(TAG, "=== Starting full recognition mode ===")
// Create recognizer for full transcription
val recognizer = Recognizer(currentModel, 16000.0f)
recognizer.setMaxAlternatives(1)
recognizer.setWords(true)
Log.d(TAG, "Recognizer created for full transcription")
// Increase timeout for longer phrases
// Note: Vosk's internal timeout is ~10 seconds of silence by default
// Start recording audio for potential Google fallback
audioBuffer.clear()
isRecordingAudio = true
Log.d(TAG, "Starting full recognition listener...")
startListening(recognizer, RecognitionMode.FULL_RECOGNITION)
Log.d(TAG, "Full recognition listener started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start full recognition mode", e)
onError("Failed to start recognition: ${e.message}")
}
}
/**
* Start listening with the given recognizer and mode.
*/
private fun startListening(recognizer: Recognizer, mode: RecognitionMode) {
Log.d(TAG, "startListening called with mode: $mode")
try {
speechService = SpeechService(recognizer, 16000.0f)
Log.d(TAG, "SpeechService created, about to start listening...")
} catch (e: Exception) {
Log.e(TAG, "Failed to create SpeechService", e)
throw e
}
speechService?.startListening(object : RecognitionListener {
override fun onPartialResult(hypothesis: String?) {
Log.d(TAG, "onPartialResult: $hypothesis (mode: $mode)")
hypothesis?.let {
when (mode) {
RecognitionMode.WAKE_WORD -> checkForWakeWord(it)
RecognitionMode.FULL_RECOGNITION -> {
// Could show partial results in UI here
Log.d(TAG, "Partial recognition: $it")
}
RecognitionMode.STOPPED -> {}
}
}
}
override fun onResult(hypothesis: String?) {
Log.d(TAG, "onResult: $hypothesis (mode: $mode)")
hypothesis?.let {
when (mode) {
RecognitionMode.WAKE_WORD -> checkForWakeWord(it)
RecognitionMode.FULL_RECOGNITION -> handleFullResult(it)
RecognitionMode.STOPPED -> {}
}
}
}
override fun onFinalResult(hypothesis: String?) {
Log.d(TAG, "onFinalResult: $hypothesis (mode: $mode)")
if (mode == RecognitionMode.FULL_RECOGNITION) {
hypothesis?.let { handleFullResult(it) }
isRecordingAudio = false
}
}
override fun onError(exception: Exception?) {
Log.e(TAG, "Recognition error in mode $mode", exception)
currentMode = RecognitionMode.STOPPED
isRecordingAudio = false
onError("Recognition error: ${exception?.message}")
}
override fun onTimeout() {
Log.d(TAG, "Recognition timeout in mode: $mode")
isRecordingAudio = false
currentMode = RecognitionMode.STOPPED
if (mode == RecognitionMode.WAKE_WORD) {
// Restart wake word detection automatically
startWakeWordMode()
} else {
// Full recognition timeout - user might not have said anything yet
// Don't treat this as an error, just return to wake word mode
Log.w(TAG, "Full recognition timeout - no speech detected")
// Return to wake word mode instead of showing error
startWakeWordMode()
}
}
})
currentMode = mode
Log.d(TAG, "Started listening in mode: $mode")
}
/**
* Check if the hypothesis contains a wake word.
*/
private fun checkForWakeWord(hypothesis: String) {
try {
val json = JSONObject(hypothesis)
val text = json.optString("partial", json.optString("text", ""))
if (text.isNotEmpty()) {
val lowerText = text.trim().lowercase()
// Check for wake words (get from preferences)
val wakeWords = getWakeWords()
for (wakeWord in wakeWords) {
if (lowerText.contains(wakeWord)) {
Log.i(TAG, "Wake word detected: $wakeWord")
stop()
onWakeWordDetected()
return
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing hypothesis: $hypothesis", e)
}
}
/**
* Handle full recognition result.
*/
private fun handleFullResult(hypothesis: String) {
try {
Log.d(TAG, "handleFullResult called with: $hypothesis")
val json = JSONObject(hypothesis)
// Vosk returns: { "alternatives": [{ "text": "...", "confidence": ... }] }
// NOT: { "text": "..." } at top level
var text = ""
var confidence = 1.0f
val alternatives = json.optJSONArray("alternatives")
if (alternatives != null && alternatives.length() > 0) {
val firstAlt = alternatives.getJSONObject(0)
text = firstAlt.optString("text", "").trim()
confidence = firstAlt.optDouble("confidence", 1.0).toFloat()
// Normalize confidence (Vosk gives raw log-likelihood scores)
// Typical range: 0-500+, normalize to 0-1
if (confidence > 1.0f) {
confidence = Math.min(confidence / 500.0f, 1.0f)
}
}
if (text.isNotEmpty()) {
Log.d(TAG, "Full result: '$text' (confidence: $confidence)")
stop()
onTranscriptionResult(text, confidence)
} else {
Log.w(TAG, "Empty transcription result, hypothesis: $hypothesis")
// Don't show error - just silently return to wake word mode
// User might have just paused or not said anything
stop()
// Don't call onError - just go back to wake word mode
// The onTimeout handler will take care of this
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing full result: $hypothesis", e)
stop()
onError("Failed to parse result")
}
}
/**
* Stop listening.
*/
fun stop() {
if (currentMode == RecognitionMode.STOPPED) {
return
}
try {
speechService?.stop()
speechService?.shutdown()
speechService = null
currentMode = RecognitionMode.STOPPED
isRecordingAudio = false
Log.d(TAG, "Stopped listening")
} catch (e: Exception) {
Log.e(TAG, "Error stopping recognition", e)
}
}
/**
* Get saved audio buffer for Google fallback.
*/
fun getSavedAudioBuffer(): List<ByteArray> {
return audioBuffer.toList()
}
/**
* Save audio buffer to temporary file for Google fallback.
*/
fun saveAudioToFile(): File? {
if (audioBuffer.isEmpty()) return null
try {
val tempFile = File(context.cacheDir, "vosk_audio_${System.currentTimeMillis()}.wav")
// TODO: Write WAV header + audio data
// For now, return null - Google fallback via re-recording
return null
} catch (e: Exception) {
Log.e(TAG, "Failed to save audio", e)
return null
}
}
/**
* Copy model from assets to internal storage.
*/
private fun copyModelFromAssets(targetDir: File) {
targetDir.mkdirs()
val dirs = listOf("am", "conf", "graph", "ivector")
for (dir in dirs) {
val assetPath = "vosk-model/$dir"
val targetSubDir = File(targetDir, dir)
targetSubDir.mkdirs()
val files = context.assets.list(assetPath) ?: continue
for (file in files) {
val assetFilePath = "$assetPath/$file"
val targetFile = File(targetSubDir, file)
val subFiles = context.assets.list(assetFilePath)
if (subFiles != null && subFiles.isNotEmpty()) {
targetFile.mkdirs()
for (subFile in subFiles) {
copyAssetFile("$assetFilePath/$subFile", File(targetFile, subFile))
}
} else {
copyAssetFile(assetFilePath, targetFile)
}
}
}
try {
copyAssetFile("vosk-model/README", File(targetDir, "README"))
} catch (e: Exception) {
// README is optional
}
}
/**
* Copy a single file from assets.
*/
private fun copyAssetFile(assetPath: String, targetFile: File) {
context.assets.open(assetPath).use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
/**
* Cleanup resources.
*/
fun destroy() {
stop()
model?.close()
model = null
audioBuffer.clear()
}
/**
* Get current mode.
*/
fun getCurrentMode(): RecognitionMode = currentMode
/**
* Check if model is initialized.
*/
fun isInitialized(): Boolean = model != null
}

View File

@@ -0,0 +1,303 @@
package com.openclaw.alfred.voice
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.RecognitionListener
import org.vosk.android.SpeechService
import org.vosk.android.StorageService
import java.io.File
import java.io.IOException
/**
* Wake word detector using Vosk for offline, continuous listening.
* Listens for "hey alfred" or "alfred" to trigger voice input.
*/
class WakeWordDetector(
private val context: Context,
private val onWakeWordDetected: () -> Unit,
private val onError: (String) -> Unit,
private val onInitialized: () -> Unit = {}
) {
private val TAG = "WakeWordDetector"
private var model: Model? = null
private var speechService: SpeechService? = null
private var isListening = false
private var detectionPending = false // Prevent duplicate detections
/**
* Get wake words from preferences.
* Returns: ["alfred", "hey alfred", "ok alfred"] (or custom variants)
*/
private fun getWakeWords(): Set<String> {
val prefs = context.getSharedPreferences("alfred_settings", android.content.Context.MODE_PRIVATE)
val customWord = prefs.getString("wake_word", "alfred") ?: "alfred"
return setOf(
customWord.lowercase().trim(),
"hey ${customWord.lowercase().trim()}",
"ok ${customWord.lowercase().trim()}"
)
}
/**
* Initialize the Vosk model (must be called before start).
* Copies model from assets and loads it.
*/
suspend fun initialize() {
withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Initializing Vosk model...")
// Target directory in app's internal storage
val modelDir = File(context.filesDir, "vosk-model")
// Copy model from assets if not already there
if (!modelDir.exists() || !File(modelDir, "am").exists()) {
Log.d(TAG, "Copying model from assets...")
copyModelFromAssets(modelDir)
Log.d(TAG, "Model copied successfully")
}
// Load the model
Log.d(TAG, "Loading model from ${modelDir.absolutePath}")
model = Model(modelDir.absolutePath)
Log.d(TAG, "Model loaded successfully")
// Notify success on main thread
withContext(Dispatchers.Main) {
onInitialized()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize model", e)
withContext(Dispatchers.Main) {
onError("Wake word setup failed: ${e.message}")
}
}
}
}
/**
* Copy model from assets to internal storage.
*/
private fun copyModelFromAssets(targetDir: File) {
targetDir.mkdirs()
// List of directories to copy
val dirs = listOf("am", "conf", "graph", "ivector")
for (dir in dirs) {
val assetPath = "vosk-model/$dir"
val targetSubDir = File(targetDir, dir)
targetSubDir.mkdirs()
// Copy all files in this directory
val files = context.assets.list(assetPath) ?: continue
for (file in files) {
val assetFilePath = "$assetPath/$file"
val targetFile = File(targetSubDir, file)
// Check if it's a subdirectory
val subFiles = context.assets.list(assetFilePath)
if (subFiles != null && subFiles.isNotEmpty()) {
// It's a directory, recurse
targetFile.mkdirs()
for (subFile in subFiles) {
copyAssetFile("$assetFilePath/$subFile", File(targetFile, subFile))
}
} else {
// It's a file, copy it
copyAssetFile(assetFilePath, targetFile)
}
}
}
// Copy README if it exists
try {
copyAssetFile("vosk-model/README", File(targetDir, "README"))
} catch (e: Exception) {
// README is optional
}
}
/**
* Copy a single file from assets.
*/
private fun copyAssetFile(assetPath: String, targetFile: File) {
context.assets.open(assetPath).use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
/**
* Start listening for wake words.
*/
fun start() {
if (isListening) {
Log.w(TAG, "Already listening")
return
}
val currentModel = model
if (currentModel == null) {
onError("Model not initialized. Call initialize() first.")
return
}
try {
// Reset detection flag for new listening session
detectionPending = false
// Create recognizer for partial results
val recognizer = Recognizer(currentModel, 16000.0f)
recognizer.setMaxAlternatives(1)
recognizer.setWords(false) // Don't need word timestamps for wake word
// Create speech service with recognition listener
speechService = SpeechService(recognizer, 16000.0f)
speechService?.startListening(object : RecognitionListener {
override fun onPartialResult(hypothesis: String?) {
hypothesis?.let { checkForWakeWord(it) }
}
override fun onResult(hypothesis: String?) {
hypothesis?.let { checkForWakeWord(it) }
}
override fun onFinalResult(hypothesis: String?) {
// Not used for continuous listening
}
override fun onError(exception: Exception?) {
Log.e(TAG, "Recognition error", exception)
onError("Wake word error: ${exception?.message}")
}
override fun onTimeout() {
Log.d(TAG, "Recognition timeout - restarting (continuous mode)")
// Immediately restart for continuous listening
if (isListening) {
start()
}
}
})
isListening = true
Log.d(TAG, "Started listening for wake words")
} catch (e: Exception) {
Log.e(TAG, "Failed to start listening", e)
onError("Failed to start wake word: ${e.message}")
}
}
/**
* Stop listening for wake words.
*/
fun stop() {
if (!isListening) {
return
}
try {
speechService?.stop()
speechService?.shutdown()
speechService = null
isListening = false
Log.d(TAG, "Stopped listening for wake words")
} catch (e: Exception) {
Log.e(TAG, "Error stopping wake word detector", e)
}
}
/**
* Check if the hypothesis contains a wake word.
*/
private fun checkForWakeWord(hypothesis: String) {
// Prevent duplicate detections
if (detectionPending) {
return
}
try {
// Parse JSON hypothesis
val json = JSONObject(hypothesis)
val text = json.optString("partial", json.optString("text", ""))
if (text.isNotEmpty()) {
val lowerText = text.trim().lowercase()
Log.d(TAG, "Heard: $lowerText")
// Check for wake words (get from preferences)
val wakeWords = getWakeWords()
for (wakeWord in wakeWords) {
if (lowerText.contains(wakeWord)) {
Log.i(TAG, "Wake word detected: $wakeWord")
detectionPending = true // Block further detections
onWakeWordDetected()
return
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing hypothesis: $hypothesis", e)
}
}
/**
* Copy folder from assets to storage recursively.
*/
private fun copyAssetFolderRecursive(assetPath: String, targetPath: File) {
try {
val assets = context.assets.list(assetPath)
if (assets == null || assets.isEmpty()) {
// It's a file, copy it
targetPath.parentFile?.mkdirs()
context.assets.open(assetPath).use { input ->
targetPath.outputStream().use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "Copied file: $assetPath -> ${targetPath.absolutePath}")
} else {
// It's a directory, create it and recurse for each child
targetPath.mkdirs()
for (asset in assets) {
val subAssetPath = "$assetPath/$asset"
val subTargetPath = File(targetPath, asset)
copyAssetFolderRecursive(subAssetPath, subTargetPath)
}
Log.d(TAG, "Copied directory: $assetPath -> ${targetPath.absolutePath}")
}
} catch (e: IOException) {
Log.e(TAG, "Error copying asset: $assetPath", e)
throw e
}
}
/**
* Cleanup resources.
*/
fun destroy() {
stop()
model?.close()
model = null
}
/**
* Check if currently listening.
*/
fun isListening(): Boolean = isListening
}

View File

@@ -0,0 +1,277 @@
package com.openclaw.alfred.voice
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.*
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.RecognitionListener
import org.vosk.android.SpeechService
import org.vosk.android.StorageService
import org.json.JSONObject
import java.io.File
/**
* Manages wake word detection using Vosk.
* Listens for "alfred" or "hey alfred" to trigger voice input.
*/
class WakeWordManager(
private val context: Context,
private val onWakeWordDetected: (fullText: String?) -> Unit,
private val onError: (String) -> Unit,
private val onListeningStateChanged: (Boolean) -> Unit
) {
private val TAG = "WakeWordManager"
private var speechService: SpeechService? = null
private var model: Model? = null
private var isListening = false
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Get wake words from preferences.
*/
private fun getWakeWords(): List<String> {
val prefs = context.getSharedPreferences("alfred_settings", android.content.Context.MODE_PRIVATE)
val customWord = prefs.getString("wake_word", "alfred") ?: "alfred"
return listOf(
customWord.lowercase().trim(),
"hey ${customWord.lowercase().trim()}",
"ok ${customWord.lowercase().trim()}"
)
}
/**
* Initialize Vosk model.
* Model should be in assets/vosk-model-small-en-us-0.15/
*/
fun initialize(onReady: () -> Unit) {
scope.launch {
try {
Log.d(TAG, "Initializing Vosk model...")
// Unpack model from assets to storage if needed
val modelPath = initModel()
if (modelPath == null) {
withContext(Dispatchers.Main) {
onError("Vosk model not found in assets. Please download vosk-model-small-en-us-0.15")
}
return@launch
}
// Load the model
model = Model(modelPath)
Log.d(TAG, "Vosk model initialized successfully")
withContext(Dispatchers.Main) {
onReady()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Vosk", e)
withContext(Dispatchers.Main) {
onError("Failed to initialize wake word: ${e.message}")
}
}
}
}
/**
* Unpack model from assets to internal storage.
*/
private suspend fun initModel(): String? {
return withContext(Dispatchers.IO) {
try {
StorageService.unpack(
context,
"vosk-model-small-en-us-0.15",
"model",
{ model ->
Log.d(TAG, "Model unpacked successfully")
},
{ exception ->
Log.e(TAG, "Failed to unpack model", exception)
}
)
// Return path to unpacked model
val modelDir = File(context.filesDir, "model")
if (modelDir.exists()) {
modelDir.absolutePath
} else {
null
}
} catch (e: Exception) {
Log.e(TAG, "Error unpacking model", e)
null
}
}
}
/**
* Start listening for wake word.
*/
fun startListening() {
if (isListening) {
Log.w(TAG, "Already listening")
return
}
if (model == null) {
onError("Model not initialized. Call initialize() first.")
return
}
// Check microphone permission
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
onError("Microphone permission not granted")
return
}
try {
// Create recognizer for continuous listening
val recognizer = Recognizer(model, 16000.0f)
// Set up grammar for wake words (improves accuracy and reduces false positives)
recognizer.setGrammar(
"[\"alfred\", \"hey alfred\", \"ok alfred\", " +
"\"[unk]\"]"
)
// Create speech service
speechService = SpeechService(recognizer, 16000.0f)
speechService?.startListening(object : RecognitionListener {
override fun onPartialResult(hypothesis: String?) {
// Check for wake word in partial results
hypothesis?.let { checkForWakeWord(it, partial = true) }
}
override fun onResult(hypothesis: String?) {
// Check for wake word in final results
hypothesis?.let { checkForWakeWord(it, partial = false) }
}
override fun onFinalResult(hypothesis: String?) {
// Final result
hypothesis?.let { checkForWakeWord(it, partial = false) }
}
override fun onError(exception: Exception?) {
Log.e(TAG, "Recognition error", exception)
onError("Wake word detection error: ${exception?.message}")
}
override fun onTimeout() {
Log.d(TAG, "Recognition timeout")
}
})
isListening = true
onListeningStateChanged(true)
Log.d(TAG, "Started listening for wake word")
} catch (e: Exception) {
Log.e(TAG, "Failed to start listening", e)
onError("Failed to start wake word detection: ${e.message}")
}
}
/**
* Stop listening for wake word.
*/
fun stopListening() {
if (!isListening) {
return
}
try {
speechService?.stop()
speechService?.shutdown()
speechService = null
isListening = false
onListeningStateChanged(false)
Log.d(TAG, "Stopped listening for wake word")
} catch (e: Exception) {
Log.e(TAG, "Error stopping listening", e)
}
}
/**
* Check if recognized text contains a wake word.
*/
private fun checkForWakeWord(hypothesis: String, partial: Boolean) {
try {
val json = JSONObject(hypothesis)
val text = json.optString("text", "").lowercase().trim()
if (text.isEmpty()) {
return
}
Log.d(TAG, "Recognized (partial=$partial): $text")
// Check if any wake word is present (get from preferences)
val wakeWords = getWakeWords()
for (wakeWord in wakeWords) {
if (text.contains(wakeWord)) {
Log.i(TAG, "Wake word detected: $wakeWord in '$text'")
// Extract the command after the wake word (if any)
val commandText = extractCommandAfterWakeWord(text, wakeWord)
// Trigger callback
onWakeWordDetected(commandText)
// For now, stop listening after wake word (prevents repeated triggers)
// Could be made configurable for continuous listening
stopListening()
break
}
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing hypothesis", e)
}
}
/**
* Extract the command text after the wake word.
* E.g., "hey alfred what's the weather" -> "what's the weather"
*/
private fun extractCommandAfterWakeWord(text: String, wakeWord: String): String? {
val index = text.indexOf(wakeWord)
if (index < 0) {
return null
}
val afterWakeWord = text.substring(index + wakeWord.length).trim()
return if (afterWakeWord.isNotEmpty()) afterWakeWord else null
}
/**
* Check if currently listening.
*/
fun isListening(): Boolean = isListening
/**
* Cleanup resources.
*/
fun destroy() {
stopListening()
model?.close()
model = null
scope.cancel()
}
}

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#4A90E2"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M30,50h48v8h-48z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M50,30h8v48h-8z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="ic_launcher_background">#4A90E2</color>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">AI Assistant</string>
<string name="welcome_message">Hello, how may I assist you today?</string>
<string name="microphone_permission_required">Microphone permission required for voice input</string>
<string name="notification_permission_required">Notification permission required for reminders</string>
<string name="settings">Settings</string>
<string name="chat">Chat</string>
<string name="voice_input">Voice Input</string>
<string name="listening">Listening...</string>
<string name="processing">Processing...</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Alfred" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Exclude files that shouldn't be backed up -->
</full-backup-content>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<!-- Exclude files that shouldn't be backed up to cloud -->
</cloud-backup>
</data-extraction-rules>