From 1376b55931db1352c3ad0eacf8c0b9b0d3d2a607 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:54:03 +0200 Subject: [PATCH] Delay nearby peer availability notification --- .../android/service/MeshForegroundService.kt | 11 +- .../service/PeerAvailabilityNotifier.kt | 33 ++++- .../service/PeerAvailabilityNotifierTest.kt | 123 ++++++++++++++---- 3 files changed, 141 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 3af70da3..42feca39 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -101,7 +101,16 @@ class MeshForegroundService : Service() { override fun onCreate() { super.onCreate() notificationManager = NotificationManagerCompat.from(this) - peerAvailabilityNotifier = PeerAvailabilityNotifier(applicationContext) + peerAvailabilityNotifier = PeerAvailabilityNotifier( + context = applicationContext, + scope = scope, + isAppCurrentlyInBackground = { + !ProcessLifecycleOwner.get() + .lifecycle + .currentState + .isAtLeast(Lifecycle.State.STARTED) + } + ) createChannel() // Ensure mesh service exists in holder (create if needed) diff --git a/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt b/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt index 8639ad22..714d0708 100644 --- a/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt +++ b/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt @@ -16,6 +16,10 @@ import androidx.core.content.ContextCompat import androidx.core.content.edit import com.bitchat.android.MainActivity import com.bitchat.android.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch internal enum class PeerAvailabilityAction { NONE, @@ -151,9 +155,12 @@ private class AndroidPeerAvailabilityTextProvider( /** * Owns the user-visible "bitchatters nearby" notification independently of the UI delegate. + * The first eligible peer starts a fixed aggregation window; arrivals within that window update + * the count without postponing the alert indefinitely. */ internal class PeerAvailabilityNotifier( private val context: Context, + private val scope: CoroutineScope, private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context), private val tracker: PeerAvailabilityTracker = PeerAvailabilityTracker( @@ -161,6 +168,8 @@ internal class PeerAvailabilityNotifier( ), private val textProvider: PeerAvailabilityTextProvider = AndroidPeerAvailabilityTextProvider(context), + private val isAppCurrentlyInBackground: () -> Boolean, + private val aggregationWindowMs: Long = AGGREGATION_WINDOW_MS, private val canPostNotifications: () -> Boolean = { notificationManager.areNotificationsEnabled() && ( @@ -175,25 +184,47 @@ internal class PeerAvailabilityNotifier( companion object { internal const val CHANNEL_ID = "bitchat_peer_availability_notifications" internal const val NOTIFICATION_ID = 997 + internal const val AGGREGATION_WINDOW_MS = 10_000L private const val TAG = "PeerAvailability" } + private var latestPeerCount = 0 + private var pendingNotificationJob: Job? = null + init { + require(aggregationWindowMs >= 0) { "aggregationWindowMs must not be negative" } createNotificationChannel() } fun onPeerCountChanged(peerCount: Int, isAppInBackground: Boolean) { + latestPeerCount = peerCount when (tracker.update(peerCount, isAppInBackground)) { PeerAvailabilityAction.NONE -> Unit PeerAvailabilityAction.CLEAR -> clear() - PeerAvailabilityAction.SHOW -> showNotification(peerCount) + PeerAvailabilityAction.SHOW -> scheduleNotification() } } fun clear() { + pendingNotificationJob?.cancel() + pendingNotificationJob = null notificationManager.cancel(NOTIFICATION_ID) } + private fun scheduleNotification() { + if (pendingNotificationJob?.isActive == true) return + + pendingNotificationJob = scope.launch { + delay(aggregationWindowMs) + pendingNotificationJob = null + + val peerCount = latestPeerCount + if (peerCount > 0 && isAppCurrentlyInBackground()) { + showNotification(peerCount) + } + } + } + private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, diff --git a/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt b/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt index 176ffad5..f83a17e3 100644 --- a/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt @@ -4,6 +4,11 @@ import android.app.Notification import android.app.NotificationManager import android.content.Context import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -121,6 +126,7 @@ class PeerAvailabilityTrackerTest { @RunWith(RobolectricTestRunner::class) @Config(sdk = [32]) +@OptIn(ExperimentalCoroutinesApi::class) class PeerAvailabilityNotifierTest { private lateinit var context: Context private lateinit var systemNotificationManager: NotificationManager @@ -138,18 +144,25 @@ class PeerAvailabilityNotifierTest { } @Test - fun `background availability posts on dedicated channel`() { + fun `background availability waits ten seconds before posting on dedicated channel`() = runTest { val textProvider = testTextProvider() - val notifier = PeerAvailabilityNotifier( - context = context, - tracker = tracker(), - textProvider = textProvider, - canPostNotifications = { true } + val notifier = createNotifier( + scope = this, + textProvider = textProvider ) notifier.onPeerCountChanged(0, isAppInBackground = true) notifier.onPeerCountChanged(2, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS - 1) + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + + advanceTimeBy(1) + runCurrent() val notification = shadowOf(systemNotificationManager).getNotification( PeerAvailabilityNotifier.NOTIFICATION_ID @@ -171,16 +184,63 @@ class PeerAvailabilityNotifierTest { } @Test - fun `returning to zero cancels availability notification`() { - val notifier = PeerAvailabilityNotifier( - context = context, - tracker = tracker(), - textProvider = testTextProvider(), - canPostNotifications = { true } + fun `later peer arrivals are included without restarting aggregation window`() = runTest { + val textProvider = testTextProvider() + val notifier = createNotifier( + scope = this, + textProvider = textProvider ) notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(6_000L) + notifier.onPeerCountChanged(3, isAppInBackground = true) + advanceTimeBy(4_000L) + runCurrent() + + val notification = + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + assertNotNull(notification) + assertEquals( + textProvider.body(3), + notification.extras.getString("android.text") + ) + } + + @Test + fun `opening app during aggregation suppresses notification`() = runTest { + var isAppInBackground = true + val history = InMemoryAlertHistory() + val notifier = createNotifier( + scope = this, + availabilityTracker = PeerAvailabilityTracker(history), + isAppCurrentlyInBackground = { isAppInBackground } + ) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2) + isAppInBackground = false + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2) + runCurrent() + + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + assertNull(history.lastAlertAtMillis) + } + + @Test + fun `returning to zero cancels pending availability notification`() = runTest { + val notifier = createNotifier(scope = this) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2) notifier.onPeerCountChanged(0, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS) + runCurrent() assertNull( shadowOf(systemNotificationManager).getNotification( @@ -190,16 +250,13 @@ class PeerAvailabilityNotifierTest { } @Test - fun `explicit clear cancels availability notification`() { - val notifier = PeerAvailabilityNotifier( - context = context, - tracker = tracker(), - textProvider = testTextProvider(), - canPostNotifications = { true } - ) + fun `explicit clear cancels pending availability notification`() = runTest { + val notifier = createNotifier(scope = this) notifier.onPeerCountChanged(1, isAppInBackground = true) notifier.clear() + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS) + runCurrent() assertNull( shadowOf(systemNotificationManager).getNotification( @@ -209,16 +266,17 @@ class PeerAvailabilityNotifierTest { } @Test - fun `disabled notifications do not post`() { + fun `disabled notifications do not post`() = runTest { val history = InMemoryAlertHistory() - val notifier = PeerAvailabilityNotifier( - context = context, - tracker = PeerAvailabilityTracker(history), - textProvider = testTextProvider(), + val notifier = createNotifier( + scope = this, + availabilityTracker = PeerAvailabilityTracker(history), canPostNotifications = { false } ) notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS) + runCurrent() assertNull( shadowOf(systemNotificationManager).getNotification( @@ -243,6 +301,23 @@ class PeerAvailabilityNotifierTest { return PeerAvailabilityTracker(InMemoryAlertHistory()) } + private fun createNotifier( + scope: CoroutineScope, + availabilityTracker: PeerAvailabilityTracker = tracker(), + textProvider: PeerAvailabilityTextProvider = testTextProvider(), + isAppCurrentlyInBackground: () -> Boolean = { true }, + canPostNotifications: () -> Boolean = { true } + ): PeerAvailabilityNotifier { + return PeerAvailabilityNotifier( + context = context, + scope = scope, + tracker = availabilityTracker, + textProvider = textProvider, + isAppCurrentlyInBackground = isAppCurrentlyInBackground, + canPostNotifications = canPostNotifications + ) + } + private fun testTextProvider(): PeerAvailabilityTextProvider { return object : PeerAvailabilityTextProvider { override fun title(): String = "Bitchatters nearby"