Pause Nostr geohash firehose in background to cut mobile data Closes: #700 (#706)

* Pause Nostr geohash firehose in background to cut mobile data

The Bluetooth mesh uses no mobile data, but the Nostr relay layer ran
unbounded in the background: the live geohash channel subscription kept
streaming across 5 relays, the presence heartbeat broadcast every ~60s,
and a participant-refresh timer polled every 30s. Combined with
reconnect/re-subscribe replay on public relays, this could burn gigabytes
while idle.

GeohashViewModel now tears down the high-volume subscriptions when the app
is backgrounded (onStop) and restores them on foreground (onStart):
- drop the live channel message stream (currentGeohashSubId)
- drop sampling subscriptions
- cancel the presence heartbeat and participant-refresh timer

Gift-wrap DM subscriptions are intentionally kept alive in the background
since they are filtered to our pubkey (lightweight) so DMs still arrive.

The selected channel is tracked in activeChannelGeohash so the stream can
be rebuilt on foreground without losing the user's selection. The channel
subscription logic is extracted into subscribeChannelStream() /
subscribeChannelDM() helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* separate the heartbeat firehose from message delivery

* correct comment

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
David Carrington
2026-06-25 21:56:29 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 callebtc
parent ff15bddfc8
commit 13c771aa29
3 changed files with 140 additions and 29 deletions
@@ -43,6 +43,33 @@ data class NostrFilter(
) )
} }
/**
* Create filter for geohash-scoped chat messages only (kind 20000).
* Low-volume; kept subscribed in the background so messages keep arriving.
*/
fun geohashMessages(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("g" to listOf(geohash)),
limit = limit
)
}
/**
* Create filter for geohash-scoped presence heartbeats only (kind 20001).
* High-volume firehose (every participant rebroadcasts ~every 60s); only used
* to refresh the participant list, so it is paused while backgrounded.
*/
fun geohashPresence(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.GEOHASH_PRESENCE),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("g" to listOf(geohash)),
limit = limit
)
}
/** /**
* Create filter for text notes from specific authors * Create filter for text notes from specific authors
*/ */
@@ -27,9 +27,18 @@ class NostrSubscriptionManager(
} }
} }
fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
scope.launch { scope.launch {
val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit) val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
}
}
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
scope.launch {
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
} }
} }
@@ -64,13 +64,19 @@ class GeohashViewModel(
dataManager = dataManager dataManager = dataManager
) )
private var currentGeohashSubId: String? = null // Live channel message stream (kind 20000). Low-volume; kept alive in the background.
private var currentGeohashMsgSubId: String? = null
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
private var currentGeohashPresenceSubId: String? = null
private var currentDmSubId: String? = null private var currentDmSubId: String? = null
private var geoTimer: Job? = null private var geoTimer: Job? = null
private var globalPresenceJob: Job? = null private var globalPresenceJob: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf<String>() private val activeSamplingGeohashes = mutableSetOf<String>()
// Geohash of the currently selected Location channel (null for Mesh/none).
private var activeChannelGeohash: String? = null
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
@@ -163,8 +169,10 @@ class GeohashViewModel(
GeohashAliasRegistry.clear() GeohashAliasRegistry.clear()
GeohashConversationRegistry.clear() GeohashConversationRegistry.clear()
subscriptionManager.disconnect() subscriptionManager.disconnect()
currentGeohashSubId = null currentGeohashMsgSubId = null
currentGeohashPresenceSubId = null
currentDmSubId = null currentDmSubId = null
activeChannelGeohash = null
geoTimer?.cancel() geoTimer?.cancel()
geoTimer = null geoTimer = null
globalPresenceJob?.cancel() globalPresenceJob?.cancel()
@@ -342,12 +350,14 @@ class GeohashViewModel(
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
geoTimer?.cancel(); geoTimer = null geoTimer?.cancel(); geoTimer = null
currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null } currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null }
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
when (channel) { when (channel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> { is com.bitchat.android.geohash.ChannelID.Mesh -> {
Log.d(TAG, "📡 Switched to mesh channel") Log.d(TAG, "📡 Switched to mesh channel")
activeChannelGeohash = null
repo.setCurrentGeohash(null) repo.setCurrentGeohash(null)
notificationManager.setCurrentGeohash(null) notificationManager.setCurrentGeohash(null)
notificationManager.clearMeshMentionNotifications() notificationManager.clearMeshMentionNotifications()
@@ -355,6 +365,7 @@ class GeohashViewModel(
} }
is com.bitchat.android.geohash.ChannelID.Location -> { is com.bitchat.android.geohash.ChannelID.Location -> {
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
activeChannelGeohash = channel.channel.geohash
repo.setCurrentGeohash(channel.channel.geohash) repo.setCurrentGeohash(channel.channel.geohash)
notificationManager.setCurrentGeohash(channel.channel.geohash) notificationManager.setCurrentGeohash(channel.channel.geohash)
notificationManager.clearNotificationsForGeohash(channel.channel.geohash) notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
@@ -369,27 +380,17 @@ class GeohashViewModel(
startGeoParticipantsTimer() startGeoParticipantsTimer()
viewModelScope.launch { // Chat message stream (kind 20000) is low-volume; keep it alive even when
val geohash = channel.channel.geohash // backgrounded so geohash messages still arrive.
val subId = "geohash-$geohash"; currentGeohashSubId = subId subscribeChannelMessages(channel.channel.geohash)
subscriptionManager.subscribeGeohash( // Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
geohash = geohash, // run it in the foreground. It is restored in onStart() and torn down in onStop().
sinceMs = System.currentTimeMillis() - 3600000L, if (isAppInForeground()) {
limit = 200, subscribeChannelPresence(channel.channel.geohash)
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
} }
// Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is
// kept alive in the background so geohash DMs still arrive.
subscribeChannelDM(channel.channel.geohash)
} }
null -> { null -> {
Log.d(TAG, "📡 No channel selected") Log.d(TAG, "📡 No channel selected")
@@ -399,6 +400,56 @@ class GeohashViewModel(
} }
} }
/**
* Subscribe to the chat message stream (kind 20000) for a geohash channel.
* Low-volume; kept alive in the background so messages keep arriving.
*/
private fun subscribeChannelMessages(geohash: String) {
val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId
subscriptionManager.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
/**
* Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel.
* High-volume; only used to refresh the participant list, so it is torn down in
* onStop() and restored in onStart() to cut background mobile data.
*/
private fun subscribeChannelPresence(geohash: String) {
val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
/**
* Subscribe to gift-wrap DMs for a geohash channel's derived identity.
* Lightweight (filtered to our pubkey); kept alive in the background.
*/
private fun subscribeChannelDM(geohash: String) {
viewModelScope.launch {
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
}
private fun startGeoParticipantsTimer() { private fun startGeoParticipantsTimer() {
geoTimer = viewModelScope.launch { geoTimer = viewModelScope.launch {
while (repo.getCurrentGeohash() != null) { while (repo.getCurrentGeohash() != null) {
@@ -416,17 +467,41 @@ class GeohashViewModel(
} }
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes") Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
// Restore the presence heartbeat firehose for the selected geohash channel.
// (The chat message stream is kept alive in the background, so it is not restored here.)
activeChannelGeohash?.let { subscribeChannelPresence(it) }
// Resume geohash sampling subscriptions
activeSamplingGeohashes.forEach { performSubscribeSampling(it) } activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
// Resume the participant-refresh polling timer if a geohash is selected
if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) {
startGeoParticipantsTimer()
}
// Resume the global presence heartbeat
if (globalPresenceJob?.isActive != true) {
startGlobalPresenceHeartbeat()
}
} }
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes") Log.d(TAG, "🌍 App backgrounded: pausing geohash presence firehose (keeping message + DM subscriptions)")
// Drop the high-volume presence heartbeat firehose (kind 20001).
// The chat message stream (kind 20000) is intentionally left active so messages still arrive.
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
// Drop geohash sampling subscriptions
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
// Stop broadcasting presence heartbeats
globalPresenceJob?.cancel(); globalPresenceJob = null
// Stop participant-refresh polling
geoTimer?.cancel(); geoTimer = null
// NOTE: gift-wrap DM subscriptions (per-geohash + global "chat-messages") are intentionally
// left active so direct messages still arrive while backgrounded.
} }
private fun performSubscribeSampling(geohash: String) { private fun performSubscribeSampling(geohash: String) {
subscriptionManager.subscribeGeohash( // Sampling only needs participant counts, never message bodies, so it subscribes to
// presence heartbeats only (kind 20001) to keep the payload small.
subscriptionManager.subscribeGeohashPresence(
geohash = geohash, geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L, sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200, limit = 200,