2026-07-27 18:23:26 +02:00

521 lines
23 KiB
Kotlin

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.geohash.GeohashNostrPrivacyPolicy
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
import com.bitchat.android.nostr.NostrBackgroundRuntime
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.isActive
import java.util.UUID
class GeohashViewModel(
application: Application,
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val notificationManager: NotificationManager
) : AndroidViewModel(application), DefaultLifecycleObserver {
companion object { private const val TAG = "GeohashViewModel" }
private val repo = GeohashRepository(application, state, dataManager)
private val uiSubscriptionOwner = "geohash-ui-${UUID.randomUUID()}"
private val subscriptionManager = NostrSubscriptionManager(
application,
owner = uiSubscriptionOwner
)
private val geohashMessageHandler = GeohashMessageHandler(
application = application,
repo = repo,
scope = viewModelScope,
dataManager = dataManager,
addChannelMessage = messageManager::addChannelMessage
)
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
private var currentGeohashPresenceSubId: String? = null
private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf<String>()
private val samplingSubscriptionIds = mutableMapOf<String, String>()
private val liveSamplingSubscriptionGeohashes = mutableSetOf<String>()
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
private var requestedUserSamplingGeohashes: Set<String> = emptySet()
private var uiSubscriptionsShutdown = false
private val liveLocationRevocationListener: () -> Unit = {
val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
revokedLiveGeohashes.forEach { geohash ->
samplingSubscriptionIds.remove(geohash)
activeSamplingGeohashes.remove(geohash)
}
liveSamplingSubscriptionGeohashes.clear()
requestedLiveSamplingGeohashes = emptySet()
}
// Geohash of the currently selected Location channel (null for Mesh/none).
private var activeChannelGeohash: String? = null
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
init {
LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener)
}
fun initialize() {
// Observe process lifecycle to manage background sampling
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
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)
}
}
} 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)
}
}
fun panicReset() {
repo.clearAll()
GeohashAliasRegistry.clear()
GeohashConversationRegistry.clear()
subscriptionManager.unsubscribeAllOwned()
currentGeohashPresenceSubId = null
activeChannelGeohash = null
geoTimer?.cancel()
geoTimer = null
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
NostrBackgroundRuntime.resetSubscriptions()
}
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
viewModelScope.launch {
try {
val canUseChannel = locationChannelManager
?.canUseSelectedLocationChannel(channel) == true
if (!canUseChannel) {
Log.w(TAG, "Blocked message to a stale live-location channel")
return@launch
}
val isLiveDerived = locationChannelManager
?.isSelectedChannelLiveDerived(channel) == true
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel)
if (isLiveDerived && liveLocationToken == null) return@launch
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 identity = NostrIdentityBridge.deriveIdentity(
forGeohash = channel.geohash,
context = getApplication()
)
val teleported = locationChannelManager?.teleported?.value
?: 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,
liveLocationToken = liveLocationToken
)
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}")
}
}
}
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet()
requestedUserSamplingGeohashes = userSelectedGeohashes.toSet()
reconcileSamplingSubscriptions()
}
private fun reconcileSamplingSubscriptions() {
val currentSet = activeSamplingGeohashes.toSet()
val newSet = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = requestedLiveSamplingGeohashes,
userSelectedGeohashes = requestedUserSamplingGeohashes,
liveLocationEnabled = LiveLocationPrivacyGate.isEnabled
)
val toRemove = currentSet - newSet
val toAdd = newSet - currentSet
val toPromoteToUserSelection = currentSet
.intersect(requestedUserSamplingGeohashes)
.intersect(liveSamplingSubscriptionGeohashes)
if (toAdd.isEmpty() && toRemove.isEmpty() && toPromoteToUserSelection.isEmpty()) return
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
// Remove old subscriptions
toRemove.forEach { geohash ->
unsubscribeSampling(geohash)
activeSamplingGeohashes.remove(geohash)
}
// A bookmark must remain functional after live access is revoked. Replace a
// live-tagged subscription with an untagged manual subscription immediately.
toPromoteToUserSelection.forEach { geohash ->
unsubscribeSampling(geohash)
if (isAppInForeground()) performSubscribeSampling(geohash)
}
// Add new subscriptions
activeSamplingGeohashes.addAll(toAdd)
if (isAppInForeground()) {
toAdd.forEach { geohash ->
performSubscribeSampling(geohash)
}
}
}
fun endGeohashSampling() {
requestedLiveSamplingGeohashes = emptySet()
requestedUserSamplingGeohashes = emptySet()
if (activeSamplingGeohashes.isEmpty()) return
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
activeSamplingGeohashes.toList().forEach { geohash ->
unsubscribeSampling(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")
}
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<String, String> = 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 ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
val geohash = repo.getConversationGeohash(conversationKey)
?: NostrBackgroundRuntime.conversationGeohash(conversationKey)
?: return
NostrBackgroundRuntime.ensureConversationDm(geohash)
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex)
return foregroundName.takeUnless { it == "anon" }
?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex)
?: foregroundName
}
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
return foregroundName.takeUnless { it == "anon" }
?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
?: foregroundName
}
fun conversationGeohash(conversationKey: String): String? =
repo.getConversationGeohash(conversationKey)
?: NostrBackgroundRuntime.conversationGeohash(conversationKey)
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
PeerIdentity.nostr(pubkeyHex)
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
geoTimer?.cancel(); geoTimer = null
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = 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 -> {
if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) {
Log.w(TAG, "Ignoring a stale live-location channel selection")
switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
return
}
Log.d(TAG, "📍 Switching to geohash channel")
activeChannelGeohash = channel.channel.geohash
repo.setCurrentGeohash(channel.channel.geohash)
repo.refreshGeohashPeople()
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 = locationChannelManager?.teleported?.value
?: state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
startGeoParticipantsTimer()
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel.channel)
// 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, liveLocationToken)
}
}
null -> {
Log.d(TAG, "📡 No channel selected")
repo.setCurrentGeohash(null)
repo.refreshGeohashPeople()
}
}
}
/**
* 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,
liveLocationToken: Long?
) {
val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
private fun startGeoParticipantsTimer() {
geoTimer = viewModelScope.launch {
while (repo.getCurrentGeohash() != null) {
delay(30000)
repo.refreshGeohashPeople()
}
}
}
override fun onCleared() {
shutdownUiSubscriptions()
super.onCleared()
}
fun shutdownUiSubscriptions() {
if (uiSubscriptionsShutdown) return
uiSubscriptionsShutdown = true
subscriptionManager.unsubscribeAllOwned()
geoTimer?.cancel()
geoTimer = null
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener)
}
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
// Android permission may have changed while backgrounded. Invalidate the
// process-wide token before restoring any subscription or heartbeat.
locationChannelManager?.syncPermissionState()
// 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.)
val selected = locationChannelManager?.selectedChannel?.value
val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location
if (selectedLocation != null &&
selectedLocation.channel.geohash == activeChannelGeohash &&
locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true
) {
subscribeChannelPresence(
selectedLocation.channel.geohash,
locationChannelManager
?.liveLocationTokenForSelectedChannel(selectedLocation.channel)
)
}
// 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()
}
}
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(::unsubscribeSampling)
// Stop participant-refresh polling
geoTimer?.cancel(); geoTimer = null
// Process-owned message and DM subscriptions remain active.
}
private fun performSubscribeSampling(geohash: String) {
val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) {
"sampling-${UUID.randomUUID()}"
}
// Sampling only needs participant counts, never message bodies, so it subscribes to
// presence heartbeats only (kind 20001) to keep the payload small.
val subscribe = {
liveSamplingSubscriptionGeohashes.remove(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
if (geohash in requestedUserSamplingGeohashes) {
subscribe()
} else if (geohash in requestedLiveSamplingGeohashes) {
val isCurrentLiveTarget = locationChannelManager
?.availableChannels
?.value
?.any { it.geohash == geohash } == true
if (!isCurrentLiveTarget) return
val token = LiveLocationPrivacyGate.captureToken() ?: return
LiveLocationPrivacyGate.runIfAllowed(token) {
liveSamplingSubscriptionGeohashes.add(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = token
)
}
}
}
private fun unsubscribeSampling(geohash: String) {
samplingSubscriptionIds.remove(geohash)?.let(subscriptionManager::unsubscribe)
liveSamplingSubscriptionGeohashes.remove(geohash)
}
private fun isAppInForeground(): Boolean {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
}