Keep notifications alive when the app is closed (foreground service)

Adds LiveSocketService, a started foreground service that holds the /ws
WebSocket open after the app is swiped away and raises system notifications
for incoming alerts and messages while backgrounded. Shown as a low-priority
ongoing "Listening" notification (channel "Live updates").

- Started/stopped together with the push toggle (Profile) and on login/logout.
- foregroundServiceType=dataSync (+ FOREGROUND_SERVICE_DATA_SYNC permission).
- START_STICKY + onTaskRemoved no-op so it survives task removal; the OS may
  still kill it on explicit Force-Stop or reboot-until-reopened.
- MainShell no longer posts notifications itself (the service owns that) and
  just keeps the bell count fresh; the poll remains as a catch-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vojtěch Maršál
2026-06-13 15:03:52 +02:00
parent 9e2dd40af0
commit 7f695b0786
8 changed files with 153 additions and 35 deletions
Binary file not shown.
+7
View File
@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".BountyBoardApp" android:name=".BountyBoardApp"
@@ -24,5 +26,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".data.push.LiveSocketService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>
@@ -85,6 +85,11 @@ class AppContainer(
fun cancelPush() = com.anypreta.bountyboard.data.push.PushScheduler.cancel(appContext) fun cancelPush() = com.anypreta.bountyboard.data.push.PushScheduler.cancel(appContext)
/** Start/stop the foreground service that keeps the socket alive when the app is closed. */
fun startLiveService() = com.anypreta.bountyboard.data.push.LiveSocketService.start(appContext)
fun stopLiveService() = com.anypreta.bountyboard.data.push.LiveSocketService.stop(appContext)
suspend fun clearPushState() = com.anypreta.bountyboard.data.push.PushStateStore(appContext).clear() suspend fun clearPushState() = com.anypreta.bountyboard.data.push.PushStateStore(appContext).clear()
fun setBaseUrl(url: String) { fun setBaseUrl(url: String) {
@@ -0,0 +1,102 @@
package com.anypreta.bountyboard.data.push
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.anypreta.bountyboard.BountyBoardApp
import com.anypreta.bountyboard.ui.util.htmlToPlain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* A started foreground service that keeps the live-update WebSocket connected even
* after the app is swiped away, and raises system notifications for incoming alerts
* and messages. This is what makes notifications work while the app is "closed"
* (it does NOT survive an explicit Force-Stop or a reboot-until-reopened).
*/
class LiveSocketService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var started = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startInForeground()
if (!started) {
started = true
val container = (application as BountyBoardApp).container
container.live.connect()
scope.launch {
container.live.notifications.collect { n ->
if (!container.appForeground) {
PushNotifier.notifyAlert(
applicationContext,
id = ("a" + n.id).hashCode(),
title = n.title.ifBlank { "New alert" },
body = htmlToPlain(n.body).ifBlank { "You have a new alert." },
route = PushNotifier.routeForServerLink(n.link),
)
}
}
}
scope.launch {
container.live.chatMessages.collect { m ->
val mine = m.senderId == container.session.user.value?.id
if (!container.appForeground && !mine) {
PushNotifier.notifyMessage(
applicationContext,
id = ("m" + m.conversationId).hashCode(),
title = "New message",
body = htmlToPlain(m.body).ifBlank { "New message" },
route = "conversation/${m.conversationId}",
)
}
}
}
}
return START_STICKY
}
private fun startInForeground() {
val notification = PushNotifier.serviceNotification(this)
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
ServiceCompat.startForeground(this, PushNotifier.SERVICE_NOTIF_ID, notification, type)
}
// Keep running when the task is swiped away — that's the whole point.
override fun onTaskRemoved(rootIntent: Intent?) {
// no-op: stay alive
}
override fun onDestroy() {
scope.cancel()
started = false
// The socket itself is owned by the process/UI; leave it for MainShell/logout.
super.onDestroy()
}
companion object {
fun start(context: Context) {
val intent = Intent(context, LiveSocketService::class.java)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, LiveSocketService::class.java))
}
}
}
@@ -18,6 +18,8 @@ object PushNotifier {
const val CHANNEL_ALERTS = "alerts" const val CHANNEL_ALERTS = "alerts"
const val CHANNEL_MESSAGES = "messages" const val CHANNEL_MESSAGES = "messages"
const val CHANNEL_SERVICE = "live"
const val SERVICE_NOTIF_ID = 1001
const val EXTRA_ROUTE = "bb_route" const val EXTRA_ROUTE = "bb_route"
fun ensureChannels(context: Context) { fun ensureChannels(context: Context) {
@@ -32,8 +34,25 @@ object PushNotifier {
description = "New direct, group and project messages" description = "New direct, group and project messages"
}, },
) )
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_SERVICE, "Live updates", NotificationManager.IMPORTANCE_LOW).apply {
description = "Keeps a connection open so alerts and messages arrive instantly"
setShowBadge(false)
},
)
} }
/** The ongoing notification shown while the live-updates foreground service runs. */
fun serviceNotification(context: Context): android.app.Notification =
NotificationCompat.Builder(context, CHANNEL_SERVICE)
.setSmallIcon(R.drawable.ic_stat_diamond)
.setContentTitle("Bounty Board")
.setContentText("Listening for alerts and messages")
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(deepLinkIntent(context, null, 0))
.build()
private fun deepLinkIntent(context: Context, route: String?, requestCode: Int): PendingIntent { private fun deepLinkIntent(context: Context, route: String?, requestCode: Int): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
@@ -115,49 +115,27 @@ fun MainShell() {
val sections = navSectionsFor(user) val sections = navSectionsFor(user)
val p = BB.colors val p = BB.colors
// Background push: schedule/cancel the poll as the toggle changes. // Background delivery: the 15-min poll + the live-updates foreground service,
// toggled together by the push preference.
val pushEnabled by container.settings.pushEnabled.collectAsStateWithLifecycle(initialValue = true) val pushEnabled by container.settings.pushEnabled.collectAsStateWithLifecycle(initialValue = true)
LaunchedEffect(pushEnabled) { LaunchedEffect(pushEnabled) {
if (pushEnabled) container.schedulePush() else container.cancelPush() if (pushEnabled) {
container.schedulePush()
container.startLiveService()
} else {
container.cancelPush()
container.stopLiveService()
}
} }
// Live updates: keep the WebSocket connected for the whole authenticated session. // Keep the WebSocket connected for the whole authenticated session; the service
val context = LocalContext.current // raises system notifications when backgrounded — here we just keep the bell fresh.
DisposableEffect(Unit) { DisposableEffect(Unit) {
container.live.connect() container.live.connect()
onDispose { /* kept alive across navigation; closed on logout */ } onDispose { /* kept alive across navigation; closed on logout */ }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Live notifications → bump the bell, and (when backgrounded) raise a system alert. container.live.notifications.collect { shellVm.refreshUnread() }
launch {
container.live.notifications.collect { n ->
shellVm.refreshUnread()
if (!container.appForeground) {
PushNotifier.notifyAlert(
context,
id = ("a" + n.id).hashCode(),
title = n.title.ifBlank { "New alert" },
body = com.anypreta.bountyboard.ui.util.htmlToPlain(n.body).ifBlank { "You have a new alert." },
route = PushNotifier.routeForServerLink(n.link),
)
}
}
}
// Live chat → when backgrounded and it isn't our own message, raise a system alert.
launch {
container.live.chatMessages.collect { m ->
val mine = m.senderId == container.session.user.value?.id
if (!container.appForeground && !mine) {
PushNotifier.notifyMessage(
context,
id = ("m" + m.conversationId).hashCode(),
title = "New message",
body = com.anypreta.bountyboard.ui.util.htmlToPlain(m.body).ifBlank { "New message" },
route = "conversation/${m.conversationId}",
)
}
}
}
} }
// Deep link from a tapped system notification. // Deep link from a tapped system notification.
@@ -48,6 +48,7 @@ class ShellViewModel(private val container: AppContainer) : ViewModel() {
container.repo.action { logout() } container.repo.action { logout() }
} catch (_: Exception) { } catch (_: Exception) {
} }
container.stopLiveService()
container.live.disconnect() container.live.disconnect()
container.cancelPush() container.cancelPush()
container.clearPushState() container.clearPushState()
@@ -96,7 +96,13 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() {
pushEnabled = enabled pushEnabled = enabled
viewModelScope.launch { viewModelScope.launch {
container.settings.setPushEnabled(enabled) container.settings.setPushEnabled(enabled)
if (enabled) container.schedulePush() else container.cancelPush() if (enabled) {
container.schedulePush()
container.startLiveService()
} else {
container.cancelPush()
container.stopLiveService()
}
} }
} }