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:
Binary file not shown.
@@ -4,6 +4,8 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<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
|
||||
android:name=".BountyBoardApp"
|
||||
@@ -24,5 +26,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".data.push.LiveSocketService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -85,6 +85,11 @@ class AppContainer(
|
||||
|
||||
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()
|
||||
|
||||
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_MESSAGES = "messages"
|
||||
const val CHANNEL_SERVICE = "live"
|
||||
const val SERVICE_NOTIF_ID = 1001
|
||||
const val EXTRA_ROUTE = "bb_route"
|
||||
|
||||
fun ensureChannels(context: Context) {
|
||||
@@ -32,8 +34,25 @@ object PushNotifier {
|
||||
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 {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
@@ -115,49 +115,27 @@ fun MainShell() {
|
||||
val sections = navSectionsFor(user)
|
||||
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)
|
||||
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.
|
||||
val context = LocalContext.current
|
||||
// Keep the WebSocket connected for the whole authenticated session; the service
|
||||
// raises system notifications when backgrounded — here we just keep the bell fresh.
|
||||
DisposableEffect(Unit) {
|
||||
container.live.connect()
|
||||
onDispose { /* kept alive across navigation; closed on logout */ }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
// Live notifications → bump the bell, and (when backgrounded) raise a system alert.
|
||||
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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
container.live.notifications.collect { shellVm.refreshUnread() }
|
||||
}
|
||||
|
||||
// Deep link from a tapped system notification.
|
||||
|
||||
@@ -48,6 +48,7 @@ class ShellViewModel(private val container: AppContainer) : ViewModel() {
|
||||
container.repo.action { logout() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
container.stopLiveService()
|
||||
container.live.disconnect()
|
||||
container.cancelPush()
|
||||
container.clearPushState()
|
||||
|
||||
@@ -96,7 +96,13 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() {
|
||||
pushEnabled = enabled
|
||||
viewModelScope.launch {
|
||||
container.settings.setPushEnabled(enabled)
|
||||
if (enabled) container.schedulePush() else container.cancelPush()
|
||||
if (enabled) {
|
||||
container.schedulePush()
|
||||
container.startLiveService()
|
||||
} else {
|
||||
container.cancelPush()
|
||||
container.stopLiveService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user