From 998ee606b14411fd7e1ebd3905f9d853bdef7673 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:46:15 +0200 Subject: [PATCH] Nostr refactor simplify (#390) * fix bug * geoDM receive works, send doesnt, and incoming message doesnt make sender appear in peer list * fix nostr dm * geohash dms work * Geohash DM UI: stop mixing Nostr DM temp chats into mesh offline list; ensure geohash DM senders are added to geohash people list only. Removed nostr_* sidebar append in PeopleSection; kept 64-hex mesh offline favorites. Verified build. * refactor * nice * works * merging nostr -> mesh works * tripple click to delete all * fix sidebar icon * remove hash * dms have correct recipient * works * wip unread badge * geohash dms wip * dms work --- .../favorites/FavoritesPersistenceService.kt | 241 +-- .../android/mesh/BluetoothMeshService.kt | 38 +- .../bitchat/android/mesh/MessageHandler.kt | 2 + .../android/nostr/GeohashAliasRegistry.kt | 24 + .../android/nostr/GeohashMessageHandler.kt | 99 + .../android/nostr/GeohashRepository.kt | 218 ++ .../nostr/NostrDirectMessageHandler.kt | 176 ++ .../android/nostr/NostrGeohashService.kt | 1788 ----------------- .../android/nostr/NostrSubscriptionManager.kt | 38 + .../bitchat/android/nostr/NostrTransport.kt | 69 +- .../bitchat/android/services/MessageRouter.kt | 30 +- .../java/com/bitchat/android/ui/ChatHeader.kt | 75 +- .../java/com/bitchat/android/ui/ChatState.kt | 17 + .../com/bitchat/android/ui/ChatViewModel.kt | 191 +- .../bitchat/android/ui/CommandProcessor.kt | 2 +- .../bitchat/android/ui/GeohashViewModel.kt | 259 +++ .../bitchat/android/ui/MeshDelegateHandler.kt | 23 +- .../bitchat/android/ui/PrivateChatManager.kt | 30 +- .../bitchat/android/ui/SidebarComponents.kt | 71 +- docs/ANNOUNCEMENT_GOSSIP.md | 83 + docs/SOURCE_ROUTING.md | 78 + 21 files changed, 1509 insertions(+), 2043 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt delete mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt create mode 100644 docs/ANNOUNCEMENT_GOSSIP.md create mode 100644 docs/SOURCE_ROUTING.md diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index f1366243..fe04c7b2 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -9,11 +9,12 @@ import java.util.* /** * Bridging Noise and Nostr favorites - * Direct port from iOS FavoritesPersistenceService.swift + * Direct port from iOS FavoritesPersistenceService.swift, with Android-specific + * peerID (16-hex) -> npub indexing for Nostr DM routing. */ data class FavoriteRelationship( val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes) - val peerNostrPublicKey: String?, // npub bech32 string + val peerNostrPublicKey: String?, // npub bech32 string val peerNickname: String, val isFavorite: Boolean, // We favorited them val theyFavoritedUs: Boolean, // They favorited us @@ -21,22 +22,22 @@ data class FavoriteRelationship( val lastUpdated: Date ) { val isMutual: Boolean get() = isFavorite && theyFavoritedUs - + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - + other as FavoriteRelationship - + if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false if (peerNostrPublicKey != other.peerNostrPublicKey) return false if (peerNickname != other.peerNickname) return false if (isFavorite != other.isFavorite) return false if (theyFavoritedUs != other.theyFavoritedUs) return false - + return true } - + override fun hashCode(): Int { var result = peerNoisePublicKey.contentHashCode() result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0) @@ -54,20 +55,21 @@ interface FavoritesChangeListener { /** * Manages favorites with Noise↔Nostr mapping - * Singleton pattern matching iOS implementation + * Singleton pattern matching iOS implementation. */ class FavoritesPersistenceService private constructor(private val context: Context) { - + companion object { private const val TAG = "FavoritesPersistenceService" - private const val FAVORITES_KEY = "favorite_relationships" - + private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship + private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub + @Volatile private var INSTANCE: FavoritesPersistenceService? = null - + val shared: FavoritesPersistenceService get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized") - + fun initialize(context: Context) { if (INSTANCE == null) { synchronized(this) { @@ -78,46 +80,40 @@ class FavoritesPersistenceService private constructor(private val context: Conte } } } - + private val stateManager = SecureIdentityStateManager(context) private val gson = Gson() - private val favorites = mutableMapOf() // noiseKeyHex -> relationship + private val favorites = mutableMapOf() // noiseHex -> relationship + // NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context + private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() - + init { loadFavorites() + loadPeerIdIndex() } - - /** - * Get favorite status for Noise public key - */ + + /** Get favorite status for Noise public key */ fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } return favorites[keyHex] } - - /** - * Get favorite status for 16-hex peerID - */ + + /** Get favorite status for 16-hex peerID (by noiseHex prefix match) */ fun getFavoriteStatus(peerID: String): FavoriteRelationship? { - // For 16-hex peerIDs, we need to find the corresponding full Noise key - // This is a simplified lookup - in practice you'd use fingerprint matching + val pid = peerID.lowercase() for ((_, relationship) in favorites) { val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) } - if (noiseKeyHex.startsWith(peerID)) { - return relationship - } + if (noiseKeyHex.startsWith(pid)) return relationship } return null } - - /** - * Update Nostr public key for a peer - */ + + /** Update Nostr public key for a peer (indexed by Noise key) */ fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } val existing = favorites[keyHex] - + if (existing != null) { val updated = existing.copy( peerNostrPublicKey = nostrPubkey, @@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) favorites[keyHex] = updated } else { - // Create new relationship val relationship = FavoriteRelationship( peerNoisePublicKey = noisePublicKey, peerNostrPublicKey = nostrPubkey, @@ -137,19 +132,54 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) favorites[keyHex] = relationship } - + saveFavorites() notifyChanged(keyHex) Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") } - - /** - * Update favorite status - */ + + /** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */ + fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) { + val pid = peerID.lowercase() + if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) { + peerIdIndex[pid] = nostrPubkey + savePeerIdIndex() + Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + } else { + Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") + } + } + + /** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */ + fun findNostrPubkeyForPeerID(peerID: String): String? { + return peerIdIndex[peerID.lowercase()] + } + + /** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */ + fun findPeerIDForNostrPubkey(nostrPubkey: String): String? { + // First, try direct match in peerIdIndex (values are stored as npub strings) + peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key } + + // Attempt legacy mapping via favorites Noise key association + val targetHex = normalizeNostrKeyToHex(nostrPubkey) + if (targetHex != null) { + // Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix + val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex } + if (rel != null) { + val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) } + // Return 16-hex prefix as best-effort if no explicit mapping exists + return noiseHex.take(16) + } + } + return null + } + + /** Update favorite status */ fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val existing = favorites[keyHex] - + val updated = if (existing != null) { existing.copy( peerNickname = nickname, @@ -168,21 +198,19 @@ class FavoritesPersistenceService private constructor(private val context: Conte lastUpdated = Date() ) } - + favorites[keyHex] = updated saveFavorites() notifyChanged(keyHex) Log.d(TAG, "Updated favorite status for $nickname: $isFavorite") } - - /** - * Update peer favorited us status - */ + + /** Update peer favorited-us flag */ fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } val existing = favorites[keyHex] - + if (existing != null) { val updated = existing.copy( theyFavoritedUs = theyFavoritedUs, @@ -191,101 +219,98 @@ class FavoritesPersistenceService private constructor(private val context: Conte favorites[keyHex] = updated saveFavorites() notifyChanged(keyHex) - + Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } } - - /** - * Get all mutual favorites - */ - fun getMutualFavorites(): List { - return favorites.values.filter { it.isMutual } - } - - /** - * Get all favorites we have - */ - fun getOurFavorites(): List { - return favorites.values.filter { it.isFavorite } - } - - /** - * Clear all favorites - */ + + fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } + fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } + fun clearAllFavorites() { favorites.clear() saveFavorites() + peerIdIndex.clear() + savePeerIdIndex() Log.i(TAG, "Cleared all favorites") notifyAllCleared() } - - /** - * Find Noise key by Nostr pubkey - */ + + /** Find Noise key by Nostr pubkey */ fun findNoiseKey(forNostrPubkey: String): ByteArray? { val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null return favorites.values.firstOrNull { rel -> - rel.peerNostrPublicKey?.let { stored -> - normalizeNostrKeyToHex(stored) - } == targetHex + rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }?.peerNoisePublicKey } - - /** - * Find Nostr pubkey by Noise key - */ + + /** Find Nostr pubkey by Noise key */ fun findNostrPubkey(forNoiseKey: ByteArray): String? { val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) } return favorites[keyHex]?.peerNostrPublicKey } - - // MARK: - Private Methods - + + // MARK: - Persistence + private fun loadFavorites() { try { - // Use public methods instead of reflection to access encrypted preferences val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) if (favoritesJson != null) { val type = object : TypeToken>() {}.type val data: Map = gson.fromJson(favoritesJson, type) - + favorites.clear() data.forEach { (key, relationshipData) -> - val relationship = relationshipData.toFavoriteRelationship() - favorites[key] = relationship + favorites[key] = relationshipData.toFavoriteRelationship() } - Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { Log.e(TAG, "Failed to load favorites: ${e.message}") } } - + private fun saveFavorites() { try { - // Convert to serializable format val data = favorites.mapValues { (_, relationship) -> FavoriteRelationshipData.fromFavoriteRelationship(relationship) } - val favoritesJson = gson.toJson(data) - - // Use public methods instead of reflection to access encrypted preferences stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) - Log.d(TAG, "Saved ${favorites.size} favorite relationships") } catch (e: Exception) { Log.e(TAG, "Failed to save favorites: ${e.message}") } } + private fun loadPeerIdIndex() { + try { + val json = stateManager.getSecureValue(PEERID_INDEX_KEY) + if (json != null) { + val type = object : TypeToken>() {}.type + val data: Map = gson.fromJson(json, type) + peerIdIndex.clear() + peerIdIndex.putAll(data) + Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load peerID index: ${e.message}") + } + } + + private fun savePeerIdIndex() { + try { + val json = gson.toJson(peerIdIndex) + stateManager.storeSecureValue(PEERID_INDEX_KEY, json) + Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") + } catch (e: Exception) { + Log.e(TAG, "Failed to save peerID index: ${e.message}") + } + } + // MARK: - Listeners fun addListener(listener: FavoritesChangeListener) { - synchronized(listeners) { - if (!listeners.contains(listener)) listeners.add(listener) - } + synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) } } fun removeListener(listener: FavoritesChangeListener) { synchronized(listeners) { listeners.remove(listener) } @@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte snapshot.forEach { runCatching { it.onAllCleared() } } } - /** - * Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison - */ - private fun normalizeNostrKeyToHex(value: String): String? { - return try { - if (value.startsWith("npub1")) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) - if (hrp != "npub") return null - data.joinToString("") { "%02x".format(it) } - } else { - // Assume hex - value.lowercase() - } - } catch (_: Exception) { null } - } + /** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */ + private fun normalizeNostrKeyToHex(value: String): String? = try { + if (value.startsWith("npub1")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) + if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) } + } else value.lowercase() + } catch (_: Exception) { null } } -/** - * Serializable data class for JSON storage - */ +/** Serializable data for JSON storage */ private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, @@ -341,7 +356,7 @@ private data class FavoriteRelationshipData( ) } } - + fun toFavoriteRelationship(): FavoriteRelationship { val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() return FavoriteRelationship( diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index da3d1226..723d603e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -284,6 +284,13 @@ class BluetoothMeshService(private val context: Context) { // Store fingerprint for the peer via centralized fingerprint manager val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + + // Index existing Nostr mapping by the new peerID if we have it + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) + } + } catch (_: Exception) { } // If there was a previous peer ID, remove it to avoid duplicates previousPeerID?.let { oldPeerID -> @@ -539,25 +546,13 @@ class BluetoothMeshService(private val context: Context) { */ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { if (content.isEmpty() || recipientPeerID.isEmpty()) return - if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return + if (recipientNickname.isEmpty()) return serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...") - // Check if this is a Nostr contact (geohash DM) - if (recipientPeerID.startsWith("nostr_")) { - // Get NostrGeohashService instance and send via Nostr - try { - val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application) - nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID) - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}") - } - return@launch - } - // Check if we have an established Noise session if (encryptionService.hasEstablishedSession(recipientPeerID)) { try { @@ -625,15 +620,14 @@ class BluetoothMeshService(private val context: Context) { serviceScope.launch { Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") - // Check if this is a Nostr contact (geohash DM) - if (recipientPeerID.startsWith("nostr_")) { - // Get NostrGeohashService instance and send read receipt via Nostr - try { - val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application) - nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID) - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}") - } + // Route geohash read receipts via MessageRouter instead of here + val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() + val isGeoAlias = try { + val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() + map.containsKey(recipientPeerID) + } catch (_: Exception) { false } + if (isGeoAlias && geo != null) { + geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) return@launch } diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 76fea7a1..c2faffea 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -452,7 +452,9 @@ class MessageHandler(private val myPeerID: String) { if (noiseKey != null) { com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) if (npub != null) { + // Index by noise key and current mesh peerID for fast Nostr routing com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub) } // Determine iOS-style guidance text diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt new file mode 100644 index 00000000..ea2ab0ba --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.nostr + +import java.util.concurrent.ConcurrentHashMap + +/** + * GeohashAliasRegistry + * - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_ -> pubkeyHex) + * - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels + */ +object GeohashAliasRegistry { + private val map: MutableMap = ConcurrentHashMap() + + fun put(alias: String, pubkeyHex: String) { + map[alias] = pubkeyHex + } + + fun get(alias: String): String? = map[alias] + + fun contains(alias: String): Boolean = map.containsKey(alias) + + fun snapshot(): Map = HashMap(map) + + fun clear() { map.clear() } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt new file mode 100644 index 00000000..766169fa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.MessageManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +/** + * GeohashMessageHandler + * - Processes kind=20000 Nostr events for geohash channels + * - Updates repository for participants + nicknames + * - Emits messages to MessageManager + */ +class GeohashMessageHandler( + private val application: Application, + private val state: ChatState, + private val messageManager: MessageManager, + private val repo: GeohashRepository, + private val scope: CoroutineScope +) { + companion object { private const val TAG = "GeohashMessageHandler" } + + // Simple event deduplication + private val processedIds = ArrayDeque() + private val seen = HashSet() + private val max = 2000 + + private fun dedupe(id: String): Boolean { + if (seen.contains(id)) return true + seen.add(id) + processedIds.addLast(id) + if (processedIds.size > max) { + val old = processedIds.removeFirst() + seen.remove(old) + } + return false + } + + fun onEvent(event: NostrEvent, subscribedGeohash: String) { + scope.launch(Dispatchers.Default) { + try { + if (event.kind != 20000) return@launch + val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) + if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch + if (dedupe(event.id)) return@launch + + // PoW validation (if enabled) + val pow = PoWPreferenceManager.getCurrentSettings() + if (pow.enabled && pow.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch + } + + // Blocked users check + if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch + + // Update repository (participants, nickname, teleport) + // Update repository on a background-safe path; repository will post updates to LiveData + repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L)) + event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) } + event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) } + // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr + try { + com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey) + } catch (_: Exception) { } + + // Skip our own events for message emission + val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) + if (my.publicKeyHex.equals(event.pubkey, true)) return@launch + + val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && + event.content.trim().isEmpty() + if (isTeleportPresence) return@launch + + val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey) + val msg = BitchatMessage( + id = event.id, + sender = senderName, + content = event.content, + timestamp = Date(event.createdAt * 1000L), + isRelay = false, + originalSender = repo.displayNameForNostrPubkey(event.pubkey), + senderPeerID = "nostr:${event.pubkey.take(8)}", + mentions = null, + channel = "#$subscribedGeohash", + powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null } + ) + withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) } + } catch (e: Exception) { + Log.e(TAG, "onEvent error: ${e.message}") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt new file mode 100644 index 00000000..f5e607bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -0,0 +1,218 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import androidx.lifecycle.LiveData +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.GeoPerson +import java.util.Date + +/** + * GeohashRepository + * - Owns geohash participant tracking and nickname caching + * - Maintains lightweight state for geohash-related UI + */ +class GeohashRepository( + private val application: Application, + private val state: ChatState +) { + companion object { private const val TAG = "GeohashRepository" } + + // geohash -> (participant pubkeyHex -> lastSeen) + private val geohashParticipants: MutableMap> = mutableMapOf() + + + // pubkeyHex(lowercase) -> nickname (without #hash) + private val geoNicknames: MutableMap = mutableMapOf() + + // conversation key (e.g., "nostr_") -> source geohash it belongs to + private val conversationGeohash: MutableMap = mutableMapOf() + + fun setConversationGeohash(convKey: String, geohash: String) { + if (geohash.isNotEmpty()) { + conversationGeohash[convKey] = geohash + } + } + + fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey] + + fun findPubkeyByNickname(targetNickname: String): String? { + return geoNicknames.entries.firstOrNull { (_, nickname) -> + val base = nickname.split("#").firstOrNull() ?: nickname + base == targetNickname + }?.key + } + + // peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases + private val nostrKeyMapping: MutableMap = mutableMapOf() + + // Current geohash in view + private var currentGeohash: String? = null + + fun setCurrentGeohash(geo: String?) { currentGeohash = geo } + fun getCurrentGeohash(): String? = currentGeohash + + fun clearAll() { + geohashParticipants.clear() + geoNicknames.clear() + nostrKeyMapping.clear() + state.setGeohashPeople(emptyList()) + state.setTeleportedGeo(emptySet()) + state.setGeohashParticipantCounts(emptyMap()) + currentGeohash = null + } + + fun cacheNickname(pubkeyHex: String, nickname: String) { + val lower = pubkeyHex.lowercase() + val previous = geoNicknames[lower] + geoNicknames[lower] = nickname + if (previous != nickname && currentGeohash != null) { + refreshGeohashPeople() + } + } + + fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()] + + fun markTeleported(pubkeyHex: String) { + val set = state.getTeleportedGeoValue().toMutableSet() + val key = pubkeyHex.lowercase() + if (!set.contains(key)) { + set.add(key) + // Background safe update + state.postTeleportedGeo(set) + } + } + + fun isPersonTeleported(pubkeyHex: String): Boolean { + return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) + } + + fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { + val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } + participants[participantId] = lastSeen + if (currentGeohash == geohash) refreshGeohashPeople() + updateReactiveParticipantCounts() + } + + fun geohashParticipantCount(geohash: String): Int { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[geohash] ?: return 0 + // prune expired + val it = participants.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.before(cutoff)) it.remove() + } + return participants.size + } + + fun refreshGeohashPeople() { + val geohash = currentGeohash + if (geohash == null) { + // Use postValue for thread safety - this can be called from background threads + state.postGeohashPeople(emptyList()) + return + } + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[geohash] ?: mutableMapOf() + // prune expired + val it = participants.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.before(cutoff)) it.remove() + } + geohashParticipants[geohash] = participants + val people = participants.map { (pubkeyHex, lastSeen) -> + val base = getCachedNickname(pubkeyHex) ?: "anon" + GeoPerson( + id = pubkeyHex.lowercase(), + displayName = base, // UI can add #hash if necessary + lastSeen = lastSeen + ) + }.sortedByDescending { it.lastSeen } + // Use postValue for thread safety - this can be called from background threads + state.postGeohashPeople(people) + } + + fun updateReactiveParticipantCounts() { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val counts = mutableMapOf() + for ((gh, participants) in geohashParticipants) { + val active = participants.values.count { !it.before(cutoff) } + counts[gh] = active + } + // Use postValue for thread safety - this can be called from background threads + state.postGeohashParticipantCounts(counts) + } + + fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { + nostrKeyMapping[tempKeyOrPeer] = pubkeyHex + } + + fun getNostrKeyMapping(): Map = nostrKeyMapping.toMap() + + fun displayNameForNostrPubkey(pubkeyHex: String): String { + val suffix = pubkeyHex.takeLast(4) + val lower = pubkeyHex.lowercase() + // Self nickname if matches current identity of current geohash + val current = currentGeohash + if (current != null) { + try { + val my = NostrIdentityBridge.deriveIdentity(current, application) + if (my.publicKeyHex.equals(lower, true)) { + return "${state.getNicknameValue()}#$suffix" + } + } catch (_: Exception) {} + } + val nick = geoNicknames[lower] ?: "anon" + return "$nick#$suffix" + } + + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val lower = pubkeyHex.lowercase() + val suffix = pubkeyHex.takeLast(4) + val current = currentGeohash + val base: String = try { + if (current != null) { + val my = NostrIdentityBridge.deriveIdentity(current, application) + if (my.publicKeyHex.equals(lower, true)) { + state.getNicknameValue() ?: "anon" + } else geoNicknames[lower] ?: "anon" + } else geoNicknames[lower] ?: "anon" + } catch (_: Exception) { geoNicknames[lower] ?: "anon" } + if (current == null) return base + return try { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[current] ?: emptyMap() + var count = 0 + for ((k, t) in participants) { + if (t.before(cutoff)) continue + val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon") + if (name.equals(base, true)) { count++; if (count > 1) break } + } + if (!participants.containsKey(lower)) count += 1 + if (count > 1) "$base#$suffix" else base + } catch (_: Exception) { base } + } + + /** + * Get display name for any geohash (not just current one) for header titles + */ + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val lower = pubkeyHex.lowercase() + val suffix = pubkeyHex.takeLast(4) + val base = geoNicknames[lower] ?: "anon" + return try { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[sourceGeohash] ?: emptyMap() + var count = 0 + for ((k, t) in participants) { + if (t.before(cutoff)) continue + val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon") + if (name.equals(base, true)) { count++; if (count > 1) break } + } + if (!participants.containsKey(lower)) count += 1 + if (count > 1) "$base#$suffix" else base + } catch (_: Exception) { base } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt new file mode 100644 index 00000000..394ea3d9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -0,0 +1,176 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +class NostrDirectMessageHandler( + private val application: Application, + private val state: ChatState, + private val privateChatManager: PrivateChatManager, + private val meshDelegateHandler: MeshDelegateHandler, + private val scope: CoroutineScope, + private val repo: GeohashRepository +) { + companion object { private const val TAG = "NostrDirectMessageHandler" } + + private val seenStore by lazy { SeenMessageStore.getInstance(application) } + + // Simple event deduplication + private val processedIds = ArrayDeque() + private val seen = HashSet() + private val max = 2000 + + private fun dedupe(id: String): Boolean { + if (seen.contains(id)) return true + seen.add(id) + processedIds.addLast(id) + if (processedIds.size > max) { + val old = processedIds.removeFirst() + seen.remove(old) + } + return false + } + + fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { + scope.launch(Dispatchers.Default) { + try { + if (dedupe(giftWrap.id)) return@launch + + val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt + if (messageAge > 173700) return@launch // 48 hours + 15 mins + + val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) + if (decryptResult == null) { + Log.w(TAG, "Failed to decrypt Nostr message") + return@launch + } + + val (content, senderPubkey, rumorTimestamp) = decryptResult + if (!content.startsWith("bitchat1:")) return@launch + + val base64Content = content.removePrefix("bitchat1:") + val packetData = base64URLDecode(base64Content) ?: return@launch + val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch + + if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch + + val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch + val messageTimestamp = Date(rumorTimestamp * 1000L) + val convKey = "nostr_${senderPubkey.take(16)}" + repo.putNostrKeyMapping(convKey, senderPubkey) + com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) + if (geohash.isNotEmpty()) { + // Remember which geohash this conversation belongs to so we can subscribe on-demand + repo.setConversationGeohash(convKey, geohash) + GeohashConversationRegistry.set(convKey, geohash) + } + + // Ensure sender appears in geohash people list even if they haven't posted publicly yet + if (geohash.isNotEmpty()) { + // Cache a best-effort nickname and mark as participant + val cached = repo.getCachedNickname(senderPubkey) + if (cached == null) { + val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#") + repo.cacheNickname(senderPubkey, base) + } + repo.updateParticipant(geohash, senderPubkey, messageTimestamp) + } + + val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey) + + processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity) + + } catch (e: Exception) { + Log.e(TAG, "onGiftWrap error: ${e.message}") + } + } + } + + private suspend fun processNoisePayload( + payload: com.bitchat.android.model.NoisePayload, + convKey: String, + senderNickname: String, + timestamp: Date, + senderPubkey: String, + recipientIdentity: NostrIdentity + ) { + when (payload.type) { + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { + val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return + val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList() + if (existingMessages.any { it.id == pm.messageID }) return + + val message = BitchatMessage( + id = pm.messageID, + sender = senderNickname, + content = pm.content, + timestamp = timestamp, + isRelay = false, + isPrivate = true, + recipientNickname = state.getNicknameValue(), + senderPeerID = convKey, + deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) + ) + + val isViewing = state.getSelectedPrivateChatPeerValue() == convKey + val suppressUnread = seenStore.hasRead(pm.messageID) + + withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) + } + + if (!seenStore.hasDelivered(pm.messageID)) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markDelivered(pm.messageID) + } + + if (isViewing && !suppressUnread) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markRead(pm.messageID) + } + } + com.bitchat.android.model.NoisePayloadType.DELIVERED -> { + val messageId = String(payload.data, Charsets.UTF_8) + withContext(Dispatchers.Main) { + meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) + } + } + com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { + val messageId = String(payload.data, Charsets.UTF_8) + withContext(Dispatchers.Main) { + meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) + } + } + } + } + + 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 + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt deleted file mode 100644 index f59e4434..00000000 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ /dev/null @@ -1,1788 +0,0 @@ - -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.* -import kotlin.random.Random - -/** - * 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, - private val notificationManager: com.bitchat.android.ui.NotificationManager -) { - - 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, - notificationManager: com.bitchat.android.ui.NotificationManager - ): NostrGeohashService { - return synchronized(this) { - INSTANCE ?: NostrGeohashService( - application, - state, - messageManager, - privateChatManager, - meshDelegateHandler, - coroutineScope, - dataManager, - notificationManager - ).also { INSTANCE = it } - } - } - } - - // MARK: - Nostr Message Integration Properties - - private val processedNostrEvents = mutableSetOf() - private val processedNostrEventOrder = mutableListOf() - private val maxProcessedNostrEvents = 2000 - // removed unused processedNostrAcks - 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 Routing - // Geohash messages are routed through MessageManager channel timelines using key "geo:" - - // 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() - 172800000L // Last 48 hours (align with NIP-17 randomization) - ) - - nostrRelayManager.subscribe( - filter = dmFilter, - id = "chat-messages", - handler = { event -> - handleNostrMessage(event) - } - ) - - Log.i(TAG, "✅ Nostr integration initialized with gift wrap subscription") - } - } - - /** - * Panic/reset flow for Nostr + geohash systems. - * - Disconnect and clear all Nostr relay subscriptions and caches - * - Delete device-wide Nostr keys and per-geohash seed cache - * - Clear geohash participants, nicknames, message history - * - Recreate identity and reconnect; reinitialize subscriptions from scratch - */ - fun panicResetNostrAndGeohash() { - coroutineScope.launch { - try { - val relayManager = NostrRelayManager.getInstance(application) - - // 1) Disconnect from all relays - relayManager.disconnect() - - // 2) Clear all subscription tracking and caches - relayManager.clearAllSubscriptions() - - // 3) Clear Nostr identity (npub/private) and geohash identity cache/seed - try { - NostrIdentityBridge.clearAllAssociations(application) - } catch (e: Exception) { - Log.e(TAG, "Failed to clear Nostr associations: ${e.message}") - } - - // 4) Clear local geohash state (participants, nicknames, message history) - try { - geohashParticipants.clear() - geoNicknames.clear() - processedNostrEvents.clear() - processedNostrEventOrder.clear() - currentGeohashSubscriptionId = null - currentGeohashDmSubscriptionId = null - currentGeohash = null - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - state.setGeohashParticipantCounts(emptyMap()) - // Stop any timers/jobs - geohashSamplingJob?.cancel() - geohashSamplingJob = null - geoParticipantsTimer?.cancel() - geoParticipantsTimer = null - } catch (e: Exception) { - Log.e(TAG, "Failed to clear geohash state: ${e.message}") - } - - // 5) Recreate identity and reconnect, then re-subscribe - try { - // Touch identity to recreate - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity == null) { - Log.w(TAG, "No identity after reset; skipping subscriptions") - return@launch - } - - // Reconnect to relays and reinitialize subscriptions - relayManager.connect() - initializeNostrIntegration() - // Re-establish location channel state observers and subscriptions - initializeLocationChannelState() - } catch (e: Exception) { - Log.e(TAG, "Failed to reinitialize Nostr after reset: ${e.message}") - } - } catch (e: Exception) { - Log.e(TAG, "panicResetNostrAndGeohash error: ${e.message}") - } - } - } - - /** - * 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 { - // Generate a temporary message ID for tracking animation - val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}" - try { - // Add local echo message IMMEDIATELY (with temporary ID) - val powSettingsLocal = PoWPreferenceManager.getCurrentSettings() - val localMessage = BitchatMessage( - id = tempMessageId, - sender = nickname ?: myPeerID, - content = content, - timestamp = Date(), - isRelay = false, - senderPeerID = "geohash:${channel.geohash}", - channel = "#${channel.geohash}", - powDifficulty = if (powSettingsLocal.enabled) powSettingsLocal.difficulty else null - ) - - // Local echo into geohash channel timeline via unified MessageManager - messageManager.addChannelMessage("geo:${channel.geohash}", localMessage) - - Log.d(TAG, "📝 Added geohash local echo with temp ID: $tempMessageId (not shown in mesh timeline)") - - // Check if PoW is enabled before starting animation - val powSettings = PoWPreferenceManager.getCurrentSettings() - if (powSettings.enabled && powSettings.difficulty > 0) { - // Start matrix animation for this message - com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempMessageId) - Log.d(TAG, "🎭 Started matrix animation for message: $tempMessageId") - } - - // Now begin the async PoW process - 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, - teleported = teleported - ) - - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.sendEventToGeohash( - event = event, - geohash = channel.geohash, - includeDefaults = false, - nRelays = 5 - ) - - Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send geohash message: ${e.message}") - } finally { - com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId) - } - } - } - - // MARK: - Nostr Message Handling - - /** - * Handle incoming Nostr message (gift wrap) - */ - private fun handleNostrMessage(giftWrap: NostrEvent) { - // Offload processing to avoid blocking UI - coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { - // Simple deduplication - if (processedNostrEvents.contains(giftWrap.id)) return@launch - 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 > 173700) { // 48 hours + 15 minutes - return@launch - } - - Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...") - - // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - - val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - Log.w(TAG, "No Nostr identity available for decryption") - return@launch - } - - try { - val decryptResult = NostrProtocol.decryptPrivateMessage( - giftWrap = giftWrap, - recipientIdentity = currentIdentity - ) - - if (decryptResult == null) { - Log.w(TAG, "Failed to decrypt Nostr message") - return@launch - } - - val (content, senderPubkey, rumorTimestamp) = decryptResult - - // Expect embedded BitChat packet content - if (!content.startsWith("bitchat1:")) { - Log.d(TAG, "Ignoring non-embedded Nostr DM content") - return@launch - } - - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) - if (packetData == null) { - Log.e(TAG, "Failed to decode base64url BitChat packet") - return@launch - } - - 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@launch - } - - // 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@launch - } - - // 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@launch - } - - // Map sender by Nostr pubkey to Noise key when possible - val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey) - val messageTimestamp = Date(rumorTimestamp * 1000L) - - // If we know the Noise key, try to resolve a currently connected mesh peer ID for it - val targetPeerID: String = if (senderNoiseKey != null) { - val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) - if (meshPeerId != null) { - // Also unify existing noise-hex/nostr-temp chats into this mesh peer - val noiseHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - val tempKey = "nostr_${senderPubkey.take(16)}" - unifyChatsIntoPeer(meshPeerId, listOf(noiseHex, tempKey)) - - // If currently viewing the temporary or noise-hex chat, auto-switch to mesh peer - val selected = state.getSelectedPrivateChatPeerValue() - if (selected == noiseHex || selected == tempKey) { - state.setSelectedPrivateChatPeer(meshPeerId) - } - meshPeerId - } else { - senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - } - } else { - "nostr_${senderPubkey.take(16)}" - } - - // Prefer nickname from mesh when connected; else fallback to favorites - val senderNickname: String = if (senderNoiseKey != null) { - val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) - if (meshPeerId != null) { - // Use live mesh nickname if available - meshDelegateHandler.getPeerInfo(meshPeerId)?.nickname - ?: getFavoriteNickname(senderNoiseKey) - ?: "Unknown" - } else { - getFavoriteNickname(senderNoiseKey) ?: "Unknown" - } - } else { - "Unknown" - } - - // Store Nostr key mapping - nostrKeyMapping[targetPeerID] = senderPubkey - - // Process payload and update UI/state - processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp) - - // If this was a private message, send a delivery ACK back over Nostr - if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) - pm?.let { pmsg -> - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - if (!seen.hasDelivered(pmsg.messageID)) { - val nostrTransport = NostrTransport.getInstance(application) - if (senderNoiseKey != null) { - val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex) - } else { - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity != null) { - nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity) - } - } - seen.markDelivered(pmsg.messageID) - } - } - } - - } catch (e: Exception) { - Log.e(TAG, "Error processing Nostr message: ${e.message}") - } - } - } - - /** - * Process NoisePayload from Nostr message - */ - private suspend 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 - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - val suppressUnread = seen.hasRead(messageId) - - // 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 on Main - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) - } - - // Send read receipt if viewing (only once across restarts) - if (isViewingThisChat && !seen.hasRead(messageId)) { - try { - val rr = com.bitchat.android.model.ReadReceipt(originalMessageID = messageId) - NostrTransport.getInstance(application).sendReadReceipt(rr, targetPeerID) - seen.markRead(messageId) - } catch (_: Exception) { } - } - - 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 on Main - withContext(Dispatchers.Main) { - 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 on Main - withContext(Dispatchers.Main) { - 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 - } - - /** - * Resolve a currently-connected mesh peer ID for a given Noise public key - * by matching against the mesh service's peer info noisePublicKey values. - */ - private fun resolveMeshPeerIdForNoiseKey(noiseKey: ByteArray): String? { - return try { - val peers: List = state.getConnectedPeersValue() - peers.firstOrNull { peerId: String -> - val info = meshDelegateHandler.getPeerInfo(peerId) - info?.noisePublicKey?.contentEquals(noiseKey) == true - } - } catch (_: Exception) { null } - } - - /** - * Merge any chats stored under the given keys into the target peer's chat entry - * so messages received while offline appear in the same chat when the peer connects. - */ - private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) - } - - /** - * 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" - - // Try to extract npub after colon, if present - val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } - - // Resolve noise key if possible and persist relationship + npub mapping - try { - var noiseKey: ByteArray? = null - // If fromPeerID looks like hex (noise key), decode - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { - // Expect 64 hex chars for full Curve25519 key; accept others best-effort - val bytes = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - // Use only if length is plausible - if (bytes.isNotEmpty()) noiseKey = bytes - } else { - // fromPeerID likely a temporary key like "nostr_..."; map to Nostr pubkey - val senderPubkey = nostrKeyMapping[fromPeerID] - if (senderPubkey != null) { - noiseKey = findNoiseKeyForNostrPubkey(senderPubkey) - } - } - - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) - if (npub != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) - } - } - } catch (_: Exception) { - // Best-effort - } - - // Determine guidance text based on mutual status (iOS-style) - val guidance = try { - val rel = run { - // Try to resolve via noise key directly or mapping - var key: ByteArray? = null - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { - key = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } else { - val mappedNostr = nostrKeyMapping[fromPeerID] - key = mappedNostr?.let { findNoiseKeyForNostrPubkey(it) } - } - key?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) } - } - if (isFavorite) { - if (rel?.isFavorite == true) { - " — mutual! You can continue DMs via Nostr when out of mesh." - } else { - " — favorite back to continue DMs later." - } - } else { - ". DMs over Nostr will pause unless you both favorite again." - } - } catch (_: Exception) { "" } - - // Show system message - val systemMessage = BitchatMessage( - sender = "system", - content = "$senderNickname $action you$guidance", - 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 - } - } - - // Geohash message history is maintained in ChatState.channelMessages; no local cache here - - // 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) using geohash-specific relays - geohashes.forEach { geohash -> - val filter = NostrFilter.geohashEphemeral( - geohash = geohash, - since = System.currentTimeMillis() - 86400000L, // Last 24 hours - limit = 200 - ) - - nostrRelayManager.subscribeForGeohash( - geohash = geohash, - filter = filter, - id = "geohash-$geohash", - handler = { event -> - handleUnifiedGeohashEvent(event, geohash) - }, - includeDefaults = false, - nRelays = 5 - ) - - 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 { - // Do not clear messages here - preserve mesh history when switching between views - - when (channel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - Log.d(TAG, "📡 Switched to mesh channel") - // Immediate UI state updates - currentGeohash = null - // Update notification manager with current geohash - notificationManager.setCurrentGeohash(null) - // Clear mesh mention notifications since user is now viewing mesh chat - notificationManager.clearMeshMentionNotifications() - // 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 - // Update notification manager with current geohash - notificationManager.setCurrentGeohash(channel.channel.geohash) - // Clear notifications for this geohash since user is now viewing it - notificationManager.clearNotificationsForGeohash(channel.channel.geohash) - // Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes - // Clear unread for this geohash since user is now viewing it - try { - messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") - } catch (_: Exception) { } - - // 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 using geohash-specific relays - 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.subscribeForGeohash( - geohash = channel.channel.geohash, - filter = geohashFilter, - id = geohashSubId, - handler = { event -> - handleUnifiedGeohashEvent(event, channel.channel.geohash) - }, - includeDefaults = false, - nRelays = 5 - ) - - 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() - 172800000L // Last 48 hours (align with NIP-17 randomization) - ) - - // IMPORTANT: For geohash DMs, use default relays (iOS behavior) - nostrRelayManager.subscribe( - filter = dmFilter, - id = dmSubId, - handler = { giftWrap -> - handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) - }, - targetRelayUrls = null - ) - - 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) { - coroutineScope.launch(Dispatchers.Default) { - try { - Log.v(TAG, "🔍 handleUnifiedGeohashEvent called - subGeohash: $geohash, currentGeohash: $currentGeohash, kind: ${event.kind}, id: ${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@launch - } - - // VALIDATE EVENT G TAG AGAINST SUBSCRIPTION GEOHASH - val eventGeohash = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) - if (eventGeohash == null) { - Log.w(TAG, "🚫 Dropping kind=20000 without 'g' tag id=${event.id.take(8)}") - return@launch - } - if (!eventGeohash.equals(geohash, ignoreCase = true)) { - Log.w(TAG, "🚫 Dropping mismatched geohash event: sub=$geohash eventTag=$eventGeohash id=${event.id.take(8)}") - return@launch - } - - // Check Proof of Work validation BEFORE other processing - val powSettings = PoWPreferenceManager.getCurrentSettings() - if (powSettings.enabled && powSettings.difficulty > 0) { - if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) { - Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})") - return@launch - } - Log.v(TAG, "✅ PoW validation passed for event ${event.id.take(8)}...") - } - - // 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@launch - } - - // Deduplicate events - if (processedNostrEvents.contains(event.id)) { - Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...") - return@launch - } - 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) - withContext(Dispatchers.Main) { 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) { - withContext(Dispatchers.Main) { 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) - withContext(Dispatchers.Main) { 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@launch - } - - // 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@launch - } - - val senderHandle = displayNameForNostrPubkey(event.pubkey) - val senderName = displayNameForNostrPubkeyUI(event.pubkey) - val content = event.content - - // Preserve Nostr event time to avoid reordering - val messageTimestamp = Date(event.createdAt.toLong() * 1000L) - // Note: mentions parsing needs peer nicknames parameter - // val mentions = messageManager.parseMentions(content, peerNicknames, nickname) - - // Calculate actual PoW difficulty from the finalized event ID so we can show it for incoming messages too - val eventHasNonseTag = event.tags.any { it.isNotEmpty() && it[0] == "nonce" } - val actualPow = try { NostrProofOfWork.calculateDifficulty(event.id) } catch (e: Exception) { 0 } - - val message = BitchatMessage( - id = event.id, - sender = senderName, - content = content, - timestamp = messageTimestamp, - isRelay = false, - originalSender = senderHandle, - senderPeerID = "nostr:${event.pubkey.take(8)}", - mentions = null, // mentions need to be passed from outside - channel = "#$geohash", - powDifficulty = actualPow.takeIf { it > 0 && eventHasNonseTag } ?: null - ) - - // Add to geohash channel timeline (unified pipeline) - withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$geohash", message) } - - // NOTIFICATION LOGIC: Check for mentions and first messages - checkAndTriggerGeohashNotifications(geohash, senderName, content, 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}") - } - } - } - - /** - * Check and trigger geohash notifications for mentions and first messages - */ - private suspend fun checkAndTriggerGeohashNotifications( - geohash: String, - senderName: String, - content: String, - message: BitchatMessage - ) { - try { - // Get user's current nickname - val currentNickname = state.getNicknameValue() - if (currentNickname.isNullOrEmpty()) { - return - } - - // Check if this message mentions the current user - val isMention = checkForMention(content, currentNickname) - - // Check if this is the first message in a subscribed geohash chat - val isFirstMessage = checkIfFirstMessage(geohash, message) - - // Only trigger notifications if we have a mention or first message - if (isMention || isFirstMessage) { - Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage") - - withContext(Dispatchers.Main) { - // Sanitize mention for notifications: hide '#hash' from @username#hash if not needed - val contentForNotification = if (isMention) { - var sanitized = content - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myDisplay = displayNameForNostrPubkeyUI(myGeoIdentity.publicKeyHex) - val needsDisambiguation = myDisplay.contains("#") - if (!needsDisambiguation) { - val pattern = ("@" + java.util.regex.Pattern.quote(currentNickname) + "#[a-fA-F0-9]{4}\\b").toRegex() - sanitized = sanitized.replace(pattern, "@" + currentNickname) - } - } catch (_: Exception) { } - sanitized - } else content - - val locationName = getLocationNameForGeohash(geohash) - notificationManager.showGeohashNotification( - geohash = geohash, - senderNickname = senderName, - messageContent = contentForNotification, - isMention = isMention, - isFirstMessage = isFirstMessage, - locationName = locationName - ) - } - } - } catch (e: Exception) { - Log.e(TAG, "Error checking geohash notifications: ${e.message}") - } - } - - /** - * Check if the content mentions the current user with @nickname#hash format - */ - private fun checkForMention(content: String, currentNickname: String): Boolean { - // iOS-style mention pattern: @nickname#1234 or @nickname - val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() - - return mentionPattern.findAll(content).any { match -> - val mentionWithoutAt = match.groupValues[1] - // Split the mention to get base nickname (without #hash suffix) - val baseName = if (mentionWithoutAt.contains("#")) { - mentionWithoutAt.substringBeforeLast("#") - } else { - mentionWithoutAt - } - - // Check if the base name matches current user's nickname - baseName.equals(currentNickname, ignoreCase = true) - } - } - - /** - * Check if this is the first message in a subscribed geohash chat - */ - private fun checkIfFirstMessage(geohash: String, message: BitchatMessage): Boolean { - // Use ChatState.channelMessages for geohash history - val channelKey = "geo:$geohash" - val messageHistory = state.getChannelMessagesValue()[channelKey] ?: emptyList() - - // Filter out our own messages (local echoes and messages from our identity) - val otherUserMessages = messageHistory.filter { msg -> - // Check if this is our own message by comparing sender ID with our geohash identity - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" - msg.senderPeerID != myIdentityId && msg.senderPeerID != message.senderPeerID - } catch (e: Exception) { - // If we can't determine identity, assume it's not our message - msg.senderPeerID != message.senderPeerID - } - } - - // This is a first message if there are no other user messages and this isn't from us - val isFromUs = try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" - message.senderPeerID == myIdentityId - } catch (e: Exception) { - false - } - - return otherUserMessages.isEmpty() && !isFromUs - } - - /** - * Handle geohash DM event (private messages in geohash context) - iOS compatible - */ - private fun handleGeohashDmEvent( - giftWrap: NostrEvent, - geohash: String, - identity: NostrIdentity - ) { - coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { - try { - // Deduplicate - if (processedNostrEvents.contains(giftWrap.id)) return@launch - processedNostrEvents.add(giftWrap.id) - - // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - - // Decrypt with per-geohash identity - val decryptResult = NostrProtocol.decryptPrivateMessage( - giftWrap = giftWrap, - recipientIdentity = identity - ) - - if (decryptResult == null) { - Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)") - return@launch - } - - val (content, senderPubkey, rumorTimestamp) = decryptResult - - // Only process BitChat embedded messages - if (!content.startsWith("bitchat1:")) return@launch - - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) ?: return@launch - val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return@launch - - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch - 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@launch - 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@launch - - val senderHandle = displayNameForNostrPubkey(senderPubkey) - val senderName = displayNameForNostrPubkeyUI(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, - originalSender = senderHandle, - deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( - to = state.getNicknameValue() ?: "Unknown", - at = Date() - ) - ) - - // Add to private chats (suppress unread if already read) - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = seen.hasRead(messageId)) - - // Send delivery ACK for geohash DMs only once - if (!seen.hasDelivered(messageId)) { - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity) - seen.markDelivered(messageId) - } - - // Send read receipt if viewing this chat - if (isViewingThisChat && !seen.hasRead(messageId)) { - // Send read receipt via Nostr for geohash DM - sendGeohashReadReceipt(messageId, senderPubkey, geohash) - // Mark message as read persistently - try { seen.markRead(messageId) } catch (_: Exception) { } - } - } - - 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" - } - - /** - * UI display name for Nostr pubkey (conditionally hides #hash when not needed) - * Used in chat window sender labels, geohash people list, and notifications. - */ - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { - val pubkeyLower = pubkeyHex.lowercase() - val suffix = pubkeyHex.takeLast(4) - val currentGeohash = this.currentGeohash - - // Determine base name (self nickname, cached nickname, or anon) - val baseName: String = try { - if (currentGeohash != null) { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = currentGeohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) { - state.getNicknameValue() ?: "anon" - } else { - geoNicknames[pubkeyLower] ?: "anon" - } - } else { - geoNicknames[pubkeyLower] ?: "anon" - } - } catch (_: Exception) { - geoNicknames[pubkeyLower] ?: "anon" - } - - // If not in a geohash context, no need for disambiguation - if (currentGeohash == null) return baseName - - // Count active participants sharing the same base name within 5 minutes - return try { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) - val participants = geohashParticipants[currentGeohash] ?: emptyMap() - - var count = 0 - for ((participantKey, lastSeen) in participants) { - if (lastSeen.before(cutoff)) continue - val participantLower = participantKey.lowercase() - val name = if (participantLower == pubkeyLower) baseName else (geoNicknames[participantLower] ?: "anon") - if (name.equals(baseName, ignoreCase = true)) { - count++ - if (count > 1) break - } - } - - if (!participants.containsKey(pubkeyLower)) { - count += 1 - } - - if (count > 1) "$baseName#$suffix" else baseName - } catch (_: Exception) { - baseName - } - } - - /** - * Mention handle for a Nostr pubkey (ALWAYS includes #hash) - * Use for mentions, hug/slap targets, and mention detection contexts. - */ - fun mentionHandleForNostrPubkey(pubkeyHex: String): String { - return displayNameForNostrPubkey(pubkeyHex) - } - - // 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) - } - - /** - * Get location name for a geohash if available - */ - private fun getLocationNameForGeohash(geohash: String): String? { - return try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val locationNames = locationManager.locationNames.value ?: return null - val available = locationManager.availableChannels.value ?: emptyList() - - // Determine the level from geohash length - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - 7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - - // Only show location name if the notification's geohash matches the device's current geohash at that level - val currentAtLevel = available.firstOrNull { it.level == level }?.geohash - if (currentAtLevel != null && currentAtLevel.equals(geohash, ignoreCase = true)) { - locationNames[level] - } else { - null - } - } catch (e: Exception) { - Log.w(TAG, "Failed to get location name for geohash $geohash: ${e.message}") - null - } - } - -} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt new file mode 100644 index 00000000..54e6a5a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * NostrSubscriptionManager + * - Encapsulates subscription lifecycle with NostrRelayManager + */ +class NostrSubscriptionManager( + private val application: Application, + private val scope: CoroutineScope +) { + companion object { private const val TAG = "NostrSubscriptionManager" } + + private val relayManager get() = NostrRelayManager.getInstance(application) + + fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } + fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } + + fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) + relayManager.subscribe(filter, id, handler) + } + } + + fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit) + relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + } + } + + fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 217e081d..18da6bc5 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -56,8 +56,7 @@ class NostrTransport( // Resolve favorite by full noise key or by short peerID fallback var recipientNostrPubkey: String? = null - // Try to resolve from favorites persistence service - // This would need integration with the existing favorites system + // Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { @@ -86,13 +85,23 @@ class NostrTransport( return@launch } + // Strict: lookup the recipient's current BitChat peer ID using favorites mapping + val recipientPeerIDForEmbed = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .findPeerIDForNostrPubkey(recipientNostrPubkey) + } catch (_: Exception) { null } + if (recipientPeerIDForEmbed.isNullOrBlank()) { + Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM. npub=${recipientNostrPubkey.take(16)}...") + return@launch + } val embedded = NostrEmbeddedBitChat.encodePMForNostr( content = content, messageID = messageID, - recipientPeerID = to, + recipientPeerID = recipientPeerIDForEmbed, senderPeerID = senderPeerID ) + if (embedded == null) { Log.e(TAG, "NostrTransport: failed to embed PM packet") return@launch @@ -412,39 +421,58 @@ class NostrTransport( fun sendPrivateMessageGeohash( content: String, toRecipientHex: String, - fromIdentity: NostrIdentity, - messageID: String + messageID: String, + sourceGeohash: String? = null ) { + // Use provided geohash or derive from current location + val geohash = sourceGeohash ?: run { + val selected = try { + com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value + } catch (_: Exception) { null } + if (selected !is com.bitchat.android.geohash.ChannelID.Location) { + Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided") + return + } + selected.channel.geohash + } + + val fromIdentity = try { + NostrIdentityBridge.deriveIdentity(geohash, context) + } catch (e: Exception) { + Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}") + return + } + transportScope.launch { try { if (toRecipientHex.isEmpty()) return@launch - - Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...") - + + Log.d( + TAG, + "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}... geohash=$geohash" + ) + // Build embedded BitChat packet without recipient peer ID val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content = content, messageID = messageID, senderPeerID = senderPeerID - ) - - if (embedded == null) { + ) ?: run { Log.e(TAG, "NostrTransport: failed to embed geohash PM packet") return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - + giftWraps.forEach { event -> Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...") NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event) } - } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") } @@ -458,14 +486,15 @@ class NostrTransport( */ private fun resolveNostrPublicKey(peerID: String): String? { try { - // Try to resolve from favorites persistence service + // 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it } + + // 2) Legacy path: resolve by noise public key association val noiseKey = hexStringToByteArray(peerID) val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - if (favoriteStatus?.peerNostrPublicKey != null) { - return favoriteStatus.peerNostrPublicKey - } - - // Fallback: try with 16-hex peerID lookup + if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey + + // 3) Prefix match on noiseHex from 16-hex peerID if (peerID.length == 16) { val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) return fallbackStatus?.peerNostrPublicKey diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index b968aa96..39119f7e 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -43,6 +43,7 @@ class MessageRouter private constructor( // Listener for favorites changes to flush outbox when npub mapping appears/changes private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { flushOutboxFor(noiseKeyHex) // Also try 16-hex short id commonly used in UI if any client used that @@ -55,16 +56,30 @@ class MessageRouter private constructor( } fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) { + // First: if this is a geohash DM alias (nostr_), route via Nostr using global registry + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { + Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}…") + val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) + if (recipientHex != null) { + // Resolve the conversation's source geohash, so we can send from anywhere + val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID) + + // If repository knows the source geohash, pass it so NostrTransport derives the correct identity + nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash) + return + } + } + val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true val hasEstablished = mesh.hasEstablishedSession(toPeerID) if (hasMesh && hasEstablished) { - Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}… id=${messageID.take(8)}…") + Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) } else if (canSendViaNostr(toPeerID)) { - Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}…") + Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…") nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) } else { - Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…") + Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") @@ -84,7 +99,14 @@ class MessageRouter private constructor( fun sendDeliveryAck(messageID: String, toPeerID: String) { // Mesh delivery ACKs are sent by the receiver automatically. - // Only route via Nostr when mesh path isn't available. + // Only route via Nostr when mesh path isn't available or when this is a geohash alias + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { + val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) + if (recipientHex != null) { + nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return }) + return + } + } if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) { nostr.sendDeliveryAck(messageID, toPeerID) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index bb161953..c33c3c14 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui + import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable * Extracted from ChatScreen.kt for better organization */ + /** * Reactive helper to compute favorite state from fingerprint mapping * This eliminates the need for static isFavorite parameters and makes @@ -159,7 +161,6 @@ fun PeerCounter( connectedPeers: List, joinedChannels: Set, hasUnreadChannels: Map, - hasUnreadPrivateMessages: Set, isConnected: Boolean, selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, geohashPeople: List, @@ -189,33 +190,6 @@ fun PeerCounter( verticalAlignment = Alignment.CenterVertically, modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing ) { - if (hasUnreadChannels.values.any { it > 0 }) { - // Channel icon in a Box to ensure consistent size with other icons - Box( - modifier = Modifier.size(16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "#", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF0080FF), - fontSize = 16.sp - ) - } - Spacer(modifier = Modifier.width(6.dp)) - } - - if (hasUnreadPrivateMessages.isNotEmpty()) { - // Filled mail icon to match sidebar style - Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Unread private messages", - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // Orange to match private message theme - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Icon( imageVector = Icons.Default.Group, contentDescription = when (selectedLocationChannel) { @@ -226,6 +200,7 @@ fun PeerCounter( tint = countColor ) Spacer(modifier = Modifier.width(4.dp)) + Text( text = "$peopleCount", style = MaterialTheme.typography.bodyMedium, @@ -290,7 +265,8 @@ fun ChatHeaderContent( selectedLocationChannel = selectedLocationChannel, geohashPeople = geohashPeople, onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } + onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }, + viewModel = viewModel ) } currentChannel != null -> { @@ -326,7 +302,8 @@ private fun PrivateChatHeader( selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, geohashPeople: List, onBackClick: () -> Unit, - onToggleFavorite: () -> Unit + onToggleFavorite: () -> Unit, + viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") @@ -345,12 +322,24 @@ private fun PrivateChatHeader( // Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity) val titleText: String = if (isNostrDM) { - val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash - val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:") - val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) } - val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown" - val geoPart = geohash?.let { "#$it" } ?: "#geohash" - "$geoPart/@$baseName" + // For geohash DMs, get the actual source geohash and proper display name + val (conversationGeohash, baseName) = try { + val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo") + repoField.isAccessible = true + val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository + val gh = repo.getConversationGeohash(peerID) ?: "geohash" + val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: "" + val displayName = if (fullPubkey.isNotEmpty()) { + repo.displayNameForGeohashConversation(fullPubkey, gh) + } else { + peerNicknames[peerID] ?: "unknown" + } + Pair(gh, displayName) + } catch (e: Exception) { + Pair("geohash", peerNicknames[peerID] ?: "unknown") + } + + "#$conversationGeohash/@$baseName" } else { // Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key peerNicknames[peerID] ?: run { @@ -563,6 +552,19 @@ private fun MainHeader( horizontalArrangement = Arrangement.spacedBy(5.dp) ) { + // Unread private messages badge (click to open most recent DM) + if (hasUnreadPrivateMessages.isNotEmpty()) { + // Render icon directly to avoid symbol resolution issues + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Unread private messages", + modifier = Modifier + .size(16.dp) + .clickable { viewModel.openLatestUnreadPrivateChat() }, + tint = Color(0xFFFF9500) + ) + } + // Location channels button (matching iOS implementation) LocationChannelsButton( viewModel = viewModel, @@ -582,7 +584,6 @@ private fun MainHeader( connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, - hasUnreadPrivateMessages = hasUnreadPrivateMessages, isConnected = isConnected, selectedLocationChannel = selectedLocationChannel, geohashPeople = geohashPeople, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 352cd807..e17c1494 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -124,6 +124,9 @@ class ChatState { // Geohash people state (iOS-compatible) private val _geohashPeople = MutableLiveData>(emptyList()) val geohashPeople: LiveData> = _geohashPeople + // For background thread updates by repositories/handlers in their own scopes + val geohashPeopleMutable: MutableLiveData> get() = _geohashPeople + private val _teleportedGeo = MutableLiveData>(emptySet()) val teleportedGeo: LiveData> = _teleportedGeo @@ -155,6 +158,16 @@ class ChatState { fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() + // Thread-safe posting helpers for background updates + fun postGeohashPeople(people: List) { + _geohashPeople.postValue(people) + } + + fun postGeohashParticipantCounts(counts: Map) { + _geohashParticipantCounts.postValue(counts) + } + + fun getCurrentChannelValue() = _currentChannel.value fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() @@ -183,6 +196,10 @@ class ChatState { _connectedPeers.value = peers } + fun postTeleportedGeo(teleported: Set) { + _teleportedGeo.postValue(teleported) + } + fun setNickname(nickname: String) { _nickname.value = nickname } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 89429b53..de85c597 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.protocol.BitchatPacket -import com.bitchat.android.nostr.NostrGeohashService + + import kotlinx.coroutines.launch import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay @@ -68,18 +69,20 @@ class ChatViewModel( getMeshService = { meshService } ) - // Nostr and Geohash service - initialize singleton - private val nostrGeohashService = NostrGeohashService.initialize( + // New Geohash architecture ViewModel (replaces God object service usage in UI path) + val geohashViewModel = GeohashViewModel( application = application, state = state, messageManager = messageManager, privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, - coroutineScope = viewModelScope, dataManager = dataManager, notificationManager = notificationManager ) + + + // Expose state through LiveData (maintaining the same interface) val messages: LiveData> = state.messages val connectedPeers: LiveData> = state.connectedPeers @@ -168,14 +171,12 @@ class ChatViewModel( } } - // Initialize location channel state - nostrGeohashService.initializeLocationChannelState() + // Initialize new geohash architecture + geohashViewModel.initialize() // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) - // Initialize Nostr integration - nostrGeohashService.initializeNostrIntegration() // Ensure NostrTransport knows our mesh peer ID for embedded packets try { @@ -213,6 +214,46 @@ class ChatViewModel( meshService.sendBroadcastAnnounce() } + /** + * Ensure Nostr DM subscription for a geohash conversation key if known + * Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline + */ + private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { + try { + val repoField = GeohashViewModel::class.java.getDeclaredField("repo") + repoField.isAccessible = true + val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository + val gh = repo.getConversationGeohash(convKey) + if (!gh.isNullOrEmpty()) { + val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager") + subMgrField.isAccessible = true + val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication()) + val subId = "geo-dm-$gh" + val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId") + currentDmSubField.isAccessible = true + val currentId = currentDmSubField.get(geohashViewModel) as String? + if (currentId != subId) { + (currentId)?.let { subMgr.unsubscribe(it) } + currentDmSubField.set(geohashViewModel, subId) + subMgr.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172800000L, + id = subId, + handler = { event -> + val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler") + dmHandlerField.isAccessible = true + val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler + dmHandler.onGiftWrap(event, gh, identity) + } + ) + } + } + } catch (e: Exception) { + Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}") + } + } + // MARK: - Channel Management (delegated) fun joinChannel(channel: String, password: String? = null): Boolean { @@ -231,6 +272,11 @@ class ChatViewModel( // MARK: - Private Chat Management (delegated) fun startPrivateChat(peerID: String) { + // For geohash conversation keys, ensure DM subscription is active + if (peerID.startsWith("nostr_")) { + ensureGeohashDMSubscriptionIfNeeded(peerID) + } + val success = privateChatManager.startPrivateChat(peerID, meshService) if (success) { // Notify notification manager about current private chat @@ -258,6 +304,66 @@ class ChatViewModel( // Clear mesh mention notifications since user is now back in mesh chat clearMeshMentionNotifications() } + + // MARK: - Open Latest Unread Private Chat + + fun openLatestUnreadPrivateChat() { + try { + val unreadKeys = state.getUnreadPrivateMessagesValue() + if (unreadKeys.isEmpty()) return + + val me = state.getNicknameValue() ?: meshService.myPeerID + val chats = state.getPrivateChatsValue() + + // Pick the latest incoming message among unread conversations + var bestKey: String? = null + var bestTime: Long = Long.MIN_VALUE + + unreadKeys.forEach { key -> + val list = chats[key] + if (!list.isNullOrEmpty()) { + // Prefer the latest incoming message (sender != me), fallback to last message + val latestIncoming = list.lastOrNull { it.sender != me } + val candidateTime = (latestIncoming ?: list.last()).timestamp.time + if (candidateTime > bestTime) { + bestTime = candidateTime + bestKey = key + } + } + } + + val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return + + val openPeer: String = if (targetKey.startsWith("nostr_")) { + // Use the exact conversation key for geohash DMs and ensure DM subscription + ensureGeohashDMSubscriptionIfNeeded(targetKey) + targetKey + } else { + // Resolve to a canonical mesh peer if needed + val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = targetKey, + connectedPeers = state.getConnectedPeersValue(), + meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + canonical ?: targetKey + } + + startPrivateChat(openPeer) + + // If sidebar visible, hide it to focus on the private chat + if (state.getShowSidebarValue()) { + state.setShowSidebar(false) + } + } catch (e: Exception) { + Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") + } + } + + // END - Open Latest Unread Private Chat + // MARK: - Message Sending @@ -270,7 +376,7 @@ class ChatViewModel( commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel -> if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { // Route command-generated public messages via Nostr in geohash channels - nostrGeohashService.sendGeohashMessage( + geohashViewModel.sendGeohashMessage( messageContent, selectedLocationForCommand.channel, meshService.myPeerID, @@ -298,7 +404,7 @@ class ChatViewModel( connectedPeers = state.getConnectedPeersValue(), meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, - nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] }, + nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } ).also { canonical -> if (canonical != state.getSelectedPrivateChatPeerValue()) { @@ -323,7 +429,7 @@ class ChatViewModel( val selectedLocationChannel = state.selectedLocationChannel.value if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { // Send to geohash channel via Nostr ephemeral event - nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) + geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) } else { // Send public/channel message via mesh val message = BitchatMessage( @@ -376,17 +482,36 @@ class ChatViewModel( Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) - // Persist relationship in FavoritesPersistenceService when we have Noise key + // Persist relationship in FavoritesPersistenceService try { + var noiseKey: ByteArray? = null + var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID + + // Case 1: Live mesh peer with known info val peerInfo = meshService.getPeerInfo(peerID) - val noiseKey = peerInfo?.noisePublicKey - val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID) + if (peerInfo?.noisePublicKey != null) { + noiseKey = peerInfo.noisePublicKey + nickname = peerInfo.nickname + } else { + // Case 2: Offline favorite entry using 64-hex noise public key as peerID + if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + try { + noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + // Prefer nickname from favorites store if available + val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!) + if (rel != null) nickname = rel.peerNickname + } catch (_: Exception) { } + } + } + if (noiseKey != null) { - val isNowFavorite = dataManager.favoritePeers.contains( - com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: "" - ) + // Determine current favorite state from DataManager using fingerprint + val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val fingerprint = identityManager.generateFingerprint(noiseKey!!) + val isNowFavorite = dataManager.favoritePeers.contains(fingerprint) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus( - noisePublicKey = noiseKey, + noisePublicKey = noiseKey!!, nickname = nickname, isFavorite = isNowFavorite ) @@ -596,7 +721,7 @@ class ChatViewModel( // Clear Nostr/geohash state, keys, connections, and reinitialize from scratch try { - nostrGeohashService.panicResetNostrAndGeohash() + geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") } @@ -638,10 +763,20 @@ class ChatViewModel( try { val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() - Log.d(TAG, "✅ Cleared secure identity state") + // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) + try { + identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") + } catch (_: Exception) { } + Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") } + + // Clear FavoritesPersistenceService persistent relationships + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") + } catch (_: Exception) { } Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { @@ -653,48 +788,48 @@ class ChatViewModel( * Get participant count for a specific geohash (5-minute activity window) */ fun geohashParticipantCount(geohash: String): Int { - return nostrGeohashService.geohashParticipantCount(geohash) + return geohashViewModel.geohashParticipantCount(geohash) } /** * Begin sampling multiple geohashes for participant activity */ fun beginGeohashSampling(geohashes: List) { - nostrGeohashService.beginGeohashSampling(geohashes) + geohashViewModel.beginGeohashSampling(geohashes) } /** * End geohash sampling */ fun endGeohashSampling() { - nostrGeohashService.endGeohashSampling() + // No-op in refactored architecture; sampling subscriptions are short-lived } /** * Check if a geohash person is teleported (iOS-compatible) */ fun isPersonTeleported(pubkeyHex: String): Boolean { - return nostrGeohashService.isPersonTeleported(pubkeyHex) + return geohashViewModel.isPersonTeleported(pubkeyHex) } /** * Start geohash DM with pubkey hex (iOS-compatible) */ fun startGeohashDM(pubkeyHex: String) { - nostrGeohashService.startGeohashDM(pubkeyHex) { convKey -> + geohashViewModel.startGeohashDM(pubkeyHex) { convKey -> startPrivateChat(convKey) } } fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - nostrGeohashService.selectLocationChannel(channel) + geohashViewModel.selectLocationChannel(channel) } /** * Block a user in geohash channels by their nickname */ fun blockUserInGeohash(targetNickname: String) { - nostrGeohashService.blockUserInGeohash(targetNickname) + geohashViewModel.blockUserInGeohash(targetNickname) } // MARK: - Navigation Management @@ -767,6 +902,6 @@ class ChatViewModel( * Get consistent color for a Nostr pubkey (iOS-compatible) */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark) + return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) } } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index d36f67b4..499b3926 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -293,7 +293,7 @@ class CommandProcessor( val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *" // If we're in a geohash location channel, don't add a local echo here. - // NostrGeohashService.sendGeohashMessage() will add the local echo with proper metadata. + // GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata. val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location // Send as regular message diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt new file mode 100644 index 00000000..08865c1e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -0,0 +1,259 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +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 kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date + +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) { + + companion object { private const val TAG = "GeohashViewModel" } + + private val repo = GeohashRepository(application, state) + private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) + private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope) + private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo) + + private var currentGeohashSubId: String? = null + private var currentDmSubId: String? = null + private var geoTimer: Job? = null + private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null + + val geohashPeople: LiveData> = state.geohashPeople + val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val selectedLocationChannel: LiveData = state.selectedLocationChannel + + fun initialize() { + subscriptionManager.connect() + 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()) + locationChannelManager?.selectedChannel?.observeForever { channel -> + state.setSelectedLocationChannel(channel) + switchLocationChannel(channel) + } + locationChannelManager?.teleported?.observeForever { teleported -> + state.setIsTeleported(teleported) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize location channel state: ${e.message}") + state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) + state.setIsTeleported(false) + } + } + + fun panicReset() { + repo.clearAll() + subscriptionManager.disconnect() + currentGeohashSubId = null + currentDmSubId = null + geoTimer?.cancel() + geoTimer = null + try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} + initialize() + } + + 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) + if (pow.enabled && pow.difficulty > 0) { + com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId) + } + val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) + val teleported = state.isTeleported.value ?: false + val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) + val relayManager = NostrRelayManager.getInstance(getApplication()) + relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) + } catch (e: Exception) { + Log.e(TAG, "Failed to send geohash message: ${e.message}") + } + } + } + + fun beginGeohashSampling(geohashes: List) { + if (geohashes.isEmpty()) return + Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") + viewModelScope.launch { + geohashes.forEach { geohash -> + subscriptionManager.subscribeGeohash( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = "sampling-$geohash", + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + } + } + + fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") } + 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) + onStartPrivateChat(convKey) + Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey") + } + + fun getNostrKeyMapping(): Map = repo.getNostrKeyMapping() + + fun blockUserInGeohash(targetNickname: String) { + val pubkey = repo.findPubkeyByNickname(targetNickname) + if (pubkey != null) { + dataManager.addGeohashBlockedUser(pubkey) + val sysMsg = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = "blocked $targetNickname in geohash channels", + timestamp = Date(), + isRelay = false + ) + 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) + com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh) + } + onStartPrivateChat(convKey) + Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)") + } + + 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 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()) + repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) + val teleported = state.isTeleported.value ?: false + 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 + com.bitchat.android.nostr.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() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 57d755d6..ea7fff68 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -107,16 +107,21 @@ class MeshDelegateHandler( meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> peers.contains(pid) }, nostrPubHexForAlias = { alias -> - // Best-effort: derive pub hex from favorites mapping - val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } - favs.firstNotNullOfOrNull { rel -> - rel.peerNostrPublicKey?.let { s -> - runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> - if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null + // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { + com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) + } else { + // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases + val prefix = alias.removePrefix("nostr_") + val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } + favs.firstNotNullOfOrNull { rel -> + rel.peerNostrPublicKey?.let { s -> + runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> + if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null + } } - } - }?.takeIf { it.startsWith(prefix, ignoreCase = true) } + }?.takeIf { it.startsWith(prefix, ignoreCase = true) } + } }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } ) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 3d849ac1..e8a3a461 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -3,6 +3,8 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.PeerFingerprintManager +import java.security.MessageDigest + import com.bitchat.android.mesh.BluetoothMeshService import java.util.* import android.util.Log @@ -123,21 +125,40 @@ class PrivateChatManager( } fun toggleFavorite(peerID: String) { - val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return + var fingerprint = fingerprintManager.getFingerprintForPeer(peerID) + + // Fallback: if this looks like a 64-hex Noise public key (offline favorite entry), + // compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers + if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + try { + val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val digest = java.security.MessageDigest.getInstance("SHA-256") + val fpBytes = digest.digest(pubBytes) + fingerprint = fpBytes.joinToString("") { "%02x".format(it) } + Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint") + } catch (e: Exception) { + Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}") + } + } + + if (fingerprint == null) { + Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle") + return + } Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint") - val wasFavorite = dataManager.isFavorite(fingerprint) + val wasFavorite = dataManager.isFavorite(fingerprint!!) Log.d(TAG, "Current favorite status: $wasFavorite") val currentFavorites = state.getFavoritePeersValue() Log.d(TAG, "Current UI state favorites: $currentFavorites") if (wasFavorite) { - dataManager.removeFavorite(fingerprint) + dataManager.removeFavorite(fingerprint!!) Log.d(TAG, "Removed from favorites: $fingerprint") } else { - dataManager.addFavorite(fingerprint) + dataManager.addFavorite(fingerprint!!) Log.d(TAG, "Added to favorites: $fingerprint") } @@ -149,6 +170,7 @@ class PrivateChatManager( Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}") } + fun isFavorite(peerID: String): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false val isFav = dataManager.isFavorite(fingerprint) diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 502e63cb..973a817b 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -413,6 +413,22 @@ fun PeopleSection( val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (isMappedToConnected) return@forEach + // Resolve potential Nostr conversation key for this favorite (for unread detection) + val nostrConvKey: String? = try { + val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + if (npubOrHex != null) { + val hex = if (npubOrHex.startsWith("npub")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) + if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + } else { + npubOrHex.lowercase() + } + hex?.let { "nostr_${it.take(16)}" } + } else null + } catch (_: Exception) { null } + + val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) + // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, // open chat with the connected peerID instead of the noise hex for a seamless window val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key @@ -420,13 +436,20 @@ fun PeopleSection( val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val showHash = (baseNameCounts[bName] ?: 0) > 1 + // Compute unreadCount from either noise conversation or Nostr conversation + val unreadCount = ( + privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 + ) + ( + if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 + ) + PeerItem( peerID = favPeerID, displayName = dn, isDirect = false, isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, - hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID), + hasUnreadDM = hasUnread, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, @@ -434,21 +457,23 @@ fun PeopleSection( Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") viewModel.toggleFavorite(favPeerID) }, - unreadCount = privateChats[favPeerID]?.count { msg -> - msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) - } ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0, + unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), showHashSuffix = showHash ) appendedOfflineIds.add(favPeerID) } - // Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet - // This ensures a user can open and read Nostr messages while the sender remains offline + // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. + // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. + // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. + // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. + /* val alreadyShownIds = connectedIds + appendedOfflineIds privateChats.keys .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && + // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases + hex64Regex.matches(key) && !alreadyShownIds.contains(key) && // Skip if this key maps to a connected peer via noiseHex mapping !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } @@ -460,24 +485,27 @@ fun PeopleSection( val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val showHash = (baseNameCounts[bName] ?: 0) > 1 - PeerItem( - peerID = convKey, - displayName = dn, - isDirect = false, - isSelected = convKey == selectedPrivatePeer, - isFavorite = false, - hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), - colorScheme = colorScheme, - viewModel = viewModel, + PeerItem( + peerID = convKey, + displayName = dn, + isDirect = false, + isSelected = convKey == selectedPrivatePeer, + isFavorite = false, + hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), + colorScheme = colorScheme, + viewModel = viewModel, onItemClick = { onPrivateChatStart(convKey) }, onToggleFavorite = { viewModel.toggleFavorite(convKey) }, unreadCount = privateChats[convKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, - showNostrGlobe = true, + showNostrGlobe = false, showHashSuffix = showHash ) } + */ + // End intentional removal + } } @@ -538,6 +566,15 @@ private fun PeerItem( modifier = Modifier.size(16.dp), tint = Color(0xFF9C27B0) // Purple ) + } else if (!isDirect && isFavorite) { + // Offline favorited user: show outlined circle icon + Icon( + //painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite), + imageVector = Icons.Outlined.Circle, + contentDescription = "Offline favorite", + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) } else { Icon( imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, diff --git a/docs/ANNOUNCEMENT_GOSSIP.md b/docs/ANNOUNCEMENT_GOSSIP.md new file mode 100644 index 00000000..61d8f398 --- /dev/null +++ b/docs/ANNOUNCEMENT_GOSSIP.md @@ -0,0 +1,83 @@ +# Announcement Gossip TLV (Direct Neighbors) + +This document specifies an optional TLV extension to the BitChat `ANNOUNCE` message that allows peers to gossip which other peers they are currently connected to directly over Bluetooth. Implementations can use this to build a mesh topology view (nodes = peers, edges = direct connections). + +Status: optional and backward-compatible. + +## Layering Overview + +- Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged. +- Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility. +- Signature: The packet MAY be signed using the Ed25519 public key carried in TLV `0x03`. The gossip TLV (if present) is part of the payload and therefore covered by the signature. + +## TLV Format + +Each TLV uses a compact layout: + +- `type`: 1 byte +- `length`: 1 byte (0..255) +- `value`: `length` bytes + +Existing TLVs (unchanged): + +- `0x01` NICKNAME: UTF‑8 string (≤ 255 bytes) +- `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519) +- `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes) + +New TLV (optional): + +- `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored. + +### Peer ID Binary Encoding (8 bytes) + +Peer IDs are represented as 8 raw bytes (16 hex chars) in “network order” (left‑to‑right): + +- Take the peer ID hex string, lowercase/truncate to at most 16 hex chars. +- Convert each 2 hex chars to 1 byte from left to right. +- If fewer than 16 hex chars are available, pad the remaining bytes with `0x00` at the end to reach 8 bytes. + +This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in the BitChat packet header. + +## Sender Behavior + +- Build the base announcement payload by emitting TLVs `0x01`..`0x03` as usual. +- Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs. + - Remove duplicates before encoding. + - Order is arbitrary and not semantically significant. +- Sign the ANNOUNCE packet so the gossip TLV is covered (recommended): + - Signature algorithm: Ed25519 using the key in TLV `0x03`. + - Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature. +- The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression. + +## Receiver Behavior + +- Decompress payload if the packet’s compression flag is set, then parse TLVs in order. +- Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs. +- If a `0x04` TLV is present: + - Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes). + - Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase). + - De‑duplicate neighbors. +- Topology maintenance guidance (optional, but recommended for consistent behavior): + - Maintain, for each announcing peer A, the last announcement timestamp and the neighbor list from TLV `0x04`. + - When a newer announcement from A arrives (use the 8‑byte unsigned `timestamp` in the BitChat packet header), replace A’s previously recorded neighbor list with the new one. If older or equal, ignore the neighbor update. + - Treat the neighbor list as a set of undirected edges `{A, B}` in your topology visualization; i.e., if A reports direct peers `[B, C]`, add edges A–B and A–C. + +## Limits and Compatibility + +- Max neighbors per TLV: 10. Senders MAY send fewer; receivers MUST accept any number `N ≥ 0` implied by `length / 8` up to the received `length`. +- Omission: If the TLV `0x04` is omitted, the announce remains valid. Peers can still chat and interoperate normally; the topology graph will just not include edges reported by that peer (other peers that include A in their neighbor lists can still introduce edges to A). +- Unknown TLVs MUST be ignored. This makes the extension safe for older implementations. + +## Minimal Example (conceptual) + +ANNOUNCE payload TLVs (concatenated): + +- `01 [len=N] [UTF‑8 nickname]` +- `02 [len=32] [32 bytes X25519 pubkey]` +- `03 [len=32] [32 bytes Ed25519 pubkey]` +- `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional) + +Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above. + +That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged. + diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md new file mode 100644 index 00000000..f6d101e2 --- /dev/null +++ b/docs/SOURCE_ROUTING.md @@ -0,0 +1,78 @@ +# Source-Based Routing for BitChat Packets + +This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying. + +Status: optional and backward-compatible. + +## Layering Overview + +- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength). +- Flags: adds a new bit `HAS_ROUTE (0x08)`. +- Variable sections (when present, in order): + 1) `SenderID` (8 bytes) + 2) `RecipientID` (8 bytes) if `HAS_RECIPIENT` + 3) `Route` (if `HAS_ROUTE`): `count` (1 byte) + `count * 8` bytes hop IDs + 4) `Payload` (with optional compression preamble) + 5) `Signature` (64 bytes) if `HAS_SIGNATURE` + +Unknown flags are ignored by older implementations (they will simply not see a route and continue broadcasting as before). + +## Route Field Encoding + +- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`. +- Layout (immediately after optional `RecipientID`): + - `count`: 1 byte (0..255) + - `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes +- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the on‑wire `senderID`/`recipientID` encoding. +- Size impact: `1 + 8*N` bytes, where `N = count`. +- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it). + +## Sender Behavior + +- Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route. +- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`. +- Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`. +- Signing: The route is covered by the Ed25519 signature (recommended): + - Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol. + +## Relay Behavior + +When receiving a packet that is not addressed to you: + +1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies). +2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list: + - If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it. + - If successful, do NOT broadcast this packet further. + - If not directly connected (or the send fails), fall back to broadcast relaying. + - If you are the last hop (no `i+1`), proceed with standard handling (e.g., if not addressed to you, do not relay further). + +TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay. + +## Receiver Behavior (Destination) + +- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.). +- Signature verification MUST include the route field when present; route tampering will invalidate the signature. + +## Compatibility + +- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`. +- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol. + +## Minimal Example (conceptual) + +- Header (fixed 13 bytes): unchanged. +- Variable sections (ordered): + - `SenderID(8)` + - `RecipientID(8)` (if present) + - `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes + - Payload (optionally compressed) + - Signature (64) + +Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set. + +## Operational Notes + +- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed. +- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop. +- Implementations may introduce policy controls (e.g., disable source routing, cap max route length). +