Merge pull request #861 from permissionlesstech/codex/delay-peer-availability-notification

Delay nearby peer notification to collect accurate count
This commit is contained in:
callebtc 2026-08-03 00:18:52 +02:00 committed by GitHub
commit 094657efa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 141 additions and 26 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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"