mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 09:25:19 +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:
@@ -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]"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user