mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
Nip17 dms to extend the mesh (#313)
* favorite each other * show favorites as system messsages * wip show glove when offline but mayb edoesnt work * send and receive works * wip kinda works but no * getting there * kinda * show offline peers in peer list * kinda wonky but almost works * fixes * nostr user goes offline works * nostr -> mesh works * handoff works * background message processing * read works * seen message store was missing
This commit is contained in:
@@ -13,5 +13,15 @@ class BitchatApplication : Application() {
|
||||
|
||||
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||
RelayDirectory.initialize(this)
|
||||
|
||||
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Warm up Nostr identity to ensure npub is available for favorite notifications
|
||||
try {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ data class FavoriteRelationship(
|
||||
}
|
||||
}
|
||||
|
||||
interface FavoritesChangeListener {
|
||||
fun onFavoriteChanged(noiseKeyHex: String)
|
||||
fun onAllCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages favorites with Noise↔Nostr mapping
|
||||
* Singleton pattern matching iOS implementation
|
||||
@@ -77,6 +82,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
private val stateManager = SecureIdentityStateManager(context)
|
||||
private val gson = Gson()
|
||||
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseKeyHex -> relationship
|
||||
private val listeners = mutableListOf<FavoritesChangeListener>()
|
||||
|
||||
init {
|
||||
loadFavorites()
|
||||
@@ -133,6 +139,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
}
|
||||
|
||||
saveFavorites()
|
||||
notifyChanged(keyHex)
|
||||
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
||||
}
|
||||
|
||||
@@ -164,7 +171,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
|
||||
favorites[keyHex] = updated
|
||||
saveFavorites()
|
||||
|
||||
notifyChanged(keyHex)
|
||||
|
||||
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
||||
}
|
||||
|
||||
@@ -182,6 +190,7 @@ 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")
|
||||
}
|
||||
@@ -208,13 +217,19 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
favorites.clear()
|
||||
saveFavorites()
|
||||
Log.i(TAG, "Cleared all favorites")
|
||||
notifyAllCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Noise key by Nostr pubkey
|
||||
*/
|
||||
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||
return favorites.values.firstOrNull { it.peerNostrPublicKey == forNostrPubkey }?.peerNoisePublicKey
|
||||
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
||||
return favorites.values.firstOrNull { rel ->
|
||||
rel.peerNostrPublicKey?.let { stored ->
|
||||
normalizeNostrKeyToHex(stored)
|
||||
} == targetHex
|
||||
}?.peerNoisePublicKey
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,6 +280,40 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Listeners
|
||||
fun addListener(listener: FavoritesChangeListener) {
|
||||
synchronized(listeners) {
|
||||
if (!listeners.contains(listener)) listeners.add(listener)
|
||||
}
|
||||
}
|
||||
fun removeListener(listener: FavoritesChangeListener) {
|
||||
synchronized(listeners) { listeners.remove(listener) }
|
||||
}
|
||||
private fun notifyChanged(noiseKeyHex: String) {
|
||||
val snapshot = synchronized(listeners) { listeners.toList() }
|
||||
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
|
||||
}
|
||||
private fun notifyAllCleared() {
|
||||
val snapshot = synchronized(listeners) { listeners.toList() }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,15 @@ class MessageHandler(private val myPeerID: String) {
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
|
||||
if (privateMessage != null) {
|
||||
Log.d(TAG, "🔓 Decrypted TLV PM from $peerID: ${privateMessage.content.take(30)}...")
|
||||
|
||||
// Handle favorite/unfavorite notifications embedded as PMs
|
||||
val pmContent = privateMessage.content
|
||||
if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) {
|
||||
handleFavoriteNotificationFromMesh(pmContent, peerID)
|
||||
// Acknowledge delivery for UX parity
|
||||
sendDeliveryAck(privateMessage.messageID, peerID)
|
||||
return
|
||||
}
|
||||
|
||||
// Create BitchatMessage - use local system time for incoming messages
|
||||
val message = BitchatMessage(
|
||||
@@ -426,6 +435,52 @@ class MessageHandler(private val myPeerID: String) {
|
||||
fun shutdown() {
|
||||
handlerScope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle favorite/unfavorite notification received over mesh as a private message.
|
||||
* Content format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..."
|
||||
*/
|
||||
private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) {
|
||||
try {
|
||||
val isFavorite = content.startsWith("[FAVORITED]")
|
||||
val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") }
|
||||
|
||||
// Update mutual favorite status in persistence
|
||||
// Resolve full Noise key if available via delegate peer info
|
||||
val peerInfo = delegate?.getPeerInfo(fromPeerID)
|
||||
val noiseKey = peerInfo?.noisePublicKey
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
||||
if (npub != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
|
||||
}
|
||||
|
||||
// Determine iOS-style guidance text
|
||||
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
val guidance = 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."
|
||||
}
|
||||
|
||||
// Emit system message via delegate callback
|
||||
val action = if (isFavorite) "favorited" else "unfavorited"
|
||||
val sys = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = "${peerInfo.nickname} $action you$guidance",
|
||||
timestamp = java.util.Date(),
|
||||
isRelay = false
|
||||
)
|
||||
delegate?.onMessageReceived(sys)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort; ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,8 @@ 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.*
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ class NostrGeohashService(
|
||||
private val processedNostrEvents = mutableSetOf<String>()
|
||||
private val processedNostrEventOrder = mutableListOf<String>()
|
||||
private val maxProcessedNostrEvents = 2000
|
||||
private val processedNostrAcks = mutableSetOf<String>()
|
||||
// removed unused processedNostrAcks
|
||||
private val nostrKeyMapping = mutableMapOf<String, String>() // senderPeerID -> nostrPubkey
|
||||
|
||||
// MARK: - Geohash Participant Tracking Properties
|
||||
@@ -221,9 +223,11 @@ class NostrGeohashService(
|
||||
* Handle incoming Nostr message (gift wrap)
|
||||
*/
|
||||
private fun handleNostrMessage(giftWrap: NostrEvent) {
|
||||
// Simple deduplication
|
||||
if (processedNostrEvents.contains(giftWrap.id)) return
|
||||
processedNostrEvents.add(giftWrap.id)
|
||||
// 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)
|
||||
@@ -235,16 +239,18 @@ class NostrGeohashService(
|
||||
// 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
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...")
|
||||
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
|
||||
}
|
||||
val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||
if (currentIdentity == null) {
|
||||
Log.w(TAG, "No Nostr identity available for decryption")
|
||||
return@launch
|
||||
}
|
||||
|
||||
try {
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(
|
||||
@@ -254,7 +260,7 @@ class NostrGeohashService(
|
||||
|
||||
if (decryptResult == null) {
|
||||
Log.w(TAG, "Failed to decrypt Nostr message")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||
@@ -262,26 +268,26 @@ class NostrGeohashService(
|
||||
// Expect embedded BitChat packet content
|
||||
if (!content.startsWith("bitchat1:")) {
|
||||
Log.d(TAG, "Ignoring non-embedded Nostr DM content")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content)
|
||||
if (packetData == null) {
|
||||
Log.e(TAG, "Failed to decode base64url BitChat packet")
|
||||
return
|
||||
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
|
||||
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
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Validate recipient if present
|
||||
@@ -295,24 +301,50 @@ class NostrGeohashService(
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload)
|
||||
if (noisePayload == null) {
|
||||
Log.e(TAG, "Failed to parse embedded NoisePayload")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Map sender by Nostr pubkey to Noise key when possible
|
||||
val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey)
|
||||
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||
val senderNickname = if (senderNoiseKey != null) {
|
||||
// Get nickname from favorites
|
||||
getFavoriteNickname(senderNoiseKey) ?: "Unknown"
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
val targetPeerID = senderNoiseKey?.let {
|
||||
it.joinToString("") { byte -> "%02x".format(byte) }
|
||||
} ?: "nostr_${senderPubkey.take(16)}"
|
||||
|
||||
// Store Nostr key mapping
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
@@ -323,17 +355,19 @@ class NostrGeohashService(
|
||||
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
|
||||
pm?.let { pmsg ->
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
// Prefer mapped peer route; fallback to direct Nostr using sender pubkey
|
||||
if (senderNoiseKey != null) {
|
||||
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
|
||||
} else {
|
||||
// Fallback: direct to sender’s Nostr pubkey (geohash-style)
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||
if (identity != null) {
|
||||
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,12 +375,13 @@ class NostrGeohashService(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing Nostr message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process NoisePayload from Nostr message
|
||||
*/
|
||||
private fun processNoisePayload(
|
||||
private suspend fun processNoisePayload(
|
||||
noisePayload: com.bitchat.android.model.NoisePayload,
|
||||
targetPeerID: String,
|
||||
senderNickname: String,
|
||||
@@ -362,6 +397,8 @@ class NostrGeohashService(
|
||||
|
||||
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]")) {
|
||||
@@ -399,13 +436,18 @@ class NostrGeohashService(
|
||||
)
|
||||
)
|
||||
|
||||
// Add to private chats
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
|
||||
// Send read receipt if viewing
|
||||
if (isViewingThisChat) {
|
||||
// Note: meshService needs to be passed as parameter
|
||||
// privateChatManager.sendReadReceiptsForPeer(targetPeerID, meshService)
|
||||
// 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")
|
||||
@@ -413,15 +455,19 @@ class NostrGeohashService(
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(noisePayload.data, Charsets.UTF_8)
|
||||
// Use the existing delegate to handle delivery acknowledgment
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID)
|
||||
// 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
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -440,6 +486,28 @@ class NostrGeohashService(
|
||||
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<String> = 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<String>) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle favorite/unfavorite notification
|
||||
@@ -447,16 +515,72 @@ class NostrGeohashService(
|
||||
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",
|
||||
content = "$senderNickname $action you$guidance",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
|
||||
|
||||
Log.i(TAG, "📥 Processed favorite notification: $senderNickname $action you")
|
||||
}
|
||||
|
||||
@@ -953,25 +1077,26 @@ class NostrGeohashService(
|
||||
* 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 - eventGeohash: $geohash, currentGeohash: $currentGeohash, eventKind: ${event.kind}, eventId: ${event.id.take(8)}...")
|
||||
|
||||
// Only handle ephemeral kind 20000 events
|
||||
if (event.kind != 20000) {
|
||||
Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 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
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Deduplicate events
|
||||
if (processedNostrEvents.contains(event.id)) {
|
||||
Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
processedNostrEvents.add(event.id)
|
||||
|
||||
@@ -984,7 +1109,7 @@ class NostrGeohashService(
|
||||
|
||||
// STEP 1: Always update participant activity for all geohashes (for location channel list)
|
||||
val timestamp = Date(event.createdAt * 1000L)
|
||||
updateGeohashParticipant(geohash, event.pubkey, timestamp)
|
||||
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 ->
|
||||
@@ -996,7 +1121,7 @@ class NostrGeohashService(
|
||||
|
||||
// If this is a new nickname or nickname change for current geohash, refresh people list
|
||||
if (previousNick != nick && currentGeohash == geohash) {
|
||||
refreshGeohashPeople()
|
||||
withContext(Dispatchers.Main) { refreshGeohashPeople() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,7 +1131,7 @@ class NostrGeohashService(
|
||||
val currentTeleported = state.getTeleportedGeoValue().toMutableSet()
|
||||
if (!currentTeleported.contains(key)) {
|
||||
currentTeleported.add(key)
|
||||
state.setTeleportedGeo(currentTeleported)
|
||||
withContext(Dispatchers.Main) { state.setTeleportedGeo(currentTeleported) }
|
||||
Log.d(TAG, "📍 Marked geohash participant as teleported: ${event.pubkey.take(8)}...")
|
||||
}
|
||||
}
|
||||
@@ -1017,7 +1142,7 @@ class NostrGeohashService(
|
||||
context = application
|
||||
)
|
||||
if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) {
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// STEP 5: Store mapping for potential geohash DM initiation
|
||||
@@ -1032,7 +1157,7 @@ class NostrGeohashService(
|
||||
event.content.trim().isEmpty()
|
||||
if (isTeleportPresence) {
|
||||
Log.v(TAG, "Skipping empty teleport presence event")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
@@ -1065,7 +1190,7 @@ class NostrGeohashService(
|
||||
selectedLocationChannel.channel.geohash == geohash)
|
||||
|
||||
if (shouldShowMessage) {
|
||||
messageManager.addMessage(message)
|
||||
withContext(Dispatchers.Main) { messageManager.addMessage(message) }
|
||||
}
|
||||
|
||||
// NOTIFICATION LOGIC: Check for mentions and first messages
|
||||
@@ -1076,12 +1201,13 @@ class NostrGeohashService(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling unified geohash event: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and trigger geohash notifications for mentions and first messages
|
||||
*/
|
||||
private fun checkAndTriggerGeohashNotifications(
|
||||
private suspend fun checkAndTriggerGeohashNotifications(
|
||||
geohash: String,
|
||||
senderName: String,
|
||||
content: String,
|
||||
@@ -1104,13 +1230,15 @@ class NostrGeohashService(
|
||||
if (isMention || isFirstMessage) {
|
||||
Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage")
|
||||
|
||||
notificationManager.showGeohashNotification(
|
||||
geohash = geohash,
|
||||
senderNickname = senderName,
|
||||
messageContent = content,
|
||||
isMention = isMention,
|
||||
isFirstMessage = isFirstMessage
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
notificationManager.showGeohashNotification(
|
||||
geohash = geohash,
|
||||
senderNickname = senderName,
|
||||
messageContent = content,
|
||||
isMention = isMention,
|
||||
isFirstMessage = isFirstMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking geohash notifications: ${e.message}")
|
||||
@@ -1184,10 +1312,13 @@ class NostrGeohashService(
|
||||
geohash: String,
|
||||
identity: NostrIdentity
|
||||
) {
|
||||
coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) {
|
||||
try {
|
||||
// Deduplicate
|
||||
if (processedNostrEvents.contains(giftWrap.id)) return
|
||||
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(
|
||||
@@ -1197,28 +1328,28 @@ class NostrGeohashService(
|
||||
|
||||
if (decryptResult == null) {
|
||||
Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||
|
||||
// Only process BitChat embedded messages
|
||||
if (!content.startsWith("bitchat1:")) return
|
||||
if (!content.startsWith("bitchat1:")) return@launch
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content) ?: return
|
||||
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return
|
||||
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
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return
|
||||
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
|
||||
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)}...")
|
||||
@@ -1232,7 +1363,7 @@ class NostrGeohashService(
|
||||
break
|
||||
}
|
||||
}
|
||||
if (messageExists) return
|
||||
if (messageExists) return@launch
|
||||
|
||||
val senderName = displayNameForNostrPubkey(senderPubkey)
|
||||
val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey
|
||||
@@ -1252,17 +1383,23 @@ class NostrGeohashService(
|
||||
)
|
||||
)
|
||||
|
||||
// Add to private chats
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
// 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))
|
||||
|
||||
// Always send delivery ACK for geohash DMs
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
|
||||
// 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) {
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1280,6 +1417,7 @@ class NostrGeohashService(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling geohash DM event: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,7 @@ object NostrProtocol {
|
||||
return null
|
||||
}
|
||||
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap")
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
|
||||
|
||||
// 2. Open the seal
|
||||
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
|
||||
|
||||
@@ -479,28 +479,11 @@ class NostrTransport(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array (8 bytes)
|
||||
* Convert full hex string to byte array
|
||||
*/
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
if (hexString.length % 2 != 0) {
|
||||
return ByteArray(8) // Return 8-byte array filled with zeros
|
||||
}
|
||||
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
|
||||
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.ui.ChatState
|
||||
|
||||
object ConversationAliasResolver {
|
||||
|
||||
fun resolveCanonicalPeerID(
|
||||
selectedPeerID: String,
|
||||
connectedPeers: List<String>,
|
||||
meshNoiseKeyForPeer: (String) -> ByteArray?,
|
||||
meshHasPeer: (String) -> Boolean,
|
||||
nostrPubHexForAlias: (String) -> String?,
|
||||
findNoiseKeyForNostr: (String) -> ByteArray?
|
||||
): String {
|
||||
var peer = selectedPeerID
|
||||
try {
|
||||
if (peer.startsWith("nostr_")) {
|
||||
val pubHex = nostrPubHexForAlias(peer)
|
||||
if (pubHex != null) {
|
||||
val noiseKey = findNoiseKeyForNostr(pubHex)
|
||||
if (noiseKey != null) {
|
||||
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
// Prefer a connected mesh peer that matches this noise key
|
||||
val meshPeer = connectedPeers.firstOrNull { pid ->
|
||||
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
|
||||
}
|
||||
peer = meshPeer ?: noiseHex
|
||||
}
|
||||
}
|
||||
} else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
// Peer is full noise key hex: upgrade to active mesh peer if available
|
||||
val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val meshPeer = connectedPeers.firstOrNull { pid ->
|
||||
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
|
||||
}
|
||||
if (meshPeer != null) {
|
||||
peer = meshPeer
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { /* no-op */ }
|
||||
return peer
|
||||
}
|
||||
|
||||
fun unifyChatsIntoPeer(
|
||||
state: ChatState,
|
||||
targetPeerID: String,
|
||||
keysToMerge: List<String>
|
||||
) {
|
||||
if (keysToMerge.isEmpty()) return
|
||||
val currentChats = state.getPrivateChatsValue().toMutableMap()
|
||||
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
|
||||
var didMerge = false
|
||||
keysToMerge.distinct().forEach { key ->
|
||||
if (key == targetPeerID) return@forEach
|
||||
val list = currentChats[key]
|
||||
if (!list.isNullOrEmpty()) {
|
||||
targetList.addAll(list)
|
||||
currentChats.remove(key)
|
||||
didMerge = true
|
||||
}
|
||||
}
|
||||
if (didMerge) {
|
||||
targetList.sortBy { it.timestamp }
|
||||
currentChats[targetPeerID] = targetList
|
||||
state.setPrivateChats(currentChats)
|
||||
|
||||
// Move unread flags
|
||||
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
var hadUnread = false
|
||||
keysToMerge.forEach { key -> if (unread.remove(key)) hadUnread = true }
|
||||
if (hadUnread) unread.add(targetPeerID)
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
|
||||
// Switch selection if currently viewing an alias that got merged
|
||||
val selected = state.getSelectedPrivateChatPeerValue()
|
||||
if (selected != null && keysToMerge.contains(selected)) {
|
||||
state.setSelectedPrivateChatPeer(targetPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.nostr.NostrTransport
|
||||
|
||||
/**
|
||||
* Routes messages between BLE mesh and Nostr transports, matching iOS behavior.
|
||||
*/
|
||||
class MessageRouter private constructor(
|
||||
private val context: Context,
|
||||
private val mesh: BluetoothMeshService,
|
||||
private val nostr: NostrTransport
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "MessageRouter"
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
INSTANCE?.also {
|
||||
// Update mesh reference if needed and keep senderPeerID in sync
|
||||
it.nostr.senderPeerID = mesh.myPeerID
|
||||
return it
|
||||
}
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
} catch (_: Exception) {}
|
||||
INSTANCE = instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outbox: peerID -> queued (content, nickname, messageID)
|
||||
private val outbox = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
|
||||
// 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
|
||||
val shortId = noiseKeyHex.take(16)
|
||||
flushOutboxFor(shortId)
|
||||
}
|
||||
override fun onAllCleared() {
|
||||
// Nothing special; leave queued items until routing becomes possible
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
|
||||
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)}…")
|
||||
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)}…")
|
||||
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)}…")
|
||||
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
||||
q.add(Triple(content, recipientNickname, messageID))
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
|
||||
if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) {
|
||||
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
|
||||
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
|
||||
} else {
|
||||
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
|
||||
nostr.sendReadReceipt(receipt, toPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
|
||||
nostr.sendDeliveryAck(messageID, toPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
|
||||
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
|
||||
val myNpub = try {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
|
||||
} catch (_: Exception) { null }
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
|
||||
mesh.sendPrivateMessage(content, toPeerID, nickname)
|
||||
} else {
|
||||
nostr.sendFavoriteNotification(toPeerID, isFavorite)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any queued messages for a specific peerID
|
||||
fun flushOutboxFor(peerID: String) {
|
||||
val queued = outbox[peerID] ?: return
|
||||
if (queued.isEmpty()) return
|
||||
Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}")
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (content, nickname, messageID) = iterator.next()
|
||||
var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
|
||||
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
|
||||
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val meshPeer = resolveMeshPeerForNoiseHex(peerID)
|
||||
if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) {
|
||||
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
|
||||
iterator.remove()
|
||||
continue
|
||||
}
|
||||
}
|
||||
val canNostr = canSendViaNostr(peerID)
|
||||
if (hasMesh) {
|
||||
mesh.sendPrivateMessage(content, peerID, nickname, messageID)
|
||||
iterator.remove()
|
||||
} else if (canNostr) {
|
||||
nostr.sendPrivateMessage(content, peerID, nickname, messageID)
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
if (queued.isEmpty()) {
|
||||
outbox.remove(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush everything (rarely used)
|
||||
fun flushAllOutbox() {
|
||||
outbox.keys.toList().forEach { flushOutboxFor(it) }
|
||||
}
|
||||
|
||||
private fun canSendViaNostr(peerID: String): Boolean {
|
||||
return try {
|
||||
// Full Noise key hex
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKey = hexToBytes(peerID)
|
||||
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
fav?.isMutual == true && fav.peerNostrPublicKey != null
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
// Ephemeral 16-hex mesh ID: resolve via prefix match in favorites
|
||||
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
fav?.isMutual == true && fav.peerNostrPublicKey != null
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val clean = if (hex.length % 2 == 0) hex else "0$hex"
|
||||
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? {
|
||||
return try {
|
||||
mesh.getPeerNicknames().keys.firstOrNull { pid ->
|
||||
val info = mesh.getPeerInfo(pid)
|
||||
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// Called when mesh peer list changes; attempt to flush any matching outbox entries
|
||||
fun onPeersUpdated(peers: List<String>) {
|
||||
peers.forEach { pid ->
|
||||
flushOutboxFor(pid)
|
||||
val noiseHex = try {
|
||||
mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let { flushOutboxFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
|
||||
fun onSessionEstablished(peerID: String) {
|
||||
flushOutboxFor(peerID)
|
||||
val noiseHex = try {
|
||||
mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let { flushOutboxFor(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.google.gson.Gson
|
||||
|
||||
/**
|
||||
* Persistent store for message IDs we've already acknowledged (DELIVERED) or READ.
|
||||
* Limits to last MAX_IDS entries per set to avoid memory bloat.
|
||||
*/
|
||||
class SeenMessageStore private constructor(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "SeenMessageStore"
|
||||
private const val STORAGE_KEY = "seen_message_store_v1"
|
||||
private const val MAX_IDS = 10_000
|
||||
|
||||
@Volatile private var INSTANCE: SeenMessageStore? = null
|
||||
fun getInstance(appContext: Context): SeenMessageStore {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: SeenMessageStore(appContext.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val secure = SecureIdentityStateManager(context)
|
||||
|
||||
private val delivered = LinkedHashSet<String>(MAX_IDS)
|
||||
private val read = LinkedHashSet<String>(MAX_IDS)
|
||||
|
||||
init { load() }
|
||||
|
||||
@Synchronized fun hasDelivered(id: String) = delivered.contains(id)
|
||||
@Synchronized fun hasRead(id: String) = read.contains(id)
|
||||
|
||||
@Synchronized fun markDelivered(id: String) {
|
||||
if (delivered.remove(id)) delivered.add(id) else {
|
||||
delivered.add(id)
|
||||
trim(delivered)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun markRead(id: String) {
|
||||
if (read.remove(id)) read.add(id) else {
|
||||
read.add(id)
|
||||
trim(read)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun trim(set: LinkedHashSet<String>) {
|
||||
if (set.size <= MAX_IDS) return
|
||||
val it = set.iterator()
|
||||
while (set.size > MAX_IDS && it.hasNext()) {
|
||||
it.next(); it.remove()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized private fun load() {
|
||||
try {
|
||||
val json = secure.getSecureValue(STORAGE_KEY) ?: return
|
||||
val data = gson.fromJson(json, StorePayload::class.java) ?: return
|
||||
delivered.clear(); read.clear()
|
||||
data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) }
|
||||
data.read.takeLast(MAX_IDS).forEach { read.add(it) }
|
||||
Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized private fun persist() {
|
||||
try {
|
||||
val payload = StorePayload(delivered.toList(), read.toList())
|
||||
val json = gson.toJson(payload)
|
||||
secure.storeSecureValue(STORAGE_KEY, json)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private data class StorePayload(
|
||||
val delivered: List<String> = emptyList(),
|
||||
val read: List<String> = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -309,6 +309,18 @@ private fun PrivateChatHeader(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
||||
val isMutualFavorite = remember(peerID, peerNicknames) {
|
||||
try {
|
||||
if (isNostrDM) return@remember false
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
|
||||
} else false
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
||||
val titleText: String = if (isNostrDM) {
|
||||
@@ -319,7 +331,18 @@ private fun PrivateChatHeader(
|
||||
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
|
||||
"$geoPart/@$baseName"
|
||||
} else {
|
||||
peerNicknames[peerID] ?: peerID
|
||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||
peerNicknames[peerID] ?: run {
|
||||
val titleFromFavorites = try {
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
titleFromFavorites ?: peerID.take(12)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -367,8 +390,16 @@ private fun PrivateChatHeader(
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// For NIP-17 chats, do not show Noise session state icon (remove warning icon)
|
||||
if (!isNostrDM) {
|
||||
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
|
||||
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
||||
if (showGlobe) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Public,
|
||||
contentDescription = "Nostr reachable",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9B59B6) // Purple like iOS
|
||||
)
|
||||
} else {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
|
||||
@@ -152,6 +152,12 @@ class ChatViewModel(
|
||||
|
||||
// Initialize Nostr integration
|
||||
nostrGeohashService.initializeNostrIntegration()
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Note: Mesh service is now started by MainActivity
|
||||
|
||||
@@ -207,6 +213,17 @@ class ChatViewModel(
|
||||
setCurrentPrivateChatPeer(peerID)
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
clearNotificationsForSender(peerID)
|
||||
|
||||
// Persistently mark all messages in this conversation as read so Nostr fetches
|
||||
// after app restarts won't re-mark them as unread.
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val chats = state.getPrivateChatsValue()
|
||||
val messages = chats[peerID] ?: emptyList()
|
||||
messages.forEach { msg ->
|
||||
try { seen.markRead(msg.id) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,10 +252,23 @@ class ChatViewModel(
|
||||
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
|
||||
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
|
||||
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
var selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
val currentChannelValue = state.getCurrentChannelValue()
|
||||
|
||||
if (selectedPeer != null) {
|
||||
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
|
||||
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = selectedPeer,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
privateChatManager.startPrivateChat(canonical, meshService)
|
||||
}
|
||||
}
|
||||
// Send private message
|
||||
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
||||
privateChatManager.sendPrivateMessage(
|
||||
@@ -248,7 +278,9 @@ class ChatViewModel(
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID
|
||||
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
||||
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
// Route via MessageRouter (mesh when connected+established, else Nostr)
|
||||
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
|
||||
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
}
|
||||
} else {
|
||||
// Check if we're in a location channel
|
||||
@@ -309,7 +341,44 @@ class ChatViewModel(
|
||||
fun toggleFavorite(peerID: String) {
|
||||
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
|
||||
privateChatManager.toggleFavorite(peerID)
|
||||
|
||||
|
||||
// Persist relationship in FavoritesPersistenceService when we have Noise key
|
||||
try {
|
||||
val peerInfo = meshService.getPeerInfo(peerID)
|
||||
val noiseKey = peerInfo?.noisePublicKey
|
||||
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
|
||||
if (noiseKey != null) {
|
||||
val isNowFavorite = dataManager.favoritePeers.contains(
|
||||
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
|
||||
)
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
|
||||
noisePublicKey = noiseKey,
|
||||
nickname = nickname,
|
||||
isFavorite = isNowFavorite
|
||||
)
|
||||
|
||||
// Send favorite notification via mesh or Nostr with our npub if available
|
||||
try {
|
||||
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
|
||||
// Prefer mesh if session established, else try Nostr
|
||||
if (meshService.hasEstablishedSession(peerID)) {
|
||||
// Reuse existing private message path for notifications
|
||||
meshService.sendPrivateMessage(
|
||||
announcementContent,
|
||||
peerID,
|
||||
nickname,
|
||||
java.util.UUID.randomUUID().toString()
|
||||
)
|
||||
} else {
|
||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendFavoriteNotification(peerID, isNowFavorite)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Log current state after toggle
|
||||
logCurrentFavoriteState()
|
||||
}
|
||||
@@ -341,10 +410,20 @@ class ChatViewModel(
|
||||
val currentPeers = state.getConnectedPeersValue()
|
||||
|
||||
// Update session states
|
||||
val prevStates = state.getPeerSessionStatesValue()
|
||||
val sessionStates = currentPeers.associateWith { peerID ->
|
||||
meshService.getSessionState(peerID).toString()
|
||||
}
|
||||
state.setPeerSessionStates(sessionStates)
|
||||
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
|
||||
sessionStates.forEach { (peerID, newState) ->
|
||||
val old = prevStates[peerID]
|
||||
if (old != "established" && newState == "established") {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(getApplication(), meshService)
|
||||
.onSessionEstablished(peerID)
|
||||
}
|
||||
}
|
||||
// Update fingerprint mappings from centralized manager
|
||||
val fingerprints = privateChatManager.getAllPeerFingerprints()
|
||||
state.setPeerFingerprints(fingerprints)
|
||||
|
||||
@@ -86,18 +86,100 @@ class MeshDelegateHandler(
|
||||
coroutineScope.launch {
|
||||
state.setConnectedPeers(peers)
|
||||
state.setIsConnected(peers.isNotEmpty())
|
||||
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
|
||||
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
|
||||
|
||||
// Clean up channel members who disconnected
|
||||
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
|
||||
|
||||
// Exit private chat if peer disconnected
|
||||
|
||||
// Handle chat view migration based on current selection and new peer list
|
||||
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
|
||||
if (!peers.contains(currentPeer)) {
|
||||
privateChatManager.cleanupDisconnectedPeer(currentPeer)
|
||||
val isNostrAlias = currentPeer.startsWith("nostr_")
|
||||
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
|
||||
if (isNostrAlias || isNoiseHex) {
|
||||
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
|
||||
// Resolve canonical target (prefer connected mesh peer if available)
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = currentPeer,
|
||||
connectedPeers = peers,
|
||||
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
|
||||
}
|
||||
}
|
||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||
},
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
if (canonical != currentPeer) {
|
||||
// Merge conversations and switch selection to the live mesh peer (or noiseHex)
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
|
||||
state.setSelectedPrivateChatPeer(canonical)
|
||||
}
|
||||
} else if (isMeshEphemeral && !peers.contains(currentPeer)) {
|
||||
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
|
||||
val favoriteRel = try {
|
||||
val info = getPeerInfo(currentPeer)
|
||||
val noiseKey = info?.noisePublicKey
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
if (favoriteRel?.isMutual == true) {
|
||||
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
if (noiseHex != currentPeer) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
|
||||
state = state,
|
||||
targetPeerID = noiseHex,
|
||||
keysToMerge = listOf(currentPeer)
|
||||
)
|
||||
state.setSelectedPrivateChatPeer(noiseHex)
|
||||
}
|
||||
} else {
|
||||
privateChatManager.cleanupDisconnectedPeer(currentPeer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global unification: for each connected peer, merge any offline/stable conversations
|
||||
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
|
||||
peers.forEach { pid ->
|
||||
try {
|
||||
val info = getPeerInfo(pid)
|
||||
val noiseKey = info?.noisePublicKey ?: return@forEach
|
||||
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
// Derive temp nostr key from favorites npub
|
||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
||||
val tempNostrKey: String? = try {
|
||||
if (npub != null) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge any chats stored under the given keys into the connected peer's chat entry.
|
||||
*/
|
||||
private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge)
|
||||
}
|
||||
|
||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||
coroutineScope.launch {
|
||||
@@ -191,4 +273,11 @@ class MeshDelegateHandler(
|
||||
}
|
||||
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
/**
|
||||
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
|
||||
*/
|
||||
fun getPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? {
|
||||
return getMeshService().getPeerInfo(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class MessageManager(private val state: ChatState) {
|
||||
}
|
||||
|
||||
// MARK: - Private Message Management
|
||||
|
||||
|
||||
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
|
||||
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
if (!currentPrivateChats.containsKey(peerID)) {
|
||||
@@ -94,6 +94,19 @@ class MessageManager(private val state: ChatState) {
|
||||
state.setUnreadPrivateMessages(currentUnread)
|
||||
}
|
||||
}
|
||||
|
||||
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
|
||||
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
|
||||
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
if (!currentPrivateChats.containsKey(peerID)) {
|
||||
currentPrivateChats[peerID] = mutableListOf()
|
||||
}
|
||||
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
||||
chatMessages.add(message)
|
||||
chatMessages.sortBy { it.timestamp }
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
}
|
||||
|
||||
fun clearPrivateMessages(peerID: String) {
|
||||
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
||||
|
||||
@@ -56,6 +56,11 @@ class PrivateChatManager(
|
||||
// Establish Noise session if needed before starting the chat
|
||||
establishNoiseSessionIfNeeded(peerID, meshService)
|
||||
|
||||
// Consolidate any temporary Nostr conversation for this peer into the stable/current peerID
|
||||
try {
|
||||
consolidateNostrTempConversationIfNeeded(peerID)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
state.setSelectedPrivateChatPeer(peerID)
|
||||
|
||||
// Clear unread
|
||||
@@ -261,18 +266,30 @@ class PrivateChatManager(
|
||||
// MARK: - Message Handling
|
||||
|
||||
fun handleIncomingPrivateMessage(message: BitchatMessage) {
|
||||
handleIncomingPrivateMessage(message, suppressUnread = false)
|
||||
}
|
||||
|
||||
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
if (!isPeerBlocked(senderPeerID)) {
|
||||
// Add to private messages
|
||||
messageManager.addPrivateMessage(senderPeerID, message)
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(senderPeerID, message)
|
||||
}
|
||||
|
||||
// Track as unread for read receipt purposes
|
||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||
unreadList.add(message)
|
||||
var unreadCount = 0
|
||||
if (!suppressUnread) {
|
||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||
unreadList.add(message)
|
||||
unreadCount = unreadList.size
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Added received message ${message.id} from $senderPeerID to unread list (${unreadList.size} unread)"
|
||||
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -439,6 +456,65 @@ class PrivateChatManager(
|
||||
return meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
}
|
||||
|
||||
// MARK: - Consolidation
|
||||
|
||||
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String) {
|
||||
// If target is a mesh/noise-based peerID, merge any messages from its temp Nostr key
|
||||
if (targetPeerID.startsWith("nostr_")) return
|
||||
|
||||
// Find favorites mapping and corresponding temp key
|
||||
val tryMergeKeys = mutableListOf<String>()
|
||||
|
||||
// If we know the sender's Nostr pubkey for this peer via favorites, derive temp key
|
||||
try {
|
||||
val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes)
|
||||
if (npub != null) {
|
||||
// Normalize to hex to match how we formed temp keys (nostr_<pub16>)
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||
if (hrp == "npub") {
|
||||
val pubHex = data.joinToString("") { "%02x".format(it) }
|
||||
tryMergeKeys.add("nostr_${pubHex.take(16)}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Also merge any directly-addressed temp key used by incoming messages (without mapping yet)
|
||||
// Search existing chats for keys that begin with "nostr_" and have messages from the same nickname
|
||||
state.getPrivateChatsValue().keys.filter { it.startsWith("nostr_") }.forEach { tempKey ->
|
||||
if (!tryMergeKeys.contains(tempKey)) tryMergeKeys.add(tempKey)
|
||||
}
|
||||
|
||||
if (tryMergeKeys.isEmpty()) return
|
||||
|
||||
val currentChats = state.getPrivateChatsValue().toMutableMap()
|
||||
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
|
||||
|
||||
var didMerge = false
|
||||
tryMergeKeys.forEach { tempKey ->
|
||||
val tempList = currentChats[tempKey]
|
||||
if (!tempList.isNullOrEmpty()) {
|
||||
targetList.addAll(tempList)
|
||||
currentChats.remove(tempKey)
|
||||
didMerge = true
|
||||
}
|
||||
}
|
||||
|
||||
if (didMerge) {
|
||||
targetList.sortBy { it.timestamp }
|
||||
currentChats[targetPeerID] = targetList
|
||||
state.setPrivateChats(currentChats)
|
||||
|
||||
// Also remove unread flag from temp keys and apply to target
|
||||
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
val hadUnread = tryMergeKeys.any { unread.remove(it) }
|
||||
if (hadUnread) {
|
||||
unread.add(targetPeerID)
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
fun clearAllPrivateChats() {
|
||||
|
||||
@@ -285,56 +285,139 @@ fun PeopleSection(
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
||||
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
// Reactive favorite computation - same as ChatHeader
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
val sortedPeers = connectedPeers.sortedWith(
|
||||
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
||||
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
||||
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
|
||||
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
||||
)
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
|
||||
signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]),
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
viewModel.toggleFavorite(peerID)
|
||||
},
|
||||
unreadCount = privateChats[peerID]?.count { msg ->
|
||||
// Count unread messages from this peer (messages not from the current user)
|
||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(peerID)
|
||||
} ?: if (hasUnreadPrivateMessages.contains(peerID)) 1 else 0
|
||||
)
|
||||
}
|
||||
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
||||
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
// Reactive favorite computation - same as ChatHeader
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
val sortedPeers = connectedPeers.sortedWith(
|
||||
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
||||
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
||||
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
|
||||
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
||||
)
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
||||
|
||||
val noiseHex = noiseHexByPeerID[peerID]
|
||||
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
|
||||
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
|
||||
val combinedHasUnread = meshUnread || nostrUnread
|
||||
val combinedUnreadCount = (
|
||||
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
|
||||
) + (
|
||||
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
|
||||
)
|
||||
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))),
|
||||
signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]),
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
viewModel.toggleFavorite(peerID)
|
||||
},
|
||||
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
|
||||
showNostrGlobe = false
|
||||
)
|
||||
}
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
||||
val appendedOfflineIds = mutableSetOf<String>()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
|
||||
// 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
|
||||
PeerItem(
|
||||
peerID = favPeerID,
|
||||
displayName = peerNicknames[favPeerID] ?: fav.peerNickname,
|
||||
signalStrength = 0,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||
onToggleFavorite = {
|
||||
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,
|
||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null)
|
||||
)
|
||||
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
|
||||
val connectedIds = sortedPeers.toSet()
|
||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
(key.startsWith("nostr_") || 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) }
|
||||
}
|
||||
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
|
||||
.forEach { convKey ->
|
||||
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)),
|
||||
signalStrength = 0,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +433,8 @@ private fun PeerItem(
|
||||
viewModel: ChatViewModel,
|
||||
onItemClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
unreadCount: Int = 0
|
||||
unreadCount: Int = 0,
|
||||
showNostrGlobe: Boolean = false
|
||||
) {
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseName, suffix) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
@@ -383,10 +467,20 @@ private fun PeerItem(
|
||||
)
|
||||
} else {
|
||||
// Signal strength indicators
|
||||
SignalStrengthIndicator(
|
||||
signalStrength = signalStrength,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
if (showNostrGlobe) {
|
||||
// Purple globe to indicate Nostr availability
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = "Reachable via Nostr",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
} else {
|
||||
SignalStrengthIndicator(
|
||||
signalStrength = signalStrength,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Reference in New Issue
Block a user