Debounce relay stat counter updates to avoid notification rate limit

AmberRelayStats.addSent/addFailed called notify(id=2) synchronously for
every relay event, driven by NostrClientLoggerListener.onSent. Bursts of
relay traffic (7-34 notifies/sec vs Android's ~5/sec per-app limit) made
NotificationManagerService shed the updates, freezing the status
notification on stale text, and wasted a full BigTextStyle build plus
Binder IPC per dropped event.

Route the counter path through a Unit event flow
(MutableSharedFlow, capacity 1, DROP_OLDEST) collected with
debounce(300): a burst collapses into one notification update and the
last tick always renders. No numeric counter, so nothing overflows or
wraps no matter how long the process runs; tryEmit never suspends or
fails on Quartz IO threads. Connection-state updates keep their
existing debounce so connect/disconnect still shows promptly.

Fixes ngit issue nevent1qy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqg80gw6ushd8jyj6rt8qseup7hngj0xhqa2ms3dvam60lhc760zvkyq6uts9
This commit is contained in:
greenart7c3
2026-08-18 08:21:23 -03:00
parent 6151a0247b
commit 1ab593b827
@@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.onEach
@@ -39,6 +41,23 @@ class AmberRelayStats(
var available = emptySet<NormalizedRelayUrl>()
var connected = emptySet<NormalizedRelayUrl>()
// Counter updates arrive per relay event (NostrClientLoggerListener.onSent),
// which can burst at 30+ events/sec and trip Android's per-app notification
// rate limit (~5/sec): NotificationManager sheds every update and the status
// notification freezes on stale text until the burst ends. Emitting a tick
// defers notify() to the debounced collector started in
// createNotificationChannel(), so a burst collapses into one notification
// update. Connection-state changes keep their own (also debounced)
// collectors so connect/disconnect still shows promptly.
// Unit events instead of a numeric counter: nothing to overflow or wrap no
// matter how long the app runs. DROP_OLDEST + capacity 1 keeps only the
// newest tick, so tryEmit never fails and the last event of a burst always
// renders.
private val counterTick = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@OptIn(FlowPreview::class)
@SuppressLint("MissingPermission")
val relayStatus = combine(client.availableRelaysFlow(), client.connectedRelaysFlow()) { available, connected ->
@@ -107,6 +126,14 @@ class AmberRelayStats(
updateNotification()
}
}
Amber.instance.applicationIOScope.launch {
// Trailing-edge debounce for the counter path (addSent/addFailed):
// the last tick of a burst always renders, at most one notify per
// 300ms window.
counterTick.debounce(300).collect {
updateNotification()
}
}
}
private val innerCache = mutableMapOf<NormalizedRelayUrl, AmberRelayStat>()
@@ -253,12 +280,16 @@ class AmberRelayStats(
fun addSent(url: NormalizedRelayUrl) {
get(url).addSent()
updateNotification()
scheduleNotificationUpdate()
}
fun addFailed(url: NormalizedRelayUrl) {
get(url).addFailed()
updateNotification()
scheduleNotificationUpdate()
}
private fun scheduleNotificationUpdate() {
counterTick.tryEmit(Unit)
}
}