diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b9c2888d..f49cf835 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
diff --git a/app/src/main/java/com/zhousl/aether/AetherForegroundService.kt b/app/src/main/java/com/zhousl/aether/AetherForegroundService.kt
index 09899836..3b37824d 100644
--- a/app/src/main/java/com/zhousl/aether/AetherForegroundService.kt
+++ b/app/src/main/java/com/zhousl/aether/AetherForegroundService.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
+import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import java.util.concurrent.atomic.AtomicBoolean
@@ -43,7 +44,7 @@ class AetherForegroundService : Service() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
- enterForeground(
+ updateForeground(
runtime.notificationController.buildForegroundNotification(
sessions = sessions,
executionStates = executionStates,
@@ -90,6 +91,20 @@ class AetherForegroundService : Service() {
hasEnteredForeground = true
}
+ /**
+ * Updates the Live Update foreground notification in place (same ID), so the
+ * system keeps the promoted-ongoing behavior and just refreshes the content
+ * — this is what makes the task progress "live".
+ */
+ private fun updateForeground(notification: android.app.Notification) {
+ val notificationManager = NotificationManagerCompat.from(this)
+ if (hasEnteredForeground) {
+ notificationManager.notify(ForegroundNotificationId, notification)
+ } else {
+ enterForeground(notification)
+ }
+ }
+
companion object {
private val startRequested = AtomicBoolean(false)
diff --git a/app/src/main/java/com/zhousl/aether/AetherNotificationController.kt b/app/src/main/java/com/zhousl/aether/AetherNotificationController.kt
index ebe8906e..21ca74b6 100644
--- a/app/src/main/java/com/zhousl/aether/AetherNotificationController.kt
+++ b/app/src/main/java/com/zhousl/aether/AetherNotificationController.kt
@@ -17,6 +17,7 @@ import com.zhousl.aether.ui.ChatSession
private const val ForegroundChannelId = "aether_background_runs"
private const val CompletionChannelId = "aether_completed_runs"
+private const val LiveUpdateChannelId = "aether_live_updates"
const val ForegroundNotificationId = 1001
class AetherNotificationController(
@@ -43,10 +44,32 @@ class AetherNotificationController(
).apply {
description = "Alerts you when a background Aether session finishes."
}
+ val liveUpdateChannel = NotificationChannel(
+ LiveUpdateChannelId,
+ "Live task progress",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Shows real-time progress of Aether tasks while the app is in the background."
+ setShowBadge(false)
+ }
manager.createNotificationChannel(foregroundChannel)
manager.createNotificationChannel(completionChannel)
+ manager.createNotificationChannel(liveUpdateChannel)
}
+ /**
+ * Builds the foreground notification for active background sessions.
+ *
+ * On Android 15+ (API 35+) the notification is promoted to a **Live Update**
+ * (a.k.a. focus notification): while the app is in the foreground the
+ * notification stays quiet, and when the app loses focus (goes to the
+ * background) the system surfaces it more prominently — status-bar chip,
+ * lock screen and top of the notification drawer — with the latest task
+ * progress, without the user opening the app.
+ *
+ * On older devices this gracefully degrades to a standard ongoing
+ * foreground notification.
+ */
fun buildForegroundNotification(
sessions: List,
executionStates: Map,
@@ -71,23 +94,46 @@ class AetherNotificationController(
PendingIntent.FLAG_UPDATE_CURRENT or pendingIntentMutabilityFlags(),
)
- return NotificationCompat.Builder(context, ForegroundChannelId)
+ // Live status detail: the most relevant running tool/status for each active session.
+ val liveStatusLines = activeSessions.mapNotNull { session ->
+ val state = executionStates[session.id] ?: return@mapNotNull null
+ val detail = state.pendingStatusDetail.ifBlank { state.pendingStatusText }
+ val tool = state.pendingToolInvocations.firstOrNull { it.isRunning }?.toolName
+ val suffix = when {
+ tool != null -> " · $tool"
+ detail.isNotBlank() -> " · $detail"
+ else -> ""
+ }
+ (session.title.ifBlank { "Untitled chat" } + suffix).takeIf { it.isNotBlank() }
+ }
+
+ val builder = NotificationCompat.Builder(
+ context,
+ if (activeSessions.isNotEmpty()) LiveUpdateChannelId else ForegroundChannelId,
+ )
.setSmallIcon(R.drawable.ic_notification_small)
.setContentTitle(title)
.setContentText(body)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(
- activeSessions.joinToString(separator = "\n") { session ->
- "- ${session.title.ifBlank { "Untitled chat" }}"
- }.ifBlank { body }
+ liveStatusLines.joinToString("\n").ifBlank { body }
)
)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(contentIntent)
- .build()
+
+ if (activeSessions.isNotEmpty()) {
+ // Live Update / focus notification (Android 15+): request promoted-ongoing
+ // rendering so the task progress is elevated while the app is backgrounded.
+ // AndroidX Core 1.17 maps this to EXTRA_REQUEST_PROMOTED_ONGOING on the
+ // platform notification, which is the supported compat path for Live Updates.
+ builder.setRequestPromotedOngoing(true)
+ }
+
+ return builder.build()
}
fun notifyCompletion(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7abecb9f..a444d74f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,7 +1,7 @@
[versions]
agp = "8.13.2"
kotlin = "2.4.0"
-coreKtx = "1.13.1"
+coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.8.6"
activityCompose = "1.9.2"
appcompat = "1.7.0"