Files
bitchat-android/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt
T
callebtcandGitHub 7243d841a3 Nostr geohash (#276)
* first nostr build

* add test file

* internet access

* fix relay manager

* fix serialization

* demo service - remove later

* fix nostr

* event dedupe

* dedupe

* ui wip

* can send messages

* subscription works

* works

* favs

* works

* delete chat on change

* fix mentions

* remove autojoin channels

* styling

* adjust colors

* ui changes

* live updates working

* use local timestamp

* message history in background

* robust

* fixes

* nicknames refresh optimization

* nostr service

* refactor nostr

* style

* geohash works

* centralize colors

* refactoring

* disable DMs for now: click on peer nickname doesnt open chat list in geohash mode

* use local time

* less logging

* robustness

* scroll nickname

* adjust some text
2025-08-22 19:09:18 +02:00

1268 lines
52 KiB
Kotlin

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
) {
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
): NostrGeohashService {
return synchronized(this) {
INSTANCE ?: NostrGeohashService(
application,
state,
messageManager,
privateChatManager,
meshDelegateHandler,
coroutineScope
).also { INSTANCE = it }
}
}
}
// MARK: - Nostr Message Integration Properties
private val processedNostrEvents = mutableSetOf<String>()
private val processedNostrEventOrder = mutableListOf<String>()
private val maxProcessedNostrEvents = 2000
private val processedNostrAcks = mutableSetOf<String>()
private val nostrKeyMapping = mutableMapOf<String, String>() // senderPeerID -> nostrPubkey
// MARK: - Geohash Participant Tracking Properties
private val geohashParticipants = mutableMapOf<String, MutableMap<String, Date>>() // geohash -> participantId -> lastSeen
private var geohashSamplingJob: Job? = null
private var geoParticipantsTimer: Job? = null
// MARK: - Geohash Message History Properties
private val geohashMessageHistory = mutableMapOf<String, MutableList<BitchatMessage>>() // 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<String, String> = 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<String>) {
// 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<String, Int>()
// 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<String, String> {
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
}
// 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}")
}
}
}
}