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:
callebtc
2026-01-13 03:23:53 +07:00
committed by GitHub
parent 654d385b6d
commit d164b3f9bc
7 changed files with 229 additions and 13 deletions
@@ -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]"
}