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:
148
app/build.gradle.kts
Normal file
148
app/build.gradle.kts
Normal 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
27
app/proguard-rules.pro
vendored
Normal 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.**
|
||||
93
app/src/main/AndroidManifest.xml
Normal file
93
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
9
app/src/main/assets/vosk-model/README
Normal file
9
app/src/main/assets/vosk-model/README
Normal 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)
|
||||
|
||||
|
||||
BIN
app/src/main/assets/vosk-model/am/final.mdl
Normal file
BIN
app/src/main/assets/vosk-model/am/final.mdl
Normal file
Binary file not shown.
7
app/src/main/assets/vosk-model/conf/mfcc.conf
Normal file
7
app/src/main/assets/vosk-model/conf/mfcc.conf
Normal 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
|
||||
10
app/src/main/assets/vosk-model/conf/model.conf
Normal file
10
app/src/main/assets/vosk-model/conf/model.conf
Normal 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
|
||||
BIN
app/src/main/assets/vosk-model/graph/Gr.fst
Normal file
BIN
app/src/main/assets/vosk-model/graph/Gr.fst
Normal file
Binary file not shown.
BIN
app/src/main/assets/vosk-model/graph/HCLr.fst
Normal file
BIN
app/src/main/assets/vosk-model/graph/HCLr.fst
Normal file
Binary file not shown.
17
app/src/main/assets/vosk-model/graph/disambig_tid.int
Normal file
17
app/src/main/assets/vosk-model/graph/disambig_tid.int
Normal file
@@ -0,0 +1,17 @@
|
||||
10015
|
||||
10016
|
||||
10017
|
||||
10018
|
||||
10019
|
||||
10020
|
||||
10021
|
||||
10022
|
||||
10023
|
||||
10024
|
||||
10025
|
||||
10026
|
||||
10027
|
||||
10028
|
||||
10029
|
||||
10030
|
||||
10031
|
||||
166
app/src/main/assets/vosk-model/graph/phones/word_boundary.int
Normal file
166
app/src/main/assets/vosk-model/graph/phones/word_boundary.int
Normal 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
|
||||
BIN
app/src/main/assets/vosk-model/ivector/final.dubm
Normal file
BIN
app/src/main/assets/vosk-model/ivector/final.dubm
Normal file
Binary file not shown.
BIN
app/src/main/assets/vosk-model/ivector/final.ie
Normal file
BIN
app/src/main/assets/vosk-model/ivector/final.ie
Normal file
Binary file not shown.
BIN
app/src/main/assets/vosk-model/ivector/final.mat
Normal file
BIN
app/src/main/assets/vosk-model/ivector/final.mat
Normal file
Binary file not shown.
3
app/src/main/assets/vosk-model/ivector/global_cmvn.stats
Normal file
3
app/src/main/assets/vosk-model/ivector/global_cmvn.stats
Normal 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 ]
|
||||
1
app/src/main/assets/vosk-model/ivector/online_cmvn.conf
Normal file
1
app/src/main/assets/vosk-model/ivector/online_cmvn.conf
Normal file
@@ -0,0 +1 @@
|
||||
# configuration file for apply-cmvn-online, used in the script ../local/run_online_decoding.sh
|
||||
2
app/src/main/assets/vosk-model/ivector/splice.conf
Normal file
2
app/src/main/assets/vosk-model/ivector/splice.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
--left-context=3
|
||||
--right-context=3
|
||||
16
app/src/main/java/com/openclaw/alfred/AlfredApplication.kt
Normal file
16
app/src/main/java/com/openclaw/alfred/AlfredApplication.kt
Normal 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
|
||||
}
|
||||
}
|
||||
330
app/src/main/java/com/openclaw/alfred/MainActivity.kt
Normal file
330
app/src/main/java/com/openclaw/alfred/MainActivity.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
218
app/src/main/java/com/openclaw/alfred/alarm/AlarmActivity.kt
Normal file
218
app/src/main/java/com/openclaw/alfred/alarm/AlarmActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
255
app/src/main/java/com/openclaw/alfred/alarm/AlarmManager.kt
Normal file
255
app/src/main/java/com/openclaw/alfred/alarm/AlarmManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
264
app/src/main/java/com/openclaw/alfred/auth/AuthManager.kt
Normal file
264
app/src/main/java/com/openclaw/alfred/auth/AuthManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
14
app/src/main/java/com/openclaw/alfred/auth/AuthResult.kt
Normal file
14
app/src/main/java/com/openclaw/alfred/auth/AuthResult.kt
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/openclaw/alfred/auth/OAuthConfig.kt
Normal file
33
app/src/main/java/com/openclaw/alfred/auth/OAuthConfig.kt
Normal 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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
682
app/src/main/java/com/openclaw/alfred/gateway/GatewayClient.kt
Normal file
682
app/src/main/java/com/openclaw/alfred/gateway/GatewayClient.kt
Normal 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()
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
2234
app/src/main/java/com/openclaw/alfred/ui/screens/MainScreen.kt
Normal file
2234
app/src/main/java/com/openclaw/alfred/ui/screens/MainScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
19
app/src/main/java/com/openclaw/alfred/ui/theme/Color.kt
Normal file
19
app/src/main/java/com/openclaw/alfred/ui/theme/Color.kt
Normal 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)
|
||||
60
app/src/main/java/com/openclaw/alfred/ui/theme/Theme.kt
Normal file
60
app/src/main/java/com/openclaw/alfred/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/com/openclaw/alfred/ui/theme/Type.kt
Normal file
34
app/src/main/java/com/openclaw/alfred/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
*/
|
||||
)
|
||||
319
app/src/main/java/com/openclaw/alfred/voice/TTSManager.kt
Normal file
319
app/src/main/java/com/openclaw/alfred/voice/TTSManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
86
app/src/main/java/com/openclaw/alfred/voice/VoiceHelper.kt
Normal file
86
app/src/main/java/com/openclaw/alfred/voice/VoiceHelper.kt
Normal 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
|
||||
}
|
||||
}
|
||||
207
app/src/main/java/com/openclaw/alfred/voice/VoiceInputManager.kt
Normal file
207
app/src/main/java/com/openclaw/alfred/voice/VoiceInputManager.kt
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
303
app/src/main/java/com/openclaw/alfred/voice/WakeWordDetector.kt
Normal file
303
app/src/main/java/com/openclaw/alfred/voice/WakeWordDetector.kt
Normal 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
|
||||
}
|
||||
277
app/src/main/java/com/openclaw/alfred/voice/WakeWordManager.kt
Normal file
277
app/src/main/java/com/openclaw/alfred/voice/WakeWordManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
15
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
15
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
11
app/src/main/res/values/colors.xml
Normal file
11
app/src/main/res/values/colors.xml
Normal 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>
|
||||
12
app/src/main/res/values/strings.xml
Normal file
12
app/src/main/res/values/strings.xml
Normal 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>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Alfred" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
4
app/src/main/res/xml/backup_rules.xml
Normal file
4
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
6
app/src/main/res/xml/data_extraction_rules.xml
Normal file
6
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
Reference in New Issue
Block a user