package com.bitchat.android.ui import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrDirectMessageHandler import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager import com.bitchat.android.nostr.NostrSubscriptionManager import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import kotlinx.coroutines.Job 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 import java.security.SecureRandom import kotlin.random.asKotlinRandom class GeohashViewModel( application: Application, private val state: ChatState, private val messageManager: MessageManager, private val privateChatManager: PrivateChatManager, private val meshDelegateHandler: MeshDelegateHandler, private val dataManager: DataManager, private val notificationManager: NotificationManager ) : AndroidViewModel(application), DefaultLifecycleObserver { companion object { private const val TAG = "GeohashViewModel" private val secureRandom = SecureRandom().asKotlinRandom() } private val repo = GeohashRepository(application, state, dataManager) private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) private val geohashMessageHandler = GeohashMessageHandler( application = application, state = state, messageManager = messageManager, repo = repo, scope = viewModelScope, dataManager = dataManager ) private val dmHandler = NostrDirectMessageHandler( application = application, state = state, privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, scope = viewModelScope, repo = repo, dataManager = dataManager ) // 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() // Geohash of the currently selected Location channel (null for Mesh/none). private var activeChannelGeohash: String? = null val geohashPeople: StateFlow> = state.geohashPeople val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts val selectedLocationChannel: StateFlow = state.selectedLocationChannel fun initialize() { subscriptionManager.connect() // Observe process lifecycle to manage background sampling kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) if (identity != null) { // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. subscriptionManager.subscribeGiftWraps( pubkey = identity.publicKeyHex, sinceMs = System.currentTimeMillis() - 172800000L, id = "chat-messages", handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity) ) } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) viewModelScope.launch { locationChannelManager?.selectedChannel?.collect { channel -> state.setSelectedLocationChannel(channel) switchLocationChannel(channel) } } viewModelScope.launch { locationChannelManager?.teleported?.collect { teleported -> 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) state.setIsTeleported(false) } } 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 = secureRandom.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 = secureRandom.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() GeohashConversationRegistry.clear() subscriptionManager.disconnect() currentGeohashMsgSubId = null currentGeohashPresenceSubId = null currentDmSubId = null activeChannelGeohash = 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 { val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" val pow = PoWPreferenceManager.getCurrentSettings() val localMsg = com.bitchat.android.model.BitchatMessage( id = tempId, sender = nickname ?: myPeerID, content = content, timestamp = Date(), isRelay = false, senderPeerID = "geohash:${channel.geohash}", channel = "#${channel.geohash}", powDifficulty = if (pow.enabled) pow.difficulty else null ) messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) val startedMining = pow.enabled && pow.difficulty > 0 if (startedMining) { com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId) } try { val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) val teleported = state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val relayManager = NostrRelayManager.getInstance(getApplication()) relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) } finally { // Ensure we stop the per-message mining animation regardless of success/failure if (startedMining) { com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId) } } } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") } } } fun beginGeohashSampling(geohashes: List) { if (geohashes.isEmpty()) { endGeohashSampling() return } // Diffing logic to avoid redundant REQ and leaks val currentSet = activeSamplingGeohashes.toSet() val newSet = geohashes.toSet() val toRemove = currentSet - newSet val toAdd = newSet - currentSet if (toAdd.isEmpty() && toRemove.isEmpty()) return Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed") // Remove old subscriptions toRemove.forEach { geohash -> subscriptionManager.unsubscribe("sampling-$geohash") activeSamplingGeohashes.remove(geohash) } // Add new subscriptions activeSamplingGeohashes.addAll(toAdd) if (isAppInForeground()) { toAdd.forEach { geohash -> performSubscribeSampling(geohash) } } } fun endGeohashSampling() { if (activeSamplingGeohashes.isEmpty()) return Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)") activeSamplingGeohashes.toList().forEach { geohash -> subscriptionManager.unsubscribe("sampling-$geohash") } activeSamplingGeohashes.clear() } fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash) fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex) fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { val convKey = "nostr_${pubkeyHex.take(16)}" repo.putNostrKeyMapping(convKey, pubkeyHex) // Record the conversation's geohash using the currently selected location channel (if any) val current = state.selectedLocationChannel.value val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash if (!gh.isNullOrEmpty()) { repo.setConversationGeohash(convKey, gh) GeohashConversationRegistry.set(convKey, gh) } onStartPrivateChat(convKey) Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})") } fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) { val pubkey = repo.findPubkeyByNickname(nickname) if (pubkey != null) { startGeohashDM(pubkey, onStartPrivateChat) } else { Log.w(TAG, "Cannot start geohash DM: nickname '$nickname' not found in repo") // Optionally notify user } } fun startGeohashDMByShortId(shortId: String, onStartPrivateChat: (String) -> Unit) { val pubkey = repo.findPubkeyByShortId(shortId) if (pubkey != null) { startGeohashDM(pubkey, onStartPrivateChat) } else { Log.w(TAG, "Cannot start geohash DM: shortId '$shortId' not found in repo") } } fun getNostrKeyMapping(): Map = repo.getNostrKeyMapping() fun blockUserInGeohash(targetNickname: String) { val pubkey = repo.findPubkeyByNickname(targetNickname) if (pubkey != null) { dataManager.addGeohashBlockedUser(pubkey) // Refresh people list and counts to remove blocked entry immediately repo.refreshGeohashPeople() repo.updateReactiveParticipantCounts() val sysMsg = com.bitchat.android.model.BitchatMessage( sender = "system", content = "blocked $targetNickname in geohash channels", timestamp = Date(), isRelay = false ) messageManager.addMessage(sysMsg) } else { val sysMsg = com.bitchat.android.model.BitchatMessage( sender = "system", content = "user '$targetNickname' not found in current geohash", timestamp = Date(), isRelay = false ) messageManager.addMessage(sysMsg) } } fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") } } fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { val seed = "nostr:${pubkeyHex.lowercase()}" return colorForPeerSeed(seed, isDark).copy() } private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = 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() repo.refreshGeohashPeople() } 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) try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { } try { val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) // 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}") } startGeoParticipantsTimer() // 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") repo.setCurrentGeohash(null) repo.refreshGeohashPeople() } } } /** * 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) { delay(30000) repo.refreshGeohashPeople() } } } override fun onCleared() { super.onCleared() kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } } override fun onStart(owner: LifecycleOwner) { 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 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) { // 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, id = "sampling-$geohash", handler = { event -> geohashMessageHandler.onEvent(event, geohash) } ) } private fun isAppInForeground(): Boolean { return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) } }