mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:25:19 +00:00
* 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:
co-authored by
Claude Opus 4.8
callebtc
parent
ff15bddfc8
commit
13c771aa29
@@ -42,6 +42,33 @@ data class NostrFilter(
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,13 +64,19 @@ class GeohashViewModel(
|
||||
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 geoTimer: Job? = null
|
||||
private var globalPresenceJob: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
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 geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
@@ -163,8 +169,10 @@ class GeohashViewModel(
|
||||
GeohashAliasRegistry.clear()
|
||||
GeohashConversationRegistry.clear()
|
||||
subscriptionManager.disconnect()
|
||||
currentGeohashSubId = null
|
||||
currentGeohashMsgSubId = null
|
||||
currentGeohashPresenceSubId = null
|
||||
currentDmSubId = null
|
||||
activeChannelGeohash = null
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
globalPresenceJob?.cancel()
|
||||
@@ -342,12 +350,14 @@ class GeohashViewModel(
|
||||
|
||||
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
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 }
|
||||
|
||||
when (channel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
Log.d(TAG, "📡 Switched to mesh channel")
|
||||
activeChannelGeohash = null
|
||||
repo.setCurrentGeohash(null)
|
||||
notificationManager.setCurrentGeohash(null)
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
@@ -355,6 +365,7 @@ class GeohashViewModel(
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
|
||||
activeChannelGeohash = channel.channel.geohash
|
||||
repo.setCurrentGeohash(channel.channel.geohash)
|
||||
notificationManager.setCurrentGeohash(channel.channel.geohash)
|
||||
notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
|
||||
@@ -368,28 +379,18 @@ class GeohashViewModel(
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
|
||||
startGeoParticipantsTimer()
|
||||
|
||||
viewModelScope.launch {
|
||||
val geohash = channel.channel.geohash
|
||||
val subId = "geohash-$geohash"; currentGeohashSubId = subId
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||
limit = 200,
|
||||
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)
|
||||
|
||||
// Chat message stream (kind 20000) is low-volume; keep it alive even when
|
||||
// backgrounded so geohash messages still arrive.
|
||||
subscribeChannelMessages(channel.channel.geohash)
|
||||
// Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
|
||||
// run it in the foreground. It is restored in onStart() and torn down in onStop().
|
||||
if (isAppInForeground()) {
|
||||
subscribeChannelPresence(channel.channel.geohash)
|
||||
}
|
||||
// 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 -> {
|
||||
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() {
|
||||
geoTimer = viewModelScope.launch {
|
||||
while (repo.getCurrentGeohash() != null) {
|
||||
@@ -416,17 +467,41 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
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) }
|
||||
// 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) {
|
||||
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") }
|
||||
// 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) {
|
||||
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,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
|
||||
Reference in New Issue
Block a user