mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
418 lines
19 KiB
Kotlin
418 lines
19 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.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
|
|
|
|
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 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
|
|
)
|
|
|
|
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
|
|
private val activeSamplingGeohashes = mutableSetOf<String>()
|
|
|
|
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
|
|
|
|
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 = 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()
|
|
GeohashConversationRegistry.clear()
|
|
subscriptionManager.disconnect()
|
|
currentGeohashSubId = null
|
|
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 {
|
|
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<String>) {
|
|
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 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 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
|
|
currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null }
|
|
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
|
|
|
|
when (channel) {
|
|
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
|
Log.d(TAG, "📡 Switched to mesh channel")
|
|
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}")
|
|
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()
|
|
|
|
viewModelScope.launch {
|
|
val geohash = channel.channel.geohash
|
|
val subId = "geohash-$geohash"; currentGeohashSubId = subId
|
|
subscriptionManager.subscribeGeohash(
|
|
geohash = geohash,
|
|
sinceMs = System.currentTimeMillis() - 3600000L,
|
|
limit = 200,
|
|
id = subId,
|
|
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
null -> {
|
|
Log.d(TAG, "📡 No channel selected")
|
|
repo.setCurrentGeohash(null)
|
|
repo.refreshGeohashPeople()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 sampling for ${activeSamplingGeohashes.size} geohashes")
|
|
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
|
|
}
|
|
|
|
override fun onStop(owner: LifecycleOwner) {
|
|
Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes")
|
|
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
|
|
}
|
|
|
|
private fun performSubscribeSampling(geohash: String) {
|
|
subscriptionManager.subscribeGeohash(
|
|
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)
|
|
}
|
|
}
|