diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5680519..a0194c4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
/* Permission result handled by system */ }
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ // Request notification permission on Android 13+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+ != PackageManager.PERMISSION_GRANTED) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
setContent {
- val mainViewModel: MainViewModel = viewModel()
- val themeMode by mainViewModel.themeMode.collectAsState()
- val useDynamicColor by mainViewModel.useDynamicColor.collectAsState()
+ val vm: MainViewModel = viewModel()
+ mainViewModel = vm
+ val themeMode by vm.themeMode.collectAsState()
+ val useDynamicColor by vm.useDynamicColor.collectAsState()
val isDarkTheme = when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -35,9 +56,18 @@ class MainActivity : AppCompatActivity() {
darkTheme = isDarkTheme,
dynamicColor = useDynamicColor
) {
- OpenClawDashboardApp(mainViewModel = mainViewModel)
+ OpenClawDashboardApp(mainViewModel = vm)
}
}
}
+
+ override fun onStart() {
+ super.onStart()
+ mainViewModel?.setAppInForeground(true)
+ }
+
+ override fun onStop() {
+ super.onStop()
+ mainViewModel?.setAppInForeground(false)
+ }
}
-
diff --git a/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt b/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt
index 8b369e3..8c7727e 100644
--- a/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt
+++ b/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt
@@ -1,6 +1,7 @@
package com.openclaw.dashboard
import android.app.Application
+import com.openclaw.dashboard.util.NotificationHelper
class OpenClawApp : Application() {
@@ -12,5 +13,6 @@ class OpenClawApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
+ NotificationHelper.createNotificationChannels(this)
}
}
diff --git a/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt b/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt
index 1d9e841..94d97f9 100644
--- a/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt
@@ -32,6 +32,7 @@ class SettingsRepository(private val context: Context) {
private val KEY_USE_DYNAMIC_COLOR = booleanPreferencesKey("use_dynamic_color")
private val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
private val KEY_LAST_SESSION_KEY = stringPreferencesKey("last_session_key")
+ private val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
}
/**
@@ -120,6 +121,22 @@ class SettingsRepository(private val context: Context) {
}
}
+ /**
+ * Get notifications enabled preference
+ */
+ val notificationsEnabled: Flow = context.dataStore.data.map { preferences ->
+ preferences[KEY_NOTIFICATIONS_ENABLED] ?: true
+ }
+
+ /**
+ * Update notifications enabled preference
+ */
+ suspend fun setNotificationsEnabled(enabled: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[KEY_NOTIFICATIONS_ENABLED] = enabled
+ }
+ }
+
/**
* Get last selected session key
*/
diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt b/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt
index ee2b614..85c71a1 100644
--- a/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt
+++ b/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt
@@ -14,6 +14,7 @@ import com.openclaw.dashboard.data.remote.GatewayClient
import com.openclaw.dashboard.data.remote.GatewayEvent
import com.openclaw.dashboard.data.repository.SettingsRepository
import com.openclaw.dashboard.data.repository.ThemeMode
+import com.openclaw.dashboard.util.NotificationHelper
import com.openclaw.dashboard.R
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -53,6 +54,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
true
)
+ // Notification settings
+ val notificationsEnabled = settingsRepository.notificationsEnabled.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000),
+ true
+ )
+
+ // Track if the app is in foreground
+ private var _isAppInForeground = true
+
+ fun setAppInForeground(inForeground: Boolean) {
+ _isAppInForeground = inForeground
+ if (inForeground) {
+ // Cancel notification when user returns to the app
+ NotificationHelper.cancelChatNotification(getApplication())
+ }
+ }
+
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch {
settingsRepository.setThemeMode(mode)
@@ -65,6 +84,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
+ fun setNotificationsEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ settingsRepository.setNotificationsEnabled(enabled)
+ }
+ }
+
// Sessions
private val _sessions = MutableStateFlow>(emptyList())
val sessions: StateFlow> = _sessions.asStateFlow()
@@ -160,6 +185,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
+
+ // Send notification for AI replies when app is in background
+ if (chatEvent.state == "final" &&
+ chatEvent.message?.role?.lowercase() != "user" &&
+ !_isAppInForeground &&
+ notificationsEnabled.value) {
+ val content = chatEvent.delta ?: chatEvent.message?.content?.toString() ?: ""
+ if (content.isNotBlank()) {
+ val sessionTitle = _sessions.value
+ .find { it.key == chatEvent.sessionKey }
+ ?.let { it.derivedTitle ?: it.label ?: it.key }
+ NotificationHelper.showChatReplyNotification(
+ getApplication(),
+ sessionTitle,
+ content.replace("\\n", "\n").take(200)
+ )
+ }
+ }
}
is GatewayEvent.Agent -> {
val agentEvent = event.event
diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt b/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt
index 6395f8b..e344d8f 100644
--- a/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt
+++ b/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt
@@ -18,7 +18,7 @@ import com.openclaw.dashboard.R
import com.openclaw.dashboard.data.repository.ThemeMode
/**
- * Settings popup dialog for theme and language configuration
+ * Settings popup dialog for theme, notification, and language configuration
*/
@Composable
fun SettingsDialog(
@@ -27,7 +27,9 @@ fun SettingsDialog(
currentThemeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
useDynamicColor: Boolean,
- onDynamicColorChange: (Boolean) -> Unit
+ onDynamicColorChange: (Boolean) -> Unit,
+ notificationsEnabled: Boolean,
+ onNotificationsEnabledChange: (Boolean) -> Unit
) {
if (!isOpen) return
@@ -110,6 +112,33 @@ fun SettingsDialog(
HorizontalDivider()
+ // Notifications Section
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.settings_notifications),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = stringResource(R.string.settings_notifications_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = notificationsEnabled,
+ onCheckedChange = onNotificationsEnabledChange
+ )
+ }
+
+ HorizontalDivider()
+
// Language Section
Text(
text = stringResource(R.string.language),
diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt b/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt
index 2b433ea..fdb7b1f 100644
--- a/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt
+++ b/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt
@@ -97,10 +97,23 @@ fun ChatScreen(
}
}
- // Auto-scroll to bottom when new message arrives
- LaunchedEffect(messages.size) {
- if (messages.isNotEmpty()) {
- listState.animateScrollToItem(messages.size - 1)
+ // Filter out messages that shouldn't be shown to users
+ val filteredMessages = remember(messages) {
+ messages.filter { msg ->
+ val role = msg.message?.role?.lowercase() ?: ""
+ if (role in listOf("toolresult", "tool", "system", "toolcall")) {
+ return@filter false
+ }
+ val content = extractMessageContent(msg)
+ content.isNotBlank()
+ }
+ }
+
+ // Auto-scroll to bottom when new message arrives or typing indicator appears
+ LaunchedEffect(filteredMessages.size, isAiTyping) {
+ if (filteredMessages.isNotEmpty()) {
+ val lastIndex = filteredMessages.size - 1 + if (isAiTyping) 1 else 0
+ listState.animateScrollToItem(lastIndex)
}
}
@@ -171,19 +184,6 @@ fun ChatScreen(
)
}
else -> {
- // Filter out messages that shouldn't be shown to users
- val filteredMessages = messages.filter { msg ->
- val role = msg.message?.role?.lowercase() ?: ""
- // Hide tool results, tool calls, and system messages
- if (role in listOf("toolresult", "tool", "system", "toolcall")) {
- return@filter false
- }
-
- // Also filter out messages with empty content
- val content = extractMessageContent(msg)
- content.isNotBlank()
- }
-
LazyColumn(
state = listState,
contentPadding = PaddingValues(16.dp),
diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt b/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt
index 655fadf..6fc67fa 100644
--- a/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt
+++ b/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt
@@ -34,6 +34,7 @@ fun OverviewScreen(
// Theme settings state
val themeMode by viewModel.themeMode.collectAsState()
val useDynamicColor by viewModel.useDynamicColor.collectAsState()
+ val notificationsEnabled by viewModel.notificationsEnabled.collectAsState()
var showSettingsDialog by remember { mutableStateOf(false) }
// Settings Dialog
@@ -43,7 +44,9 @@ fun OverviewScreen(
currentThemeMode = themeMode,
onThemeModeChange = { viewModel.setThemeMode(it) },
useDynamicColor = useDynamicColor,
- onDynamicColorChange = { viewModel.setUseDynamicColor(it) }
+ onDynamicColorChange = { viewModel.setUseDynamicColor(it) },
+ notificationsEnabled = notificationsEnabled,
+ onNotificationsEnabledChange = { viewModel.setNotificationsEnabled(it) }
)
Scaffold(
diff --git a/app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt b/app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt
new file mode 100644
index 0000000..893a2c4
--- /dev/null
+++ b/app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt
@@ -0,0 +1,85 @@
+package com.openclaw.dashboard.util
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import com.openclaw.dashboard.MainActivity
+import com.openclaw.dashboard.R
+
+/**
+ * Helper class for managing notifications
+ */
+object NotificationHelper {
+
+ const val CHANNEL_ID_CHAT = "chat_replies"
+ private const val NOTIFICATION_ID_CHAT = 1001
+
+ /**
+ * Create notification channels (must be called on app startup)
+ */
+ fun createNotificationChannels(context: Context) {
+ val channel = NotificationChannel(
+ CHANNEL_ID_CHAT,
+ context.getString(R.string.notification_channel_chat),
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ description = context.getString(R.string.notification_channel_chat_desc)
+ }
+
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.createNotificationChannel(channel)
+ }
+
+ /**
+ * Show a notification for AI reply
+ */
+ fun showChatReplyNotification(
+ context: Context,
+ sessionTitle: String?,
+ messagePreview: String
+ ) {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val title = sessionTitle ?: context.getString(R.string.notification_chat_default_title)
+
+ // Truncate long messages
+ val preview = if (messagePreview.length > 200) {
+ messagePreview.take(200) + "…"
+ } else {
+ messagePreview
+ }
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID_CHAT)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(title)
+ .setContentText(preview)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(preview))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .build()
+
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.notify(NOTIFICATION_ID_CHAT, notification)
+ }
+
+ /**
+ * Cancel chat notification (e.g., when user opens the chat)
+ */
+ fun cancelChatNotification(context: Context) {
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.cancel(NOTIFICATION_ID_CHAT)
+ }
+}
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index d7c2ee6..4179300 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -64,6 +64,11 @@
目前沒有可用的 Session
選擇一個 Session
+
+ AI 回覆
+ AI 聊天回覆通知
+ AI 回覆
+
配置編輯器
儲存中…
@@ -120,6 +125,8 @@
深色模式
動態色彩
使用壁紙色彩
+ 通知
+ AI 回覆時發送通知
完成
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f82b760..102c8c8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -64,6 +64,11 @@
No sessions available
Select a session
+
+ AI Replies
+ Notifications for AI chat replies
+ AI Reply
+
Config Editor
Saving…
@@ -120,6 +125,8 @@
Dark Mode
Dynamic Color
Use wallpaper colors
+ Notifications
+ Notify when AI replies
Done