From d164b3f9bc9b9bf6ccd6c42e6e69f824723c3153 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 13 Jan 2026 03:23:53 +0700 Subject: [PATCH] Implement Geohash Presence (Heartbeats) (#576) * geohash announce * only for some geohashes * global presence right away * jitter delays * show ? people for high-precision geohashes * 1000 events --- .../android/nostr/GeohashMessageHandler.kt | 22 +++-- .../com/bitchat/android/nostr/NostrEvent.kt | 1 + .../com/bitchat/android/nostr/NostrFilter.kt | 6 +- .../bitchat/android/nostr/NostrProtocol.kt | 22 +++++ .../bitchat/android/ui/GeohashViewModel.kt | 74 +++++++++++++- .../android/ui/LocationChannelsSheet.kt | 21 +++- docs/GeohashPresenceSpec.md | 96 +++++++++++++++++++ 7 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 docs/GeohashPresenceSpec.md diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 0e6e6634..ec3af4e6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -46,15 +46,17 @@ class GeohashMessageHandler( fun onEvent(event: NostrEvent, subscribedGeohash: String) { scope.launch(Dispatchers.Default) { try { - if (event.kind != 20000) return@launch + if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch if (dedupe(event.id)) return@launch - // PoW validation (if enabled) - val pow = PoWPreferenceManager.getCurrentSettings() - if (pow.enabled && pow.difficulty > 0) { - if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch + // PoW validation (if enabled) - apply to chat messages primarily + if (event.kind == NostrKind.EPHEMERAL_EVENT) { + val pow = PoWPreferenceManager.getCurrentSettings() + if (pow.enabled && pow.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch + } } // Blocked users check (use injected DataManager which has loaded state) @@ -62,7 +64,12 @@ class GeohashMessageHandler( // Update repository (participants, nickname, teleport) // Update repository on a background-safe path; repository will post updates to LiveData - repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L)) + + // Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events + if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) { + repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L)) + } + event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) } event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) } // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr @@ -70,6 +77,9 @@ class GeohashMessageHandler( com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey) } catch (_: Exception) { } + // Stop here for presence events - they don't produce chat messages + if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch + // Skip our own events for message emission val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) if (my.publicKeyHex.equals(event.pubkey, true)) return@launch diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 785b41f3..92752b17 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -215,6 +215,7 @@ object NostrKind { const val SEAL = 13 // NIP-17 sealed event const val GIFT_WRAP = 1059 // NIP-17 gift wrap const val EPHEMERAL_EVENT = 20000 // For geohash channels + const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index b6313ea7..247162a0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -32,11 +32,11 @@ data class NostrFilter( } /** - * Create filter for geohash-scoped ephemeral events (kind 20000) + * Create filter for geohash-scoped ephemeral events (kind 20000 and 20001) */ - fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter { return NostrFilter( - kinds = listOf(NostrKind.EPHEMERAL_EVENT), + kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE), since = since?.let { (it / 1000).toInt() }, tagFilters = mapOf("g" to listOf(geohash)), limit = limit diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 0b94bf78..1e0b51f6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -117,6 +117,28 @@ object NostrProtocol { return@withContext senderIdentity.signEvent(event) } + + /** + * Create a geohash-scoped presence event (kind 20001) + * Has no content and no nickname, used for participant counting + */ + suspend fun createGeohashPresenceEvent( + geohash: String, + senderIdentity: NostrIdentity + ): NostrEvent = withContext(Dispatchers.Default) { + val tags = mutableListOf>() + tags.add(listOf("g", geohash)) + + val event = NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.GEOHASH_PRESENCE, + tags = tags, + content = "" + ) + + return@withContext senderIdentity.signEvent(event) + } /** * Create a geohash-scoped ephemeral public message (kind 20000) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 4d66e306..a25fb37b 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -19,6 +19,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Date +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.Dispatchers class GeohashViewModel( application: Application, @@ -55,6 +58,7 @@ class GeohashViewModel( private var currentGeohashSubId: 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 val geohashPeople: StateFlow> = state.geohashPeople @@ -86,6 +90,10 @@ class GeohashViewModel( state.setIsTeleported(teleported) } } + + // Start global presence heartbeat loop + startGlobalPresenceHeartbeat() + } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) @@ -93,6 +101,49 @@ class GeohashViewModel( } } + private fun startGlobalPresenceHeartbeat() { + globalPresenceJob?.cancel() + globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + // Reactively restart heartbeat whenever available channels change + locationChannelManager?.availableChannels?.collectLatest { channels -> + // Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5 + val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash } + + if (targetGeohashes.isNotEmpty()) { + // Enter heartbeat loop for this set of channels + // If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately + while (true) { + // Randomize loop interval (40-80s, average 60s) + val loopInterval = kotlin.random.Random.nextLong(40000L, 80000L) + var timeSpent = 0L + + try { + Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels") + targetGeohashes.forEach { geohash -> + // Decorrelate individual broadcasts with random delay (1s-5s) + val stepDelay = kotlin.random.Random.nextLong(1000L, 10000L) + delay(stepDelay) + timeSpent += stepDelay + + broadcastPresence(geohash) + } + } catch (e: Exception) { + Log.w(TAG, "Global presence heartbeat error: ${e.message}") + } + + // Wait remaining time to satisfy target average cadence + val remaining = loopInterval - timeSpent + if (remaining > 0) { + delay(remaining) + } else { + delay(10000L) // Minimum guard delay + } + } + } + } + } + } + fun panicReset() { repo.clearAll() GeohashAliasRegistry.clear() @@ -102,10 +153,25 @@ class GeohashViewModel( currentDmSubId = null geoTimer?.cancel() geoTimer = null + globalPresenceJob?.cancel() + globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} initialize() } + private suspend fun broadcastPresence(geohash: String) { + try { + val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) + val relayManager = NostrRelayManager.getInstance(getApplication()) + // Presence is lightweight, send to geohash relays + relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5) + Log.v(TAG, "💓 Sent presence heartbeat for $geohash") + } catch (e: Exception) { + Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") + } + } + fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { viewModelScope.launch { try { @@ -147,6 +213,8 @@ class GeohashViewModel( fun beginGeohashSampling(geohashes: List) { if (geohashes.isEmpty()) return Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") + + // Subscribe to events viewModelScope.launch { geohashes.forEach { geohash -> subscriptionManager.subscribeGeohash( @@ -160,7 +228,9 @@ class GeohashViewModel( } } - fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") } + fun endGeohashSampling() { + Log.d(TAG, "🌍 Ending geohash sampling") + } fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash) fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex) @@ -238,7 +308,7 @@ class GeohashViewModel( try { val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) - repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) + // We don't update participant here anymore; presence loop handles it via Kind 20001 val teleported = state.isTeleported.value if (teleported) repo.markTeleported(identity.publicKeyHex) } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 495683bd..d3835a29 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -706,7 +706,16 @@ private fun meshCount(viewModel: ChatViewModel): Int { @Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + + // For high precision channels (Neighborhood, Block) where we don't broadcast presence, + // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication. + val isHighPrecision = channel.level.precision > 5 + val peopleText = if (isHighPrecision && participantCount == 0) { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + } else { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + } + val levelName = when (channel.level) { com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) @@ -721,7 +730,15 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int @Composable private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + val level = levelForLength(geohash.length) + val isHighPrecision = level.precision > 5 + + val peopleText = if (isHighPrecision && participantCount == 0) { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + } else { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + } + return "#$geohash [$peopleText]" } diff --git a/docs/GeohashPresenceSpec.md b/docs/GeohashPresenceSpec.md new file mode 100644 index 00000000..321a491f --- /dev/null +++ b/docs/GeohashPresenceSpec.md @@ -0,0 +1,96 @@ +# Geohash Presence Specification + +## Overview + +The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts. + +## Nostr Protocol + +### Event Kind +A new ephemeral event kind is defined for presence heartbeats: +- **Kind:** `20001` (`GEOHASH_PRESENCE`) +- **Type:** Ephemeral (not stored by relays long-term) + +### Event Structure +The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness". + +```json +{ + "kind": 20001, + "created_at": , + "tags": [ + ["g", ""] + ], + "content": "", + "pubkey": "", + "id": "", + "sig": "" +} +``` + +* **`content`**: Must be empty string. +* **`tags`**: Must include `["g", ""]`. Should NOT include `["n", ""]`. +* **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages). + +## Client Behavior + +### 1. Broadcasting Presence + +Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing. + +* **Global Heartbeat:** + * **Trigger:** Application start / initialization, or whenever location (available geohashes) changes. + * **Frequency:** Randomized loop interval between **40s and 80s** (average 60s). + * **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*. + * **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically: + * **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5). + * **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+). + * **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence. + +### 2. Subscribing to Presence + +Clients must update their Nostr filters to listen for both chat and presence events on geohash channels. + +* **Filter:** + * `kinds`: `[20000, 20001]` + * `#g`: `[""]` + +### 3. Participant Counting + +The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages. + +* **Logic:** + * Maintain a map of `pubkey -> last_seen_timestamp` for each geohash. + * Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event. + * A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**. + +### 4. UI Presentation + +The presentation of the participant count depends on the geohash precision level and data availability. + +* **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`. +* **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where: + * Presence broadcasting is disabled (privacy restriction). + * **AND** the detected participant count is `0`. + * **Display:** `[? people]` + * **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking). + +### 5. Implementation Details (Android Reference) + +* **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`. +* **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event. +* **`GeohashViewModel`**: + * `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`. + * Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s). + * Filters channels by `precision <= 5` before broadcasting. +* **`GeohashMessageHandler`**: + * Refactored `onEvent` to update participant counts for both Kind 20000 and 20001. +* **`LocationChannelsSheet`**: + * Implements the `[? people]` display logic for high-precision, zero-count channels. + +## Benefits + +* **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages). +* **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays. +* **Consistency:** "Online" status is maintained globally while the app is open. +* **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting.