package com.bitchat.android.nostr import android.app.Application import android.util.Log import androidx.lifecycle.viewModelScope import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.MessageManager import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.PrivateChatManager import com.bitchat.android.ui.GeoPerson import com.bitchat.android.ui.colorForPeerSeed import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* /** * Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel * Maintains 100% iOS compatibility and exact same functionality */ class NostrGeohashService( private val application: Application, private val state: ChatState, private val messageManager: MessageManager, private val privateChatManager: PrivateChatManager, private val meshDelegateHandler: MeshDelegateHandler, private val coroutineScope: CoroutineScope, private val dataManager: com.bitchat.android.ui.DataManager ) { companion object { private const val TAG = "NostrGeohashService" @Volatile private var INSTANCE: NostrGeohashService? = null fun getInstance(application: Application): NostrGeohashService { return INSTANCE ?: synchronized(this) { INSTANCE ?: throw IllegalStateException("NostrGeohashService not initialized. Call initialize() first.") } } fun initialize( application: Application, state: ChatState, messageManager: MessageManager, privateChatManager: PrivateChatManager, meshDelegateHandler: MeshDelegateHandler, coroutineScope: CoroutineScope, dataManager: com.bitchat.android.ui.DataManager ): NostrGeohashService { return synchronized(this) { INSTANCE ?: NostrGeohashService( application, state, messageManager, privateChatManager, meshDelegateHandler, coroutineScope, dataManager ).also { INSTANCE = it } } } } // MARK: - Nostr Message Integration Properties private val processedNostrEvents = mutableSetOf() private val processedNostrEventOrder = mutableListOf() private val maxProcessedNostrEvents = 2000 private val processedNostrAcks = mutableSetOf() private val nostrKeyMapping = mutableMapOf() // senderPeerID -> nostrPubkey // MARK: - Geohash Participant Tracking Properties private val geohashParticipants = mutableMapOf>() // geohash -> participantId -> lastSeen private var geohashSamplingJob: Job? = null private var geoParticipantsTimer: Job? = null // MARK: - Geohash Message History Properties private val geohashMessageHistory = mutableMapOf>() // geohash -> messages private val maxGeohashMessages = 1000 // Maximum messages per geohash // MARK: - Location Channel Management Properties private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private var currentGeohashSubscriptionId: String? = null private var currentGeohashDmSubscriptionId: String? = null private var currentGeohash: String? = null private var geoNicknames: MutableMap = mutableMapOf() // pubkeyHex(lowercased) -> nickname // MARK: - Initialization /** * Initialize Nostr relay subscriptions for gift wraps and geohash events */ fun initializeNostrIntegration() { coroutineScope.launch { val nostrRelayManager = NostrRelayManager.getInstance(application) // Connect to relays nostrRelayManager.connect() // Get current Nostr identity val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) if (currentIdentity == null) { Log.w(TAG, "No Nostr identity available for subscriptions") return@launch } // Subscribe to gift wraps (NIP-17 private messages) val dmFilter = NostrFilter.giftWrapsFor( pubkey = currentIdentity.publicKeyHex, since = System.currentTimeMillis() - 86400000L // Last 24 hours ) nostrRelayManager.subscribe( filter = dmFilter, id = "chat-messages", handler = { event -> handleNostrMessage(event) } ) Log.i(TAG, "✅ Nostr integration initialized with gift wrap subscription") } } /** * Initialize location channel state */ fun initializeLocationChannelState() { try { // Initialize location channel manager safely locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) // Observe location channel manager state and trigger channel switching locationChannelManager?.selectedChannel?.observeForever { channel -> state.setSelectedLocationChannel(channel) // CRITICAL FIX: Switch to the channel when selection changes switchLocationChannel(channel) } locationChannelManager?.teleported?.observeForever { teleported -> state.setIsTeleported(teleported) } Log.d(TAG, "✅ Location channel state initialized successfully") } catch (e: Exception) { Log.e(TAG, "❌ Failed to initialize location channel state: ${e.message}") // Set default values in case of failure state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) state.setIsTeleported(false) } } // MARK: - Message Sending /** * Send message to geohash channel via Nostr ephemeral event */ fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { coroutineScope.launch { try { val identity = NostrIdentityBridge.deriveIdentity( forGeohash = channel.geohash, context = application ) val teleported = state.isTeleported.value ?: false val event = NostrProtocol.createEphemeralGeohashEvent( content = content, geohash = channel.geohash, senderIdentity = identity, nickname = nickname ) val nostrRelayManager = NostrRelayManager.getInstance(application) nostrRelayManager.sendEvent(event) Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") // Add local echo message val localMessage = BitchatMessage( sender = nickname ?: myPeerID, content = content, timestamp = Date(), isRelay = false, senderPeerID = "geohash:${channel.geohash}", channel = "#${channel.geohash}" ) // Store our own message in geohash history storeGeohashMessage(channel.geohash, localMessage) messageManager.addMessage(localMessage) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") } } } // MARK: - Nostr Message Handling /** * Handle incoming Nostr message (gift wrap) */ private fun handleNostrMessage(giftWrap: NostrEvent) { // Simple deduplication if (processedNostrEvents.contains(giftWrap.id)) return processedNostrEvents.add(giftWrap.id) // Manage deduplication cache size processedNostrEventOrder.add(giftWrap.id) if (processedNostrEventOrder.size > maxProcessedNostrEvents) { val oldestId = processedNostrEventOrder.removeAt(0) processedNostrEvents.remove(oldestId) } // Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt if (messageAge > 87300) { // 24 hours + 15 minutes return } Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...") val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) if (currentIdentity == null) { Log.w(TAG, "No Nostr identity available for decryption") return } try { val decryptResult = NostrProtocol.decryptPrivateMessage( giftWrap = giftWrap, recipientIdentity = currentIdentity ) if (decryptResult == null) { Log.w(TAG, "Failed to decrypt Nostr message") return } val (content, senderPubkey, rumorTimestamp) = decryptResult // Expect embedded BitChat packet content if (!content.startsWith("bitchat1:")) { Log.d(TAG, "Ignoring non-embedded Nostr DM content") return } val base64Content = content.removePrefix("bitchat1:") val packetData = base64URLDecode(base64Content) if (packetData == null) { Log.e(TAG, "Failed to decode base64url BitChat packet") return } val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) if (packet == null) { Log.e(TAG, "Failed to parse embedded BitChat packet from Nostr DM") return } // Only process noiseEncrypted envelope for private messages/receipts if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) { Log.w(TAG, "Unsupported embedded packet type: ${packet.type}") return } // Validate recipient if present packet.recipientID?.let { rid -> val ridHex = rid.joinToString("") { "%02x".format(it) } // Note: myPeerID needs to be passed in as parameter // if (ridHex != myPeerID) return } // Parse plaintext typed payload (NoisePayload) val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) if (noisePayload == null) { Log.e(TAG, "Failed to parse embedded NoisePayload") return } // Map sender by Nostr pubkey to Noise key when possible val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey) val messageTimestamp = Date(rumorTimestamp * 1000L) val senderNickname = if (senderNoiseKey != null) { // Get nickname from favorites getFavoriteNickname(senderNoiseKey) ?: "Unknown" } else { "Unknown" } // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer val targetPeerID = senderNoiseKey?.let { it.joinToString("") { byte -> "%02x".format(byte) } } ?: "nostr_${senderPubkey.take(16)}" // Store Nostr key mapping nostrKeyMapping[targetPeerID] = senderPubkey processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp) } catch (e: Exception) { Log.e(TAG, "Error processing Nostr message: ${e.message}") } } /** * Process NoisePayload from Nostr message */ private fun processNoisePayload( noisePayload: com.bitchat.android.model.NoisePayload, targetPeerID: String, senderNickname: String, messageTimestamp: Date ) { when (noisePayload.type) { com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) if (pm == null) { Log.e(TAG, "Failed to decode PrivateMessagePacket") return } val messageId = pm.messageID val messageContent = pm.content // Handle favorite/unfavorite notifications if (messageContent.startsWith("[FAVORITED]") || messageContent.startsWith("[UNFAVORITED]")) { handleFavoriteNotification(messageContent, targetPeerID, senderNickname) return } // Check for duplicate message val existingChats = state.getPrivateChatsValue() var messageExists = false for ((_, messages) in existingChats) { if (messages.any { it.id == messageId }) { messageExists = true break } } if (messageExists) return // Check if viewing this chat val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == targetPeerID // Create BitchatMessage val message = BitchatMessage( id = messageId, sender = senderNickname, content = messageContent, timestamp = messageTimestamp, isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = targetPeerID, deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( to = state.getNicknameValue() ?: "Unknown", at = Date() ) ) // Add to private chats privateChatManager.handleIncomingPrivateMessage(message) // Send read receipt if viewing if (isViewingThisChat) { // Note: meshService needs to be passed as parameter // privateChatManager.sendReadReceiptsForPeer(targetPeerID, meshService) } Log.i(TAG, "📥 Processed Nostr private message from $senderNickname") } com.bitchat.android.model.NoisePayloadType.DELIVERED -> { val messageId = String(noisePayload.data, Charsets.UTF_8) // Use the existing delegate to handle delivery acknowledgment meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID) Log.d(TAG, "📥 Processed Nostr delivery ACK for message $messageId") } com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { val messageId = String(noisePayload.data, Charsets.UTF_8) // Use the existing delegate to handle read receipt meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID) Log.d(TAG, "📥 Processed Nostr read receipt for message $messageId") } } } /** * Find Noise key for Nostr pubkey from favorites */ private fun findNoiseKeyForNostrPubkey(nostrPubkey: String): ByteArray? { return com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(nostrPubkey) } /** * Get favorite nickname for Noise key */ private fun getFavoriteNickname(noiseKey: ByteArray): String? { return com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)?.peerNickname } /** * Handle favorite/unfavorite notification */ private fun handleFavoriteNotification(content: String, fromPeerID: String, senderNickname: String) { val isFavorite = content.startsWith("[FAVORITED]") val action = if (isFavorite) "favorited" else "unfavorited" // Show system message val systemMessage = BitchatMessage( sender = "system", content = "$senderNickname $action you", timestamp = Date(), isRelay = false ) messageManager.addMessage(systemMessage) Log.i(TAG, "📥 Processed favorite notification: $senderNickname $action you") } /** * Base64URL decode (without padding) */ private fun base64URLDecode(input: String): ByteArray? { return try { val padded = input.replace("-", "+") .replace("_", "/") .let { str -> val padding = (4 - str.length % 4) % 4 str + "=".repeat(padding) } android.util.Base64.decode(padded, android.util.Base64.DEFAULT) } catch (e: Exception) { Log.e(TAG, "Failed to decode base64url: ${e.message}") null } } // MARK: - Geohash Message History /** * Store a message in geohash history */ private fun storeGeohashMessage(geohash: String, message: BitchatMessage) { val messages = geohashMessageHistory.getOrPut(geohash) { mutableListOf() } messages.add(message) messages.sortBy { it.timestamp } // Limit message history to prevent memory issues if (messages.size > maxGeohashMessages) { messages.removeAt(0) // Remove oldest message } Log.v(TAG, "📦 Stored message in geohash $geohash history (${messages.size} total) - sender: ${message.sender}, content: '${message.content.take(30)}...'") } /** * Load stored messages for a geohash channel */ fun loadGeohashMessages(geohash: String) { val storedMessages = geohashMessageHistory[geohash] if (storedMessages == null) { Log.d(TAG, "📥 No stored messages found for geohash $geohash") return } Log.d(TAG, "📥 Loading ${storedMessages.size} stored messages for geohash $geohash") // Add all stored messages to the current message timeline storedMessages.forEach { message -> Log.v(TAG, "📥 Loading stored message: ${message.sender} - '${message.content.take(30)}...'") messageManager.addMessage(message) } } /** * Clear geohash message history */ fun clearGeohashMessageHistory() { geohashMessageHistory.clear() } // MARK: - Geohash Participant Tracking /** * Get participant count for a specific geohash (5-minute activity window) */ fun geohashParticipantCount(geohash: String): Int { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) // 5 minutes ago val participants = geohashParticipants[geohash] ?: return 0 // Remove expired participants val iterator = participants.iterator() while (iterator.hasNext()) { val entry = iterator.next() if (entry.value.before(cutoff)) { iterator.remove() } } return participants.size } /** * Begin sampling multiple geohashes for participant activity */ fun beginGeohashSampling(geohashes: List) { // Cancel existing sampling geohashSamplingJob?.cancel() if (geohashes.isEmpty()) return Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") geohashSamplingJob = coroutineScope.launch { val nostrRelayManager = NostrRelayManager.getInstance(application) // Subscribe to each geohash for ephemeral events (kind 20000) geohashes.forEach { geohash -> val filter = NostrFilter.geohashEphemeral( geohash = geohash, since = System.currentTimeMillis() - 86400000L, // Last 24 hours limit = 200 ) nostrRelayManager.subscribe( filter = filter, id = "geohash-$geohash", handler = { event -> handleUnifiedGeohashEvent(event, geohash) } ) Log.d(TAG, "Subscribed to geohash events for: $geohash") } } } /** * End geohash sampling */ fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") geohashSamplingJob?.cancel() geohashSamplingJob = null } /** * Update participant activity for a geohash */ private fun updateGeohashParticipant(geohash: String, participantId: String, lastSeen: Date) { val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } participants[participantId] = lastSeen // Update geohash people list if this is the current geohash if (currentGeohash == geohash) { refreshGeohashPeople() } // CRITICAL FIX: Force UI recomposition by updating reactive participant counts for location channel selector // This ensures that the location channels sheet shows live participant counts for ALL geohashes updateReactiveParticipantCounts() } /** * Update reactive participant counts for real-time location channel selector (CRITICAL FIX) */ private fun updateReactiveParticipantCounts() { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) // 5 minutes ago val counts = mutableMapOf() // Calculate current participant counts for all geohashes with recent activity for ((geohash, participants) in geohashParticipants) { // CRITICAL BUG FIX: Count active participants WITHOUT mutating original data // Don't remove from original structure - just count active ones val activeCount = participants.values.count { lastSeen -> !lastSeen.before(cutoff) } // Store the current count counts[geohash] = activeCount } // CRITICAL: Update reactive state to trigger UI recomposition state.setGeohashParticipantCounts(counts) Log.v(TAG, "🔄 Updated reactive participant counts: ${counts.size} geohashes with activity") } /** * Record geohash participant by pubkey hex (iOS-compatible) */ private fun recordGeoParticipant(pubkeyHex: String) { currentGeohash?.let { geohash -> updateGeohashParticipant(geohash, pubkeyHex, Date()) } } /** * Refresh geohash people list from current participants (iOS-compatible) */ private fun refreshGeohashPeople() { val geohash = currentGeohash if (geohash == null) { state.setGeohashPeople(emptyList()) return } // Use 5-minute activity window (matches iOS exactly) val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val participants = geohashParticipants[geohash] ?: mutableMapOf() // Remove expired participants val iterator = participants.iterator() while (iterator.hasNext()) { val entry = iterator.next() if (entry.value.before(cutoff)) { iterator.remove() } } geohashParticipants[geohash] = participants // Build GeoPerson list val people = participants.map { (pubkeyHex, lastSeen) -> val displayName = displayNameForNostrPubkey(pubkeyHex) //Log.v(TAG, "🏷️ Participant ${pubkeyHex.take(8)} -> displayName: $displayName") GeoPerson( id = pubkeyHex.lowercase(), displayName = displayName, lastSeen = lastSeen ) }.sortedByDescending { it.lastSeen } // Most recent first state.setGeohashPeople(people) //Log.d(TAG, "🌍 Refreshed geohash people: ${people.size} participants in $geohash") } /** * Start participant refresh timer for geohash channels (iOS-compatible) */ private fun startGeoParticipantsTimer() { // Cancel existing timer geoParticipantsTimer?.cancel() // Start 30-second refresh timer (matches iOS) geoParticipantsTimer = coroutineScope.launch { while (currentGeohash != null) { delay(30000) // 30 seconds refreshGeohashPeople() } } } /** * Stop participant refresh timer */ private fun stopGeoParticipantsTimer() { geoParticipantsTimer?.cancel() geoParticipantsTimer = null } /** * Check if a geohash person is teleported (iOS-compatible) */ fun isPersonTeleported(pubkeyHex: String): Boolean { return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) } /** * Start geohash DM with pubkey hex (iOS-compatible) */ fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { val convKey = "nostr_${pubkeyHex.take(16)}" nostrKeyMapping[convKey] = pubkeyHex onStartPrivateChat(convKey) Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey") } /** * Get the Nostr key mapping for geohash DMs */ fun getNostrKeyMapping(): Map { return nostrKeyMapping.toMap() } /** * Send read receipt for geohash DM */ private fun sendGeohashReadReceipt(messageID: String, recipientPubkey: String, geohash: String) { coroutineScope.launch { try { // Derive geohash-specific identity for sending val senderIdentity = NostrIdentityBridge.deriveIdentity( forGeohash = geohash, context = application ) // Send via Nostr transport val nostrTransport = NostrTransport.getInstance(application) // Set sender peer ID (get from mesh service or use a placeholder) nostrTransport.senderPeerID = "geohash:$geohash" nostrTransport.sendReadReceiptGeohash( messageID = messageID, toRecipientHex = recipientPubkey, fromIdentity = senderIdentity ) Log.d(TAG, "📤 Sent geohash read receipt for $messageID to ${recipientPubkey.take(8)}...") } catch (e: Exception) { Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") } } } // MARK: - Location Channel Management fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - LocationChannelManager not initialized") } } /** * Switch to location channel and set up proper Nostr subscriptions (iOS-compatible) * Optimized for non-blocking UI with immediate feedback */ private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { // STEP 1: Immediate UI updates (synchronous, no blocking) try { // Clear all displayed messages and load stored messages for the new channel messageManager.clearMessages() Log.d(TAG, "🗑️ Cleared all messages for channel switch") when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { Log.d(TAG, "📡 Switched to mesh channel") // Immediate UI state updates currentGeohash = null // Note: Don't clear geoNicknames - keep cached for when we return to location channels stopGeoParticipantsTimer() state.setGeohashPeople(emptyList()) state.setTeleportedGeo(emptySet()) } is com.bitchat.android.geohash.ChannelID.Location -> { Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") currentGeohash = channel.channel.geohash // Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes // Load stored messages for this geohash immediately loadGeohashMessages(channel.channel.geohash) // Immediate self-registration for instant UI feedback try { val identity = NostrIdentityBridge.deriveIdentity( forGeohash = channel.channel.geohash, context = application ) recordGeoParticipant(identity.publicKeyHex) // Mark teleported state immediately val teleported = state.isTeleported.value ?: false if (teleported) { val currentTeleported = state.getTeleportedGeoValue().toMutableSet() currentTeleported.add(identity.publicKeyHex.lowercase()) state.setTeleportedGeo(currentTeleported) } Log.d(TAG, "📍 Immediate self-registration completed for geohash UI") } catch (e: Exception) { Log.w(TAG, "Failed immediate identity setup: ${e.message}") } // Start participant refresh timer immediately startGeoParticipantsTimer() // Force immediate refresh to show any cached nicknames refreshGeohashPeople() } null -> { Log.d(TAG, "📡 No channel selected") currentGeohash = null // Note: Don't clear geoNicknames - keep cached nicknames for when we return stopGeoParticipantsTimer() state.setGeohashPeople(emptyList()) state.setTeleportedGeo(emptySet()) } } } catch (e: Exception) { Log.e(TAG, "❌ Error in immediate channel switch: ${e.message}") } // STEP 2: Async subscription setup (non-blocking background) coroutineScope.launch { try { Log.d(TAG, "🔄 Starting async subscription setup...") // Clear processed events when switching channels to get fresh timeline processedNostrEvents.clear() processedNostrEventOrder.clear() // Unsubscribe from previous geohash ephemeral events (async) currentGeohashSubscriptionId?.let { subId -> try { val nostrRelayManager = NostrRelayManager.getInstance(application) nostrRelayManager.unsubscribe(subId) currentGeohashSubscriptionId = null Log.d(TAG, "🔄 Unsubscribed from previous geohash ephemeral events: $subId") } catch (e: Exception) { Log.w(TAG, "Failed to unsubscribe from geohash events: ${e.message}") } } // Unsubscribe from previous geohash DMs (async) currentGeohashDmSubscriptionId?.let { dmSubId -> try { val nostrRelayManager = NostrRelayManager.getInstance(application) nostrRelayManager.unsubscribe(dmSubId) currentGeohashDmSubscriptionId = null Log.d(TAG, "🔄 Unsubscribed from previous geohash DMs: $dmSubId") } catch (e: Exception) { Log.w(TAG, "Failed to unsubscribe from DMs: ${e.message}") } } // Setup new subscriptions for location channels if (channel is com.bitchat.android.geohash.ChannelID.Location) { Log.d(TAG, "🌐 Setting up Nostr subscriptions for geohash: ${channel.channel.geohash}") try { val nostrRelayManager = NostrRelayManager.getInstance(application) // Subscribe to geohash ephemeral events for this specific channel val geohashSubId = "geohash-${channel.channel.geohash}" currentGeohashSubscriptionId = geohashSubId val geohashFilter = NostrFilter.geohashEphemeral( geohash = channel.channel.geohash, since = System.currentTimeMillis() - 3600000L, // Last hour for channel messages limit = 200 ) nostrRelayManager.subscribe( filter = geohashFilter, id = geohashSubId, handler = { event -> handleUnifiedGeohashEvent(event, channel.channel.geohash) } ) Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}") // Subscribe to DMs for this channel's identity val dmIdentity = NostrIdentityBridge.deriveIdentity( forGeohash = channel.channel.geohash, context = application ) val dmSubId = "geo-dm-${channel.channel.geohash}" currentGeohashDmSubscriptionId = dmSubId val dmFilter = NostrFilter.giftWrapsFor( pubkey = dmIdentity.publicKeyHex, since = System.currentTimeMillis() - 86400000L // Last 24 hours ) nostrRelayManager.subscribe( filter = dmFilter, id = dmSubId, handler = { giftWrap -> handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) } ) Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...") } catch (e: Exception) { Log.e(TAG, "❌ Failed to setup geohash subscriptions: ${e.message}") } } Log.d(TAG, "✅ Async subscription setup completed") } catch (e: Exception) { Log.e(TAG, "❌ Failed in async channel switching: ${e.message}") } } } /** * Unified handler for all geohash ephemeral events (kind 20000) * Handles participant tracking, nickname caching, message display, and teleport state */ private fun handleUnifiedGeohashEvent(event: NostrEvent, geohash: String) { try { Log.v(TAG, "🔍 handleUnifiedGeohashEvent called - eventGeohash: $geohash, currentGeohash: $currentGeohash, eventKind: ${event.kind}, eventId: ${event.id.take(8)}...") // Only handle ephemeral kind 20000 events if (event.kind != 20000) { Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})") return } // Check if this user is blocked in geohash channels BEFORE any processing if (isGeohashUserBlocked(event.pubkey)) { Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...") return } // Deduplicate events if (processedNostrEvents.contains(event.id)) { Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...") return } processedNostrEvents.add(event.id) // Manage deduplication cache size processedNostrEventOrder.add(event.id) if (processedNostrEventOrder.size > maxProcessedNostrEvents) { val oldestId = processedNostrEventOrder.removeAt(0) processedNostrEvents.remove(oldestId) } // STEP 1: Always update participant activity for all geohashes (for location channel list) val timestamp = Date(event.createdAt * 1000L) updateGeohashParticipant(geohash, event.pubkey, timestamp) // STEP 2: Always cache nickname from tag if present (for all geohashes) event.tags.find { it.size >= 2 && it[0] == "n" }?.let { nickTag -> val nick = nickTag[1] val pubkeyLower = event.pubkey.lowercase() val previousNick = geoNicknames[pubkeyLower] geoNicknames[pubkeyLower] = nick Log.v(TAG, "📝 Cached nickname for ${event.pubkey.take(8)}: $nick") // If this is a new nickname or nickname change for current geohash, refresh people list if (previousNick != nick && currentGeohash == geohash) { refreshGeohashPeople() } } // STEP 3: Always track teleport tag for participants (iOS-compatible) event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { val key = event.pubkey.lowercase() val currentTeleported = state.getTeleportedGeoValue().toMutableSet() if (!currentTeleported.contains(key)) { currentTeleported.add(key) state.setTeleportedGeo(currentTeleported) Log.d(TAG, "📍 Marked geohash participant as teleported: ${event.pubkey.take(8)}...") } } // STEP 4: Skip our own events for message display (we already locally echoed) val myGeoIdentity = NostrIdentityBridge.deriveIdentity( forGeohash = geohash, context = application ) if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) { return } // STEP 5: Store mapping for potential geohash DM initiation val key16 = "nostr_${event.pubkey.take(16)}" val key8 = "nostr:${event.pubkey.take(8)}" nostrKeyMapping[key16] = event.pubkey nostrKeyMapping[key8] = event.pubkey // STEP 6: Process message content for display (only for current/visible geohash) // Skip empty teleport presence events val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && event.content.trim().isEmpty() if (isTeleportPresence) { Log.v(TAG, "Skipping empty teleport presence event") return } val senderName = displayNameForNostrPubkey(event.pubkey) val content = event.content // Use local time instead of Nostr event time for consistent message ordering val messageTimestamp = Date() // Note: mentions parsing needs peer nicknames parameter // val mentions = messageManager.parseMentions(content, peerNicknames, nickname) val message = BitchatMessage( id = event.id, sender = senderName, content = content, timestamp = messageTimestamp, isRelay = false, senderPeerID = "nostr:${event.pubkey.take(8)}", mentions = null, // mentions need to be passed from outside channel = "#$geohash" ) // Store in geohash history for persistence across channel switches storeGeohashMessage(geohash, message) // CRITICAL BUG FIX: Add to message timeline if we're viewing this geohash OR if it matches our selected location channel // This prevents messages from being lost during channel switching race conditions val selectedLocationChannel = state.selectedLocationChannel.value val shouldShowMessage = currentGeohash == geohash || (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && selectedLocationChannel.channel.geohash == geohash) if (shouldShowMessage) { messageManager.addMessage(message) } Log.d(TAG, "📥 Unified geohash event processed - geohash: $geohash, sender: $senderName, content: ${content.take(50)}") } catch (e: Exception) { Log.e(TAG, "Error handling unified geohash event: ${e.message}") } } /** * Handle geohash DM event (private messages in geohash context) - iOS compatible */ private fun handleGeohashDmEvent( giftWrap: NostrEvent, geohash: String, identity: NostrIdentity ) { try { // Deduplicate if (processedNostrEvents.contains(giftWrap.id)) return processedNostrEvents.add(giftWrap.id) // Decrypt with per-geohash identity val decryptResult = NostrProtocol.decryptPrivateMessage( giftWrap = giftWrap, recipientIdentity = identity ) if (decryptResult == null) { Log.w(TAG, "Failed to decrypt geohash DM") return } val (content, senderPubkey, rumorTimestamp) = decryptResult // Only process BitChat embedded messages if (!content.startsWith("bitchat1:")) return val base64Content = content.removePrefix("bitchat1:") val packetData = base64URLDecode(base64Content) ?: return val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return val messageTimestamp = Date(rumorTimestamp * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" nostrKeyMapping[convKey] = senderPubkey when (noisePayload.type) { com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return val messageId = pm.messageID Log.d(TAG, "📥 Received geohash DM from ${senderPubkey.take(8)}...") // Check for duplicate message val existingChats = state.getPrivateChatsValue() var messageExists = false for ((_, messages) in existingChats) { if (messages.any { it.id == messageId }) { messageExists = true break } } if (messageExists) return val senderName = displayNameForNostrPubkey(senderPubkey) val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey val message = BitchatMessage( id = messageId, sender = senderName, content = pm.content, timestamp = messageTimestamp, isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = convKey, deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( to = state.getNicknameValue() ?: "Unknown", at = Date() ) ) // Add to private chats privateChatManager.handleIncomingPrivateMessage(message) // Send read receipt if viewing this chat if (isViewingThisChat) { // Send read receipt via Nostr for geohash DM sendGeohashReadReceipt(messageId, senderPubkey, geohash) } } com.bitchat.android.model.NoisePayloadType.DELIVERED -> { val messageId = String(noisePayload.data, Charsets.UTF_8) meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) } com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { val messageId = String(noisePayload.data, Charsets.UTF_8) meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) } } } catch (e: Exception) { Log.e(TAG, "Error handling geohash DM event: ${e.message}") } } /** * Display name for Nostr pubkey (iOS-compatible) */ private fun displayNameForNostrPubkey(pubkeyHex: String): String { val suffix = pubkeyHex.takeLast(4) val pubkeyLower = pubkeyHex.lowercase() // If this is our per-geohash identity, use our nickname val currentGeohash = this.currentGeohash if (currentGeohash != null) { try { val myGeoIdentity = NostrIdentityBridge.deriveIdentity( forGeohash = currentGeohash, context = application ) if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) { return "${state.getNicknameValue()}#$suffix" } } catch (e: Exception) { // Continue with other methods } } // If we have a cached nickname for this pubkey, use it geoNicknames[pubkeyLower]?.let { nick -> //Log.v(TAG, "✅ Found cached nickname for ${pubkeyHex.take(8)}: $nick") return "$nick#$suffix" } // Otherwise, anonymous with collision-resistant suffix //Log.v(TAG, "❌ No cached nickname for ${pubkeyHex.take(8)}, using anon") return "anon#$suffix" } // MARK: - Color System /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { val seed = "nostr:${pubkeyHex.lowercase()}" return colorForPeerSeed(seed, isDark).copy() } // MARK: - Nostr Direct Message Sending /** * Send private message via Nostr for geohash contacts */ fun sendNostrGeohashDM(content: String, recipientPeerID: String, messageID: String, myPeerID: String) { coroutineScope.launch { try { // Get the current geohash from location channel manager val locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) val selectedChannel = locationChannelManager.selectedChannel.value if (selectedChannel !is com.bitchat.android.geohash.ChannelID.Location) { Log.w(TAG, "Cannot send geohash DM: not in a location channel") return@launch } // Get the recipient's Nostr public key from the mapping val recipientHex = nostrKeyMapping[recipientPeerID] if (recipientHex == null) { Log.w(TAG, "Cannot send geohash DM: no public key mapping for $recipientPeerID") return@launch } // Derive geohash-specific identity for sending val senderIdentity = NostrIdentityBridge.deriveIdentity( forGeohash = selectedChannel.channel.geohash, context = application ) // Send via Nostr transport val nostrTransport = NostrTransport.getInstance(application) // Ensure the senderPeerID is set properly nostrTransport.senderPeerID = myPeerID nostrTransport.sendPrivateMessageGeohash( content = content, toRecipientHex = recipientHex, fromIdentity = senderIdentity, messageID = messageID ) Log.d(TAG, "📤 Sent geohash DM to $recipientPeerID via Nostr") } catch (e: Exception) { Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}") } } } /** * Send read receipt via Nostr for geohash contacts */ fun sendNostrGeohashReadReceipt(messageID: String, recipientPeerID: String, myPeerID: String) { coroutineScope.launch { try { // Get the current geohash from location channel manager val locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) val selectedChannel = locationChannelManager.selectedChannel.value if (selectedChannel !is com.bitchat.android.geohash.ChannelID.Location) { Log.w(TAG, "Cannot send geohash read receipt: not in a location channel") return@launch } // Get the recipient's Nostr public key from the mapping val recipientHex = nostrKeyMapping[recipientPeerID] if (recipientHex == null) { Log.w(TAG, "Cannot send geohash read receipt: no public key mapping for $recipientPeerID") return@launch } // Derive geohash-specific identity for sending val senderIdentity = NostrIdentityBridge.deriveIdentity( forGeohash = selectedChannel.channel.geohash, context = application ) // Send via Nostr transport val nostrTransport = NostrTransport.getInstance(application) // Ensure the senderPeerID is set properly nostrTransport.senderPeerID = myPeerID nostrTransport.sendReadReceiptGeohash( messageID = messageID, toRecipientHex = recipientHex, fromIdentity = senderIdentity ) Log.d(TAG, "📤 Sent geohash read receipt for $messageID to $recipientPeerID via Nostr") } catch (e: Exception) { Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}") } } } // MARK: - Geohash Blocking /** * Block a user in geohash channels by their nickname */ fun blockUserInGeohash(targetNickname: String) { // Find the pubkey for this nickname val pubkeyHex = geoNicknames.entries.firstOrNull { (_, nickname) -> val baseName = nickname.split("#").firstOrNull() ?: nickname baseName == targetNickname }?.key if (pubkeyHex != null) { // Add to geohash block list dataManager.addGeohashBlockedUser(pubkeyHex) // Add system message val systemMessage = com.bitchat.android.model.BitchatMessage( sender = "system", content = "blocked $targetNickname in geohash channels", timestamp = java.util.Date(), isRelay = false ) messageManager.addMessage(systemMessage) Log.i(TAG, "🚫 Blocked geohash user: $targetNickname (pubkey: ${pubkeyHex.take(8)}...)") } else { // User not found val systemMessage = com.bitchat.android.model.BitchatMessage( sender = "system", content = "user '$targetNickname' not found in current geohash", timestamp = java.util.Date(), isRelay = false ) messageManager.addMessage(systemMessage) } } /** * Check if a user is blocked in geohash channels */ private fun isGeohashUserBlocked(pubkeyHex: String): Boolean { return dataManager.isGeohashUserBlocked(pubkeyHex) } }