diff --git a/BountyBoard-debug.apk b/BountyBoard-debug.apk
index 491bc67..2b56ed9 100644
Binary files a/BountyBoard-debug.apk and b/BountyBoard-debug.apk differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9809cdb..e7be4a6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,8 @@
+
+
+
+
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt b/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt
index 88346e0..3aecb60 100644
--- a/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt
+++ b/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt
@@ -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) {
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/push/LiveSocketService.kt b/app/src/main/java/com/anypreta/bountyboard/data/push/LiveSocketService.kt
new file mode 100644
index 0000000..618f6f1
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/push/LiveSocketService.kt
@@ -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))
+ }
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt b/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt
index bf97ae1..34a7680 100644
--- a/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt
+++ b/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt
@@ -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
diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt b/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt
index 3707d98..d2b972d 100644
--- a/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt
+++ b/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt
@@ -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.
diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt b/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt
index b2ce695..5a1c67c 100644
--- a/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt
+++ b/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt
@@ -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()
diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/profile/ProfileScreen.kt b/app/src/main/java/com/anypreta/bountyboard/ui/profile/ProfileScreen.kt
index eb78fe7..6668049 100644
--- a/app/src/main/java/com/anypreta/bountyboard/ui/profile/ProfileScreen.kt
+++ b/app/src/main/java/com/anypreta/bountyboard/ui/profile/ProfileScreen.kt
@@ -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()
+ }
}
}