Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".OpenClawApp"
Expand Down
40 changes: 35 additions & 5 deletions app/src/main/java/com/openclaw/dashboard/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.openclaw.dashboard

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.viewmodel.compose.viewModel
import com.openclaw.dashboard.data.repository.ThemeMode
Expand All @@ -15,15 +20,31 @@ import com.openclaw.dashboard.presentation.OpenClawDashboardApp
import com.openclaw.dashboard.presentation.theme.OpenClawTheme

class MainActivity : AppCompatActivity() {

private var mainViewModel: MainViewModel? = null

private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ -> /* 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()
Expand All @@ -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)
}
}

2 changes: 2 additions & 0 deletions app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.openclaw.dashboard

import android.app.Application
import com.openclaw.dashboard.util.NotificationHelper

class OpenClawApp : Application() {

Expand All @@ -12,5 +13,6 @@ class OpenClawApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
NotificationHelper.createNotificationChannels(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

/**
Expand Down Expand Up @@ -120,6 +121,22 @@ class SettingsRepository(private val context: Context) {
}
}

/**
* Get notifications enabled preference
*/
val notificationsEnabled: Flow<Boolean> = 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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -65,6 +84,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}

fun setNotificationsEnabled(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.setNotificationsEnabled(enabled)
}
}

// Sessions
private val _sessions = MutableStateFlow<List<SessionInfo>>(emptyList())
val sessions: StateFlow<List<SessionInfo>> = _sessions.asStateFlow()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading