mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<String>>()
|
||||
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)
|
||||
|
||||
@@ -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<List<GeoPerson>> = 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<String>) {
|
||||
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}") }
|
||||
|
||||
@@ -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]"
|
||||
}
|
||||
|
||||
|
||||
@@ -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": <timestamp>,
|
||||
"tags": [
|
||||
["g", "<geohash>"]
|
||||
],
|
||||
"content": "",
|
||||
"pubkey": "<geohash_derived_pubkey>",
|
||||
"id": "<event_id>",
|
||||
"sig": "<signature>"
|
||||
}
|
||||
```
|
||||
|
||||
* **`content`**: Must be empty string.
|
||||
* **`tags`**: Must include `["g", "<geohash>"]`. Should NOT include `["n", "<nickname>"]`.
|
||||
* **`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`: `["<geohash>"]`
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user