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() + } } }