feat: stabilize private chat identity across mesh and Nostr (#745)

* feat: stabilize private chat identity across mesh and Nostr

Introduce ContactDirectory and ContactIdentityResolver so DMs and favorites
use stable contact_* conversation IDs instead of ephemeral mesh peer IDs.

* fix: preserve canonical private message routing

* test: complete private chat regression fixtures
This commit is contained in:
callebtc 2026-07-26 13:06:22 +02:00 committed by GitHub
parent c8f45408b1
commit 5a38aaf3e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1183 additions and 642 deletions

View File

@ -0,0 +1,33 @@
package com.bitchat.android.favorites
import com.bitchat.android.services.ContactIdentityResolver
data class FavoriteControlMessage(
val isFavorite: Boolean,
val npub: String?
) {
companion object {
private const val FAVORITED = "[FAVORITED]"
private const val UNFAVORITED = "[UNFAVORITED]"
fun parse(content: String): FavoriteControlMessage? {
val trimmed = content.trim()
val isFavorite = when {
trimmed.startsWith(FAVORITED) -> true
trimmed.startsWith(UNFAVORITED) -> false
else -> return null
}
val encodedKey = trimmed.substringAfter(":", "").trim()
val npub = encodedKey
.takeIf { it.isNotEmpty() }
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
?.let { ContactIdentityResolver.npubFromHex(it) }
return FavoriteControlMessage(isFavorite = isFavorite, npub = npub)
}
fun encode(isFavorite: Boolean, npub: String?): String {
val prefix = if (isFavorite) FAVORITED else UNFAVORITED
return "$prefix:${npub.orEmpty()}"
}
}
}

View File

@ -2,15 +2,15 @@ package com.bitchat.android.favorites
import android.content.Context
import android.util.Log
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.*
/**
* Bridging Noise and Nostr favorites
* 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)
@ -84,7 +84,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
@ -95,35 +94,55 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
return favorites[keyHex]
}
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
if (noiseKeyHex.startsWith(pid)) return relationship
val pid = peerID.trim().lowercase()
if (ContactIdentityResolver.isNoiseKeyHex(pid)) {
return favorites[pid]
}
ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint ->
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid]?.let { indexedNpub ->
findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) }
}
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid
}
}
return null
}
/** 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 keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = nostrPubkey,
peerNostrPublicKey = normalizedNpub,
lastUpdated = Date()
)
favorites[keyHex] = updated
} else {
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = nostrPubkey,
peerNostrPublicKey = normalizedNpub,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
@ -139,11 +158,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
/** Update Nostr pubkey for a specific mesh peerID. */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.lowercase()
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
peerIdIndex[pid] = nostrPubkey
val pid = peerID.trim().lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
@ -152,33 +174,32 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
fun findNostrPubkeyForPeerID(peerID: String): String? {
return peerIdIndex[peerID.lowercase()]
val pid = peerID.trim().lowercase()
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
}
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
/** Resolve mesh peerID 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)
}
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
peerIdIndex.entries.firstOrNull { (_, stored) ->
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex
}?.let { return it.key }
favorites.values.firstOrNull { relationship ->
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex
}?.let { relationship ->
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey)
}
return null
}
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
@ -210,7 +231,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
if (existing != null) {
@ -228,6 +249,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
fun clearAllFavorites() {
favorites.clear()
@ -240,15 +262,15 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex
}?.peerNoisePublicKey
}
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
return favorites[keyHex]?.peerNostrPublicKey
}
@ -292,7 +314,12 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear()
peerIdIndex.putAll(data)
data.forEach { (peerID, npub) ->
val normalizedPeerID = peerID.lowercase()
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
peerIdIndex[normalizedPeerID] = npub
}
}
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
@ -318,6 +345,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
synchronized(listeners) { listeners.remove(listener) }
}
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
}
@ -325,14 +353,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } }
}
/** 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 for JSON storage */
@ -348,7 +368,7 @@ private data class FavoriteRelationshipData(
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData(
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey),
peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNickname = relationship.peerNickname,
isFavorite = relationship.isFavorite,
@ -360,7 +380,7 @@ private data class FavoriteRelationshipData(
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0)
return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey,

View File

@ -385,6 +385,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Store fingerprint for the peer via centralized fingerprint manager
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
try {
com.bitchat.android.identity.SecureIdentityStateManager(context)
.cachePeerNoiseKey(newPeerID, publicKey.toHexString())
} catch (_: Exception) { }
// Index existing Nostr mapping by the new peerID if we have it
try {
@ -952,9 +956,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
// FIXED: Don't send didReceiveMessage for our own sent messages
// This was causing self-notifications - iOS doesn't do this
// The UI handles showing sent messages through its own message sending logic
// The UI handles sent messages through its own sending path.
} catch (e: Exception) {
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
@ -964,8 +966,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake")
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
// FIXED: Don't send didReceiveMessage for our own sent messages
// The UI will handle showing the message in the chat interface
// The UI handles sent messages through its own sending path.
}
}
}

View File

@ -288,6 +288,13 @@ class MeshCore(
) {
peerManager.addOrUpdatePeer(newPeerID, nickname)
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
try {
com.bitchat.android.identity.SecureIdentityStateManager(context)
.cachePeerNoiseKey(newPeerID, publicKey.toHexString())
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
}
} catch (_: Exception) { }
previousPeerID?.let { peerManager.removePeer(it) }
Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)

View File

@ -1,6 +1,7 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
@ -11,7 +12,6 @@ import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
import kotlin.random.Random
/**
* Handles processing of different message types
@ -66,7 +66,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
return
}
// NEW: Use NoisePayload system exactly like iOS
val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
if (noisePayload == null) {
Log.w(TAG, "Failed to parse NoisePayload from $peerID")
@ -84,7 +83,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Handle favorite/unfavorite notifications embedded as PMs
val pmContent = privateMessage.content
if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) {
if (FavoriteControlMessage.parse(pmContent) != null) {
handleFavoriteNotificationFromMesh(pmContent, peerID)
// Acknowledge delivery for UX parity
sendDeliveryAck(privateMessage.messageID, peerID)
@ -102,7 +101,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
isPrivate = true,
recipientNickname = delegate?.getMyNickname(),
senderPeerID = peerID,
mentions = null // TODO: Parse mentions if needed
mentions = null
)
// Notify delegate
@ -549,24 +548,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
*/
private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) {
try {
val isFavorite = content.startsWith("[FAVORITED]")
val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") }
val control = FavoriteControlMessage.parse(content) ?: return
// 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) {
// 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)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
if (control.npub != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, control.npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, control.npub)
}
// Determine iOS-style guidance text
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val guidance = if (isFavorite) {
val guidance = if (control.isFavorite) {
if (rel?.isFavorite == true) {
" — mutual! You can continue DMs via Nostr when out of mesh."
} else {
@ -576,8 +570,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
". DMs over Nostr will pause unless you both favorite again."
}
// Emit system message via delegate callback
val action = if (isFavorite) "favorited" else "unfavorited"
val action = if (control.isFavorite) "favorited" else "unfavorited"
val sys = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "${peerInfo.nickname} $action you$guidance",

View File

@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
@ -91,7 +92,7 @@ class UnifiedMeshService(
} catch (_: Exception) {
null
}
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = getPeerNicknames()[peerID] ?: peerID
if (hasEstablishedSession(peerID)) {
sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())

View File

@ -2,6 +2,8 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
@ -9,10 +11,13 @@ import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
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 com.bitchat.android.ui.PrivateMessageOrigin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -99,8 +104,9 @@ class NostrDirectMessageHandler(
}
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
val conversationID = ContactDirectory.canonicalConversationId(convKey)
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
processNoisePayload(noisePayload, conversationID, senderNickname, messageTimestamp, senderPubkey, identity)
} catch (e: Exception) {
Log.e(TAG, "onGiftWrap error: ${e.message}")
@ -110,7 +116,7 @@ class NostrDirectMessageHandler(
private suspend fun processNoisePayload(
payload: NoisePayload,
convKey: String,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String,
@ -119,9 +125,20 @@ class NostrDirectMessageHandler(
when (payload.type) {
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
seenStore.markDelivered(pm.messageID)
}
return
}
val message = BitchatMessage(
id = pm.messageID,
sender = senderNickname,
@ -130,15 +147,19 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey,
senderPeerID = conversationID,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
)
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasRead(pm.messageID)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!seenStore.hasDelivered(pm.messageID)) {
@ -156,13 +177,13 @@ class NostrDirectMessageHandler(
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
}
}
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
}
}
NoisePayloadType.FILE_TRANSFER -> {
@ -180,14 +201,18 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey
senderPeerID = conversationID
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = false)
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
}
} else {
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
}
NoisePayloadType.VERIFY_CHALLENGE,
@ -195,6 +220,63 @@ class NostrDirectMessageHandler(
}
}
private suspend fun handleFavoriteControl(
control: FavoriteControlMessage,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String
) {
try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return
}
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
senderNpub?.let { FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it) }
val targetConversationID = ContactDirectory.canonicalConversationId(conversationID)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val displayName = relationship
?.peerNickname
?.takeUnless { it.equals("Unknown", ignoreCase = true) }
?: senderNickname
val guidance = if (control.isFavorite) {
if (relationship?.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."
}
val action = if (control.isFavorite) "favorited" else "unfavorited"
val systemMessage = BitchatMessage(
sender = "system",
content = "$displayName $action you$guidance",
timestamp = timestamp,
isRelay = false,
isPrivate = true,
senderPeerID = targetConversationID
)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
message = systemMessage,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
}
}
private fun base64URLDecode(input: String): ByteArray? {
return try {
val padded = input.replace("-", "+")

View File

@ -2,15 +2,16 @@ package com.bitchat.android.nostr
import android.util.Base64
import android.util.Log
import com.bitchat.android.mesh.MeshPacketUtils
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
/**
* BitChat-over-Nostr Adapter
* Direct port from iOS implementation for 100% compatibility
*/
object NostrEmbeddedBitChat {
@ -173,26 +174,12 @@ object NostrEmbeddedBitChat {
* Normalize recipient peer ID (matches iOS implementation)
*/
private fun normalizeRecipientPeerID(recipientPeerID: String): String {
try {
val maybeData = hexStringToByteArray(recipientPeerID)
return when (maybeData.size) {
32 -> {
// Treat as Noise static public key; derive peerID from fingerprint
// For now, return first 8 bytes as hex (simplified)
maybeData.take(8).joinToString("") { "%02x".format(it) }
}
8 -> {
// Already an 8-byte peer ID
recipientPeerID
}
else -> {
// Fallback: return as-is (expecting 16 hex chars)
recipientPeerID
}
}
} catch (e: Exception) {
// Fallback: return as-is
return recipientPeerID
val clean = recipientPeerID.trim().lowercase()
return when {
ContactIdentityResolver.isNoiseKeyHex(clean) ->
ContactIdentityResolver.peerIdForNoiseKeyHex(clean) ?: clean
ContactIdentityResolver.isMeshPeerId(clean) -> clean
else -> recipientPeerID
}
}
@ -210,25 +197,6 @@ object NostrEmbeddedBitChat {
/**
* Convert 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 } // Exactly 8 bytes like iOS
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
}
private fun hexStringToByteArray(hexString: String): ByteArray =
MeshPacketUtils.hexStringToByteArray(hexString)
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import java.security.MessageDigest
import java.security.SecureRandom
@ -131,8 +132,7 @@ object NostrIdentityBridge {
* Uses HMAC-SHA256(deviceSeed, geohash) as private key material with fallback rehashing
* if the candidate is not a valid secp256k1 private key.
*
* Direct port from iOS implementation for 100% compatibility
* OPTIMIZED: Cached for UI responsiveness
* Cached for UI responsiveness.
*/
fun deriveIdentity(forGeohash: String, context: Context): NostrIdentity {
// Check cache first for immediate response
@ -188,12 +188,8 @@ object NostrIdentityBridge {
* Associate a Nostr identity with a Noise public key (for favorites)
*/
fun associateNostrIdentity(nostrPubkey: String, noisePublicKey: ByteArray, context: Context) {
val stateManager = SecureIdentityStateManager(context)
// We'll use the existing signing key storage mechanism for associations
// For now, we'll store this as a preference since it's just for favorites mapping
// In a full implementation, you'd want a proper association storage system
FavoritesPersistenceService.initialize(context)
FavoritesPersistenceService.shared.updateNostrPublicKey(noisePublicKey, nostrPubkey)
Log.d(TAG, "Associated Nostr pubkey ${nostrPubkey.take(16)}... with Noise key")
}
@ -201,9 +197,8 @@ object NostrIdentityBridge {
* Get Nostr public key associated with a Noise public key
*/
fun getNostrPublicKey(noisePublicKey: ByteArray, context: Context): String? {
// This would need proper implementation based on your favorites storage system
// For now, return null as we don't have the full association system
return null
FavoritesPersistenceService.initialize(context)
return FavoritesPersistenceService.shared.findNostrPubkey(noisePublicKey)
}
/**

View File

@ -2,15 +2,17 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Minimal Nostr transport for offline sending
* Direct port from iOS NostrTransport for 100% compatibility
* Nostr transport for offline private messages and receipts.
*/
class NostrTransport(
private val context: Context,
@ -53,11 +55,7 @@ class NostrTransport(
) {
transportScope.launch {
try {
// Resolve favorite by full noise key or by short peerID fallback
var recipientNostrPubkey: String? = null
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for peerID: $to")
@ -72,20 +70,12 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
// Convert recipient npub -> hex (x-only)
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") {
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
return@launch
}
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey")
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)
@ -115,6 +105,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -147,10 +138,7 @@ class NostrTransport(
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
val recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}")
@ -167,15 +155,8 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
// Convert recipient npub -> hex
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") {
scheduleNextReadAck()
return@launch
}
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
scheduleNextReadAck()
return@launch
}
@ -201,6 +182,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -224,10 +206,7 @@ class NostrTransport(
fun sendFavoriteNotification(to: String, isFavorite: Boolean) {
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for favorite notification to: $to")
@ -240,16 +219,12 @@ class NostrTransport(
return@launch
}
val content = if (isFavorite) "[FAVORITED]:${senderIdentity.npub}" else "[UNFAVORITED]:${senderIdentity.npub}"
val content = FavoriteControlMessage.encode(isFavorite, senderIdentity.npub)
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
// Convert recipient npub -> hex
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") return@launch
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
@ -273,6 +248,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -285,10 +261,7 @@ class NostrTransport(
fun sendDeliveryAck(messageID: String, to: String) {
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for delivery ack to: $to")
@ -303,11 +276,8 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") return@launch
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
@ -331,6 +301,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -482,16 +453,17 @@ class NostrTransport(
*/
private fun resolveNostrPublicKey(peerID: String): String? {
try {
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
ContactDirectory.resolve(peerID).nostrPubkey?.let { return it }
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
if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peerID) ?: return null
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
}
// 3) Prefix match on noiseHex from 16-hex peerID
if (peerID.length == 16) {
if (ContactIdentityResolver.isMeshPeerId(peerID)) {
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
return fallbackStatus?.peerNostrPublicKey
}
@ -503,14 +475,6 @@ class NostrTransport(
}
}
/**
* Convert full hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
fun cleanup() {
transportScope.cancel()
}

View File

@ -99,10 +99,11 @@ object AppStateStore {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[peerID] ?: emptyList()) + msg
map[peerID] = list
_privateMessages.value = map
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
}
}
@ -139,6 +140,57 @@ object AppStateStore {
}
}
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
if (keysToMerge.isEmpty()) return
synchronized(this) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val map = _privateMessages.value.toMutableMap()
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
val targetIds = targetList.map { it.id }.toMutableSet()
var changed = false
keysToMerge.distinct().forEach { key ->
val canonicalKey = ContactDirectory.canonicalConversationId(key)
if (canonicalKey == targetConversationID) {
val messages = map.remove(key)
if (messages != null) {
changed = true
messages.forEach { message ->
if (targetIds.add(message.id)) targetList.add(message)
}
}
return@forEach
}
if (key == targetConversationID) return@forEach
val messages = map.remove(key) ?: return@forEach
changed = true
messages.forEach { message ->
if (targetIds.add(message.id)) {
targetList.add(message)
}
}
}
if (changed) {
if (targetList.isEmpty()) {
map.remove(targetConversationID)
} else {
map[targetConversationID] = targetList
}
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
}
}
}
fun canonicalizePrivateChats() {
synchronized(this) {
val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonical != _privateMessages.value) {
_privateMessages.value = canonical
}
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return

View File

@ -0,0 +1,201 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.GeohashAliasRegistry
object ContactDirectory {
data class ContactResolution(
val conversationID: String,
val meshPeerID: String?,
val noisePublicKey: ByteArray?,
val nostrPubkey: String?,
val displayName: String?,
val isMutualFavorite: Boolean
) {
val noiseKeyHex: String? get() = noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
}
@Volatile
private var appContext: Context? = null
@Volatile
private var meshProvider: (() -> MeshService?)? = null
fun initialize(context: Context, meshProvider: () -> MeshService?) {
appContext = context.applicationContext
this.meshProvider = meshProvider
}
fun isContactConversationID(value: String): Boolean =
ContactIdentityResolver.isContactConversationId(value)
fun canonicalConversationId(peerOrConversationID: String): String {
val value = peerOrConversationID.trim()
if (ContactIdentityResolver.isContactConversationId(value)) return value.lowercase()
noiseKeyForAlias(value)?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
if (ContactIdentityResolver.isMeshPeerId(value)) {
favoriteForMeshPeerID(value)?.peerNoisePublicKey?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
}
if (ContactIdentityResolver.isNostrAlias(value)) {
nostrPubkeyHexForAlias(value)?.let { pubHex ->
findFavoriteByNostrHex(pubHex)?.peerNoisePublicKey?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
}
}
return value
}
fun resolve(peerOrConversationID: String): ContactResolution {
val conversationID = canonicalConversationId(peerOrConversationID)
val contactFingerprint = ContactIdentityResolver.fingerprintFromContactConversationId(conversationID)
val favorite = contactFingerprint?.let { findFavoriteByFingerprint(it) }
val noiseKey = when {
favorite != null -> favorite.peerNoisePublicKey
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
ContactIdentityResolver.bytesFromHex(peerOrConversationID)
else -> null
}
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) }
?: peerOrConversationID.takeIf { ContactIdentityResolver.isMeshPeerId(it) && isMeshPeerConnected(it) }
return ContactResolution(
conversationID = conversationID,
meshPeerID = liveMeshPeerID,
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
nostrPubkey = favorite?.peerNostrPublicKey,
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname },
isMutualFavorite = favorite?.isMutual == true
)
}
fun canonicalizePrivateChats(
chats: Map<String, List<BitchatMessage>>
): Map<String, List<BitchatMessage>> {
if (chats.isEmpty()) return chats
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
chats.forEach { (key, messages) ->
val canonical = canonicalConversationId(key)
val list = merged.getOrPut(canonical) { mutableListOf() }
list.addAll(messages)
}
return merged.mapValues { (_, messages) ->
messages
.distinctBy { it.id }
.sortedWith(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
}
}
fun aliasesForConversation(peerOrConversationID: String): Set<String> {
val resolution = resolve(peerOrConversationID)
val aliases = mutableSetOf<String>()
aliases.add(peerOrConversationID)
aliases.add(resolution.conversationID)
resolution.meshPeerID?.let { aliases.add(it) }
resolution.noiseKeyHex?.let { aliases.add(it) }
resolution.nostrPubkey
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
?.let { aliases.add(it) }
return aliases
}
private fun noiseKeyForAlias(value: String): ByteArray? {
if (ContactIdentityResolver.isNoiseKeyHex(value)) {
return ContactIdentityResolver.bytesFromHex(value)
}
if (ContactIdentityResolver.isMeshPeerId(value)) {
meshProvider?.invoke()?.getPeerInfo(value)?.noisePublicKey?.let { return it }
cachedNoiseKey(value)?.let { return it }
}
return null
}
private fun cachedNoiseKey(peerID: String): ByteArray? {
val context = appContext ?: return null
return try {
SecureIdentityStateManager(context)
.getCachedNoiseKey(peerID)
?.let { ContactIdentityResolver.bytesFromHex(it) }
} catch (_: Exception) {
null
}
}
private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
private fun findFavoriteByFingerprint(fingerprint: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getAllRelationships().firstOrNull {
ContactIdentityResolver.fingerprintHex(it.peerNoisePublicKey).equals(fingerprint, ignoreCase = true)
}
} catch (_: Exception) {
null
}
private fun findFavoriteByNostrHex(pubHex: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getAllRelationships().firstOrNull {
it.peerNostrPublicKey
?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) }
?.equals(pubHex, ignoreCase = true) == true
}
} catch (_: Exception) {
null
}
private fun nostrPubkeyHexForAlias(alias: String): String? {
GeohashAliasRegistry.get(alias)?.let { return it.lowercase() }
val prefix = alias.removePrefix("nostr_").removePrefix("nostr:")
if (prefix.isBlank()) return null
return try {
FavoritesPersistenceService.shared.getAllRelationships()
.asSequence()
.mapNotNull { it.peerNostrPublicKey?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) } }
.firstOrNull { it.startsWith(prefix, ignoreCase = true) }
} catch (_: Exception) {
null
}
}
private fun findLiveMeshPeerForFingerprint(fingerprint: String): String? {
val mesh = meshProvider?.invoke() ?: return null
return mesh.getPeerNicknames().keys.firstOrNull { peerID ->
val info = mesh.getPeerInfo(peerID)
val noiseKey = info?.noisePublicKey ?: cachedNoiseKey(peerID)
noiseKey != null &&
ContactIdentityResolver.fingerprintHex(noiseKey).equals(fingerprint, ignoreCase = true) &&
info?.isConnected == true
}
}
private fun isMeshPeerConnected(peerID: String): Boolean =
try {
meshProvider?.invoke()?.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}

View File

@ -0,0 +1,83 @@
package com.bitchat.android.services
import com.bitchat.android.nostr.Bech32
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
object ContactIdentityResolver {
private const val CONTACT_PREFIX = "contact_"
private val meshPeerIdRegex = Regex("^[0-9a-fA-F]{16}$")
private val noiseKeyRegex = Regex("^[0-9a-fA-F]{64}$")
private val fingerprintRegex = Regex("^[0-9a-fA-F]{64}$")
fun isMeshPeerId(value: String): Boolean = meshPeerIdRegex.matches(value)
fun isNoiseKeyHex(value: String): Boolean = noiseKeyRegex.matches(value)
fun isNostrAlias(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
fun noiseKeyHex(noisePublicKey: ByteArray): String = noisePublicKey.hexEncodedString()
fun fingerprintHex(noisePublicKey: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(noisePublicKey)
return digest.hexEncodedString()
}
fun contactConversationIdForNoiseKey(noisePublicKey: ByteArray): String =
CONTACT_PREFIX + fingerprintHex(noisePublicKey)
fun contactConversationIdForFingerprint(fingerprint: String): String? =
fingerprint
.takeIf { fingerprintRegex.matches(it) }
?.let { CONTACT_PREFIX + it.lowercase() }
fun isContactConversationId(value: String): Boolean =
value.startsWith(CONTACT_PREFIX) &&
fingerprintRegex.matches(value.removePrefix(CONTACT_PREFIX))
fun fingerprintFromContactConversationId(value: String): String? =
value
.takeIf { isContactConversationId(it) }
?.removePrefix(CONTACT_PREFIX)
?.lowercase()
fun peerIdForNoiseKey(noisePublicKey: ByteArray): String =
fingerprintHex(noisePublicKey).take(16)
fun peerIdForNoiseKeyHex(noiseKeyHex: String): String? =
bytesFromHex(noiseKeyHex)
?.takeIf { it.size == 32 }
?.let { peerIdForNoiseKey(it) }
fun bytesFromHex(hex: String): ByteArray? {
val clean = hex.trim()
if (clean.length % 2 != 0) return null
if (!clean.matches(Regex("^[0-9a-fA-F]+$"))) return null
return clean.dataFromHexString()
}
fun nostrPubkeyHex(value: String): String? {
val clean = value.trim()
if (clean.startsWith("npub1", ignoreCase = true)) {
return try {
val (hrp, data) = Bech32.decode(clean.lowercase())
if (hrp == "npub" && data.size == 32) data.hexEncodedString() else null
} catch (_: Exception) {
null
}
}
return clean
.takeIf { noiseKeyRegex.matches(it) }
?.lowercase()
}
fun npubFromHex(hex: String): String? =
bytesFromHex(hex)
?.takeIf { it.size == 32 }
?.let { Bech32.encode("npub", it) }
fun nostrAliasForPubkey(value: String): String? =
nostrPubkeyHex(value)?.let { "nostr_${it.take(16)}" }
}

View File

@ -8,28 +8,25 @@ object ConversationAliasResolver {
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_")) {
if (ContactIdentityResolver.isNostrAlias(peer)) {
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 noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
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()
} else if (ContactIdentityResolver.isNoiseKeyHex(peer)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peer) ?: return peer
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
}
@ -38,7 +35,7 @@ object ConversationAliasResolver {
}
}
} catch (_: Exception) { /* no-op */ }
return peer
return ContactDirectory.canonicalConversationId(peer)
}
fun unifyChatsIntoPeer(
@ -47,11 +44,17 @@ object ConversationAliasResolver {
keysToMerge: List<String>
) {
if (keysToMerge.isEmpty()) return
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val mergeKeys = (keysToMerge + targetPeerID)
.flatMap { ContactDirectory.aliasesForConversation(it) }
.distinct()
AppStateStore.unifyPrivateChatsIntoPeer(targetConversationID, mergeKeys)
val currentChats = state.getPrivateChatsValue().toMutableMap()
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
val targetList = currentChats[targetConversationID]?.toMutableList() ?: mutableListOf()
var didMerge = false
keysToMerge.distinct().forEach { key ->
if (key == targetPeerID) return@forEach
mergeKeys.forEach { key ->
if (key == targetConversationID) return@forEach
val list = currentChats[key]
if (!list.isNullOrEmpty()) {
targetList.addAll(list)
@ -60,27 +63,28 @@ object ConversationAliasResolver {
}
}
if (didMerge) {
// Preserve arrival order; do not sort by timestamp
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)
currentChats[targetConversationID] = targetList
.distinctBy { it.id }
.sortedBy { it.timestamp.time }
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(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)
mergeKeys.forEach { key -> if (unread.remove(key)) hadUnread = true }
if (hadUnread) unread.add(targetConversationID)
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)
if (selected != null && mergeKeys.contains(selected)) {
state.setSelectedPrivateChatPeer(targetConversationID)
}
// Switch sheet peer if currently viewing an alias that got merged
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
if (sheetPeer != null && mergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetConversationID)
}
}
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
@ -14,6 +15,13 @@ class MessageRouter private constructor(
private var mesh: MeshService,
private val nostr: NostrTransport
) {
enum class RouteResult {
MESH,
NOSTR,
QUEUED,
DROPPED
}
companion object {
private const val TAG = "MessageRouter"
@Volatile private var INSTANCE: MessageRouter? = null
@ -46,53 +54,58 @@ class MessageRouter private constructor(
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)
ContactIdentityResolver.peerIdForNoiseKeyHex(noiseKeyHex)?.let { flushOutboxFor(it) }
}
override fun onAllCleared() {
// Nothing special; leave queued items until routing becomes possible
}
}
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
// First: if this is a geohash DM alias (nostr_<pub16>), route via Nostr using global registry
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult {
val resolution = ContactDirectory.resolve(toPeerID)
val conversationID = resolution.conversationID
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
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
return RouteResult.NOSTR
}
return RouteResult.DROPPED
}
val hasMesh = isConnected(mesh, toPeerID)
if (isReady(mesh, toPeerID)) {
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(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
return RouteResult.MESH
} else if (canSendViaNostr(nostrTarget)) {
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID)
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(conversationID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID)
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}")
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
return RouteResult.QUEUED
}
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
if (isReady(mesh, 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)
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing READ via mesh to ${meshTarget.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, meshTarget, mesh.getPeerNicknames()[meshTarget] ?: mesh.myPeerID)
} else {
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
nostr.sendReadReceipt(receipt, toPeerID)
nostr.sendReadReceipt(receipt, nostrTarget)
}
}
@ -106,50 +119,48 @@ class MessageRouter private constructor(
return
}
}
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
nostr.sendDeliveryAck(messageID, toPeerID)
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (!(meshTarget != null && (mesh.getPeerInfo(meshTarget)?.isConnected == true) && mesh.hasEstablishedSession(meshTarget))) {
nostr.sendDeliveryAck(messageID, resolution.noiseKeyHex ?: toPeerID)
}
}
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) {
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) {
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, null)
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget
mesh.sendPrivateMessage(content, meshTarget, nickname, null)
} else {
nostr.sendFavoriteNotification(toPeerID, isFavorite)
nostr.sendFavoriteNotification(resolution.noiseKeyHex ?: toPeerID, isFavorite)
}
}
// Flush any queued messages for a specific peerID
fun flushOutboxFor(peerID: String) {
val queued = outbox[peerID] ?: return
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}")
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
val (content, nickname, messageID) = iterator.next()
val hasMesh = isReady(mesh, 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 = resolvePeerForNoiseHex(peerID, mesh)
if (meshPeer != null && isReady(mesh, meshPeer)) {
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
iterator.remove()
continue
}
}
val canNostr = canSendViaNostr(peerID)
if (hasMesh) {
mesh.sendPrivateMessage(content, peerID, nickname, messageID)
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(content, meshTarget, nickname, messageID)
iterator.remove()
} else if (canNostr) {
nostr.sendPrivateMessage(content, peerID, nickname, messageID)
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
iterator.remove()
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID)
outbox.remove(peerID)
}
}
@ -161,14 +172,15 @@ class MessageRouter private constructor(
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 resolution = ContactDirectory.resolve(peerID)
if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true
val target = resolution.noiseKeyHex ?: peerID
if (ContactIdentityResolver.isNoiseKeyHex(target)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(target) ?: return false
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)
} else if (ContactIdentityResolver.isMeshPeerId(target)) {
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(target)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else {
false
@ -176,11 +188,6 @@ class MessageRouter private constructor(
} 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 isConnected(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true
@ -198,22 +205,12 @@ class MessageRouter private constructor(
}
}
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
return try {
service.getPeerNicknames().keys.firstOrNull { pid ->
val info = service.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) }
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
@ -223,7 +220,7 @@ class MessageRouter private constructor(
fun onSessionEstablished(peerID: String) {
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}

View File

@ -22,16 +22,14 @@ import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.launch
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.random.Random
import com.bitchat.android.services.VerificationService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
@ -205,6 +203,8 @@ class ChatViewModel(
init {
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
@ -220,14 +220,14 @@ class ChatViewModel(
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
// Replace with store snapshot
state.setPrivateChats(byPeer)
val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer)
state.setPrivateChats(canonicalChats)
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
canonicalChats.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
}
state.setUnreadPrivateMessages(unread)
@ -402,17 +402,18 @@ class ChatViewModel(
val success = privateChatManager.startPrivateChat(peerID, mesh)
if (success) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
// Notify notification manager about current private chat
setCurrentPrivateChatPeer(peerID)
setCurrentPrivateChatPeer(conversationID)
// Clear notifications for this sender since user is now viewing the chat
clearNotificationsForSender(peerID)
clearNotificationsForSender(conversationID)
// 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()
val messages = chats[conversationID] ?: emptyList()
messages.forEach { msg ->
try { seen.markRead(msg.id) } catch (_: Exception) { }
}
@ -466,12 +467,11 @@ class ChatViewModel(
} else {
// Resolve to a canonical mesh peer if needed
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
canonical ?: targetKey
}
@ -510,21 +510,19 @@ class ChatViewModel(
}
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
// 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"
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(
selectedPeer = ContactDirectory.canonicalConversationId(
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = selectedPeer,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
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()) {
privateChatManager.startPrivateChat(canonical, mesh)
@ -543,9 +541,11 @@ class ChatViewModel(
state.getNicknameValue(),
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
// Route via MessageRouter (mesh when connected+established, else Nostr)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent)
}
}
} else {
// Check if we're in a location channel
@ -609,25 +609,23 @@ class ChatViewModel(
var noiseKey: ByteArray? = null
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
// Case 1: Live mesh peer with known info
val peerInfo = mesh.getPeerInfo(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) { }
} else if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
noiseKey = ContactIdentityResolver.bytesFromHex(peerID)
val rel = noiseKey?.let {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
if (rel != null) nickname = rel.peerNickname
} else {
val contact = ContactDirectory.resolve(peerID)
noiseKey = contact.noisePublicKey
contact.displayName?.let { nickname = it }
}
if (noiseKey != null) {
// 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)
@ -638,7 +636,6 @@ class ChatViewModel(
isFavorite = isNowFavorite
)
// Send favorite notification via mesh or Nostr with our npub if available
try {
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), mesh)
@ -845,7 +842,8 @@ class ChatViewModel(
}
fun showPrivateChatSheet(peerID: String) {
state.setPrivateChatSheetPeer(peerID)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
state.setPrivateChatSheetPeer(conversationID)
}
fun hidePrivateChatSheet() {
@ -934,8 +932,6 @@ class ChatViewModel(
return meshDelegateHandler.isFavorite(peerID)
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
// MARK: - Emergency Clear
fun panicClearAllData() {

View File

@ -5,6 +5,7 @@ import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Date
@ -26,7 +27,6 @@ class MeshDelegateHandler(
override fun didReceiveMessage(message: BitchatMessage) {
coroutineScope.launch {
// FIXED: Deduplicate messages from dual connection paths
val messageKey = messageManager.generateMessageKey(message)
if (messageManager.isMessageProcessed(messageKey)) {
return@launch // Duplicate message, ignore
@ -111,88 +111,31 @@ class MeshDelegateHandler(
// Clean up channel members who disconnected
channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
// Handle chat view migration based on current selection and new peer list
runCatching { com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() }
state.getSelectedPrivateChatPeerValue()?.let { 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 = mergedPeers,
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mergedPeers.contains(pid) },
nostrPubHexForAlias = { alias ->
// 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) }
}
},
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
val canonical = ContactDirectory.canonicalConversationId(currentPeer)
if (canonical != currentPeer) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = canonical,
keysToMerge = ContactDirectory.aliasesForConversation(currentPeer).toList()
)
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 && !mergedPeers.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)
}
state.setSelectedPrivateChatPeer(canonical)
}
}
state.getPrivateChatSheetPeerValue()?.let { sheetPeer ->
val canonical = ContactDirectory.canonicalConversationId(sheetPeer)
if (canonical != sheetPeer) {
state.setPrivateChatSheetPeer(canonical)
}
}
// 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.
mergedPeers.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))
val canonical = ContactDirectory.canonicalConversationId(pid)
unifyChatsIntoPeer(canonical, ContactDirectory.aliasesForConversation(pid).toList())
} catch (_: Exception) { }
}
}
@ -294,16 +237,30 @@ class MeshDelegateHandler(
// Send read receipt if user is currently focused on this specific chat
val senderPeerID = message.senderPeerID
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
val senderConversationID = senderPeerID?.let { ContactDirectory.canonicalConversationId(it) }
val focusedConversationID = currentPrivateChatPeer?.let { ContactDirectory.canonicalConversationId(it) }
val shouldSendReadReceipt = !isAppInBackground &&
senderConversationID != null &&
focusedConversationID == senderConversationID
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
android.util.Log.d(
"MeshDelegateHandler",
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
)
val nickname = state.getNicknameValue() ?: "unknown"
val mesh = getMeshService()
val sent = try {
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
if (hasMesh) {
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
val meshPeerID = senderConversationID
?.let { ContactDirectory.resolve(it).meshPeerID }
?: senderPeerID?.takeIf {
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
}
if (meshPeerID != null &&
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
mesh.hasEstablishedSession(meshPeerID)
) {
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
true
} else {
false
@ -315,7 +272,8 @@ class MeshDelegateHandler(
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
if (current.remove(senderPeerID)) {
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
if (changed) {
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
@ -324,9 +282,6 @@ class MeshDelegateHandler(
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
/**
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
*/

View File

@ -18,6 +18,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -30,10 +31,16 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
/**
@ -286,6 +293,11 @@ fun PeopleSection(
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
val context = LocalContext.current
val identityStateManager = remember(context) {
SecureIdentityStateManager(context.applicationContext)
}
Column(modifier = modifier) {
Text(
text = stringResource(id = R.string.people).uppercase(),
@ -322,9 +334,8 @@ fun PeopleSection(
// 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)
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
}
@ -334,13 +345,35 @@ fun PeopleSection(
}
}
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
// Build mapping of connected peerID -> Noise key hex to unify with offline favorites.
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
?: identityStateManager.getCachedNoiseKey(pid)
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
val nostrHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
FavoritesPersistenceService.shared
.findNostrPubkeyForPeerID(pid)
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
val connectedNoiseHexes = noiseHexByPeerID.values.map { it.lowercase() }.toSet()
val connectedNostrHexes = nostrHexByPeerID.values.map { it.lowercase() }.toSet()
fun isFavoriteMappedToConnected(favorite: FavoriteRelationship): Boolean {
val noiseHex = ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey).lowercase()
if (connectedNoiseHexes.contains(noiseHex)) return true
val nostrHex = favorite.peerNostrPublicKey
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
?.lowercase()
return nostrHex != null && connectedNostrHexes.contains(nostrHex)
}
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
@ -369,11 +402,10 @@ fun PeopleSection(
}
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (!isFavoriteMappedToConnected(fav)) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
@ -382,12 +414,11 @@ fun PeopleSection(
// Nostr-only conversations
val connectedIds = sortedPeers.toSet()
val appendedOfflineIds = mutableSetOf<String>()
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
!connectedIds.contains(key) &&
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
!connectedNoiseHexes.contains(key.lowercase())
}
.forEach { convKey ->
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
@ -396,16 +427,17 @@ fun PeopleSection(
}
sortedPeers.forEach { peerID ->
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[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 meshUnread = hasUnreadPrivateMessages.contains(conversationID) || 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
privateChats[conversationID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
) + (
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
)
@ -421,7 +453,7 @@ fun PeopleSection(
displayName = displayName,
isDirect = isDirectLive,
isWifiAware = peerID in wifiAwarePeerIDs,
isSelected = peerID == selectedPrivatePeer,
isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer,
isFavorite = isFavorite,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread,
@ -440,29 +472,19 @@ fun PeopleSection(
// Append offline favorites we actively favorite (and not currently connected)
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
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (isFavoriteMappedToConnected(fav)) 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
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
} catch (_: Exception) { null }
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
val conversationID = ContactDirectory.canonicalConversationId(favPeerID)
val hasUnread = hasUnreadPrivateMessages.contains(conversationID) ||
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
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (bName, _) = splitSuffix(dn)
@ -470,9 +492,8 @@ fun PeopleSection(
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
// Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = (
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0
) + (
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
)
@ -481,7 +502,7 @@ fun PeopleSection(
peerID = favPeerID,
displayName = dn,
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
isVerified = isVerified,
hasUnreadDM = hasUnread,
@ -496,51 +517,7 @@ fun PeopleSection(
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
showHashSuffix = showHash
)
appendedOfflineIds.add(favPeerID)
}
// 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 ->
// 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) }
}
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
.forEach { convKey ->
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
val (bName, _) = 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,
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 = false,
showHashSuffix = showHash
)
}
*/
// End intentional removal
}
}
@ -788,7 +765,11 @@ fun PrivateChatSheet(
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val isWifiAware = peerID in wifiAwareConnected.keys
val contactResolution = remember(peerID, connectedPeers, favoritePeers) {
ContactDirectory.resolve(peerID)
}
val activeMeshPeerID = contactResolution.meshPeerID
val isWifiAware = activeMeshPeerID in wifiAwareConnected.keys || peerID in wifiAwareConnected.keys
// Start private chat when screen opens
LaunchedEffect(peerID) {
@ -796,10 +777,27 @@ fun PrivateChatSheet(
}
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
val favoriteRelationship = remember(peerID, favoritePeers) {
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
}
val isDirect = activeMeshPeerID?.let { peerDirectMap[it] } == true || peerDirectMap[peerID] == true
val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect
val isNostrReachableFavorite =
!isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null
// Compute display name and title text reactively
val displayName = peerNicknames[peerID] ?: peerID.take(12)
val titleText = remember(peerID, peerNicknames) {
val displayName = remember(peerID, peerNicknames, favoriteRelationship) {
peerNicknames[peerID]
?: activeMeshPeerID?.let { peerNicknames[it] }
?: contactResolution.displayName
?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: viewModel.resolvePeerDisplayNameForFingerprint(peerID)
}
val titleText = remember(peerID, peerNicknames, favoriteRelationship) {
if (isNostrPeer) {
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
@ -810,16 +808,17 @@ fun PrivateChatSheet(
}
"#$gh/@$name"
} else {
peerNicknames[peerID] ?: peerID.take(12)
displayName
}
}
val messages = privateChats[peerID] ?: emptyList()
val isDirect = peerDirectMap[peerID] == true
val isConnected = connectedPeers.contains(peerID) || isDirect
val sessionState = peerSessionStates[peerID]
val fingerprint = peerFingerprints[peerID]
val isFavorite = remember(favoritePeers, fingerprint) {
val conversationID = contactResolution.conversationID
val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList()
val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID]
val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] }
?: peerFingerprints[peerID]
?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
@ -827,7 +826,7 @@ fun PrivateChatSheet(
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
val securityModifier = if (!isNostrPeer) {
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
@ -941,7 +940,7 @@ fun PrivateChatSheet(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isNostrPeer -> {
isNostrPeer || isNostrReachableFavorite -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
@ -981,14 +980,14 @@ fun PrivateChatSheet(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.then(securityModifier)
) {
if (!isNostrPeer) {
if (!isNostrPeer && !isNostrReachableFavorite) {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)

View File

@ -2,6 +2,7 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
import java.util.*
import java.util.Collections
@ -10,7 +11,7 @@ import java.util.Collections
*/
class MessageManager(private val state: ChatState) {
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
// Message deduplication for duplicate deliveries from multiple local transports.
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds
@ -100,57 +101,63 @@ class MessageManager(private val state: ChatState) {
// MARK: - Private Message Management
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
currentPrivateChats[peerID] = mutableListOf()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
currentPrivateChats[conversationID] = chatMessages
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) {
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(peerID)
currentUnread.add(conversationID)
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 conversationID = ContactDirectory.canonicalConversationId(peerID)
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
currentPrivateChats[peerID] = mutableListOf()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
currentPrivateChats[conversationID] = chatMessages
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
}
fun clearPrivateMessages(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[peerID] = emptyList()
updatedChats[conversationID] = emptyList()
state.setPrivateChats(updatedChats)
}
fun initializePrivateChat(peerID: String) {
if (state.getPrivateChatsValue().containsKey(peerID)) return
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (state.getPrivateChatsValue().containsKey(conversationID)) return
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[peerID] = emptyList()
updatedChats[conversationID] = emptyList()
state.setPrivateChats(updatedChats)
}
fun clearPrivateUnreadMessages(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
updatedUnread.remove(peerID)
updatedUnread.remove(conversationID)
state.setUnreadPrivateMessages(updatedUnread)
}

View File

@ -12,6 +12,7 @@ import androidx.core.app.Person
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.util.NotificationIntervalManager
import java.util.concurrent.ConcurrentHashMap
@ -128,8 +129,8 @@ class NotificationManager(
* Update current private chat peer - affects notification logic
*/
fun setCurrentPrivateChatPeer(peerID: String?) {
currentPrivateChatPeer = peerID
Log.d(TAG, "Current private chat peer changed: $peerID")
currentPrivateChatPeer = peerID?.let { ContactDirectory.canonicalConversationId(it) }
Log.d(TAG, "Current private chat peer changed: $currentPrivateChatPeer")
}
/**
@ -144,28 +145,30 @@ class NotificationManager(
* Show a notification for a private message with proper grouping and state awareness
*/
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
// Only show notifications if app is in background OR user is not viewing this specific chat
val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != senderPeerID)
val shouldNotify = isAppInBackground ||
(!isAppInBackground && currentPrivateChatPeer != conversationID)
if (!shouldNotify) {
Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname")
return
}
Log.d(TAG, "Showing notification for message from $senderNickname (peerID: $senderPeerID)")
Log.d(TAG, "Showing notification for message from $senderNickname (conversationID: $conversationID)")
val notification = PendingNotification(
senderPeerID = senderPeerID,
senderPeerID = conversationID,
senderNickname = senderNickname,
messageContent = messageContent,
timestamp = System.currentTimeMillis()
)
// Add to pending notifications for this sender
pendingNotifications.computeIfAbsent(senderPeerID) { mutableListOf() }.add(notification)
pendingNotifications.computeIfAbsent(conversationID) { mutableListOf() }.add(notification)
// Create or update notification for this sender
showNotificationForSender(senderPeerID)
showNotificationForSender(conversationID)
// Update summary notification if we have multiple senders
if (pendingNotifications.size > 1) {
@ -404,11 +407,15 @@ class NotificationManager(
* Clear notifications for a specific sender (e.g., when user opens their chat)
*/
fun clearNotificationsForSender(senderPeerID: String) {
pendingNotifications.remove(senderPeerID)
// Cancel the individual notification
val notificationId = senderPeerID.hashCode()
notificationManager.cancel(notificationId)
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
val matchingKeys = pendingNotifications.keys.filter { key ->
ContactDirectory.canonicalConversationId(key) == conversationID
}
matchingKeys.forEach { key ->
pendingNotifications.remove(key)
notificationManager.cancel(key.hashCode())
}
notificationManager.cancel(conversationID.hashCode())
// Update or remove summary notification
if (pendingNotifications.isEmpty()) {
@ -421,7 +428,7 @@ class NotificationManager(
showSummaryNotification()
}
Log.d(TAG, "Cleared notifications for sender: $senderPeerID")
Log.d(TAG, "Cleared notifications for conversation: $conversationID")
}
/**

View File

@ -1,10 +1,12 @@
package com.bitchat.android.ui
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.mesh.MeshService
import java.security.MessageDigest
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
import android.util.Log
@ -19,9 +21,13 @@ interface NoiseSessionDelegate {
fun getMyPeerID(): String
}
enum class PrivateMessageOrigin {
MESH,
NOSTR
}
/**
* Handles private chat functionality including peer management and blocking
* Now uses centralized PeerFingerprintManager for all fingerprint operations
* Handles private chat functionality including peer management and blocking.
*/
class PrivateChatManager(
private val state: ChatState,
@ -34,7 +40,6 @@ class PrivateChatManager(
private const val TAG = "PrivateChatManager"
}
// Use centralized fingerprint management - NO LOCAL STORAGE
private val fingerprintManager = PeerFingerprintManager.getInstance()
// Track received private messages that need read receipts
@ -43,8 +48,12 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val route = ContactDirectory.resolve(conversationID)
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (isPeerBlocked(peerID)) {
val peerNickname = getPeerNickname(peerID, meshService)
val peerNickname = route.displayName ?: getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot start chat with $peerNickname: user is blocked.",
@ -55,24 +64,24 @@ class PrivateChatManager(
return false
}
// Establish Noise session if needed before starting the chat
establishNoiseSessionIfNeeded(peerID, meshService)
if (meshPeerID != null && meshService.getPeerInfo(meshPeerID)?.isConnected == true) {
establishNoiseSessionIfNeeded(meshPeerID, meshService)
}
// Consolidate any temporary Nostr conversation for this peer into the stable/current peerID
try {
consolidateNostrTempConversationIfNeeded(peerID)
consolidateNostrTempConversationIfNeeded(conversationID, meshService)
} catch (_: Exception) { }
state.setSelectedPrivateChatPeer(peerID)
state.setSelectedPrivateChatPeer(conversationID)
// Clear unread
messageManager.clearPrivateUnreadMessages(peerID)
messageManager.clearPrivateUnreadMessages(conversationID)
// Initialize chat if needed
messageManager.initializePrivateChat(peerID)
messageManager.initializePrivateChat(conversationID)
// Send read receipts for all unread messages from this peer
sendReadReceiptsForPeer(peerID, meshService)
sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
return true
}
@ -89,6 +98,7 @@ class PrivateChatManager(
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
@ -111,8 +121,8 @@ class PrivateChatManager(
deliveryStatus = DeliveryStatus.Sending
)
messageManager.addPrivateMessage(peerID, message)
onSendMessage(content, peerID, recipientNickname ?: "", message.id)
messageManager.addPrivateMessage(conversationID, message)
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
return true
}
@ -121,20 +131,19 @@ class PrivateChatManager(
fun isPeerBlocked(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
?: ContactDirectory.resolve(peerID).noisePublicKey?.let { ContactIdentityResolver.fingerprintHex(it) }
return fingerprint != null && dataManager.isUserBlocked(fingerprint)
}
fun toggleFavorite(peerID: String) {
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(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]+$"))) {
if (fingerprint == null && ContactIdentityResolver.isNoiseKeyHex(peerID)) {
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) }
val pubBytes = ContactIdentityResolver.bytesFromHex(peerID) ?: return
fingerprint = ContactIdentityResolver.fingerprintHex(pubBytes)
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}")
@ -172,10 +181,26 @@ class PrivateChatManager(
fun isFavorite(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
val isFav = dataManager.isFavorite(fingerprint)
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav")
return isFav
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
?: if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
ContactIdentityResolver.bytesFromHex(peerID)?.let { ContactIdentityResolver.fingerprintHex(it) }
} else {
null
}
if (fingerprint != null && dataManager.isFavorite(fingerprint)) {
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=true")
return true
}
val persistedFavorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isFavorite == true
} catch (_: Exception) {
false
}
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$persistedFavorite")
return persistedFavorite
}
fun getPeerFingerprint(peerID: String): String? {
@ -288,33 +313,44 @@ class PrivateChatManager(
// MARK: - Message Handling
fun handleIncomingPrivateMessage(message: BitchatMessage) {
handleIncomingPrivateMessage(message, suppressUnread = false)
handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.MESH
)
}
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
fun handleIncomingPrivateMessage(
message: BitchatMessage,
suppressUnread: Boolean,
origin: PrivateMessageOrigin = PrivateMessageOrigin.MESH
) {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
if (!isPeerBlocked(senderPeerID)) {
// Ensure chat exists
messageManager.initializePrivateChat(senderPeerID)
messageManager.initializePrivateChat(conversationID)
// Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here.
if (senderPeerID.startsWith("nostr_")) {
// Mesh messages are already reflected through AppStateStore by the mesh service.
// Nostr messages originate here and must be added explicitly, even after their
// sender alias has canonicalized to a contact_* conversation ID.
if (origin == PrivateMessageOrigin.NOSTR) {
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
messageManager.addPrivateMessageNoUnread(conversationID, message)
} else {
messageManager.addPrivateMessage(senderPeerID, message)
messageManager.addPrivateMessage(conversationID, message)
}
}
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})")
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(senderPeerID)
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
}
}
@ -333,24 +369,39 @@ class PrivateChatManager(
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
fun sendReadReceiptsForPeer(
conversationID: String,
meshPeerID: String?,
meshService: MeshService
) {
val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID)
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[peerID].orEmpty()
val messages = chats[canonicalConversationID].orEmpty()
if (messages.isEmpty()) {
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
Log.d(TAG, "No messages found for conversation $canonicalConversationID to send read receipts")
}
val myNickname = state.getNicknameValue() ?: "unknown"
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
val hasMesh = meshPeerID != null && try {
meshService.getPeerInfo(meshPeerID)?.isConnected == true &&
meshService.hasEstablishedSession(meshPeerID)
} catch (_: Exception) {
false
}
var sentCount = 0
messages.forEach { msg ->
// Only for incoming messages from this peer
if (msg.senderPeerID == peerID) {
val senderPeerID = msg.senderPeerID
val isFromTarget = senderPeerID != null && (
senderPeerID == meshPeerID ||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
)
if (isFromTarget && meshPeerID != null) {
try {
if (hasMesh) {
meshService.sendReadReceipt(msg.id, peerID, myNickname)
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
sentCount += 1
}
} catch (e: Exception) {
@ -360,10 +411,13 @@ class PrivateChatManager(
}
// Clear any locally tracked unread queue for this peer
unreadReceivedMessages.remove(peerID)
unreadReceivedMessages.remove(canonicalConversationID)
// Also clear UI unread marker for this peer now that chat is focused/read
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
try { messageManager.clearPrivateUnreadMessages(canonicalConversationID) } catch (_: Exception) { }
Log.d(
TAG,
"Sent $sentCount read receipts for conversation $canonicalConversationID via mesh peer $meshPeerID"
)
}
fun cleanupDisconnectedPeer(peerID: String) {
@ -426,65 +480,37 @@ class PrivateChatManager(
// 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
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String, meshService: MeshService) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
if (ContactIdentityResolver.isNostrAlias(targetPeerID)) 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)
val noiseKey = when {
ContactIdentityResolver.isNoiseKeyHex(targetPeerID) ->
ContactIdentityResolver.bytesFromHex(targetPeerID)
ContactIdentityResolver.isMeshPeerId(targetPeerID) ->
meshService.getPeerInfo(targetPeerID)?.noisePublicKey
else -> null
}
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 (noiseKey != null) {
val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
if (!noiseHex.equals(targetPeerID, ignoreCase = true)) {
tryMergeKeys.add(noiseHex)
}
try {
FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
?.let { tryMergeKeys.add(it) }
} catch (_: Exception) { }
}
if (didMerge) {
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)
}
// If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
}
if (tryMergeKeys.isNotEmpty()) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = targetConversationID,
keysToMerge = tryMergeKeys
)
}
}

View File

@ -96,4 +96,45 @@ class AppStateStoreTest {
assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers())
}
@Test
fun `private chat aliases merge into canonical peer`() {
val live = BitchatMessage(id = "live-message", sender = "alice", content = "live", timestamp = Date(1))
val noise = BitchatMessage(id = "noise-message", sender = "alice", content = "noise", timestamp = Date(2))
val nostr = BitchatMessage(id = "nostr-message", sender = "alice", content = "nostr", timestamp = Date(3))
AppStateStore.addPrivateMessage("live-peer", live)
AppStateStore.addPrivateMessage("noise-hex", noise)
AppStateStore.addPrivateMessage("nostr_alias", nostr)
AppStateStore.unifyPrivateChatsIntoPeer("live-peer", listOf("noise-hex", "nostr_alias"))
assertEquals(setOf("live-peer"), AppStateStore.privateMessages.value.keys)
assertEquals(listOf(live, noise, nostr), AppStateStore.privateMessages.value["live-peer"])
}
@Test
fun `noise hex private messages are stored under stable contact conversation`() {
val noiseKeyHex = "00".repeat(32)
val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32))
val message = BitchatMessage(id = "noise-message", sender = "alice", content = "hello", timestamp = Date(1))
AppStateStore.addPrivateMessage(noiseKeyHex, message)
assertEquals(setOf(contactID), AppStateStore.privateMessages.value.keys)
assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID])
}
@Test
fun `canonicalized private chat history is chronological after alias merge`() {
val noiseKeyHex = "01".repeat(32)
val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 })
val later = BitchatMessage(id = "later", sender = "alice", content = "later", timestamp = Date(3))
val earlier = BitchatMessage(id = "earlier", sender = "alice", content = "earlier", timestamp = Date(1))
AppStateStore.addPrivateMessage(contactID, later)
AppStateStore.addPrivateMessage(noiseKeyHex, earlier)
assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID])
}
}

View File

@ -0,0 +1,107 @@
package com.bitchat.android.ui
import android.os.Build
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PeerInfo
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Date
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
@OptIn(ExperimentalCoroutinesApi::class)
class PrivateChatManagerTest {
private lateinit var state: ChatState
private lateinit var manager: PrivateChatManager
@Before
fun setUp() {
AppStateStore.clear()
state = ChatState(TestScope())
manager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = DataManager(RuntimeEnvironment.getApplication()),
noiseSessionDelegate = mock()
)
}
@After
fun tearDown() {
AppStateStore.clear()
}
@Test
fun `nostr message is stored after sender alias canonicalizes to contact id`() {
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 7 })
val message = BitchatMessage(
id = "nostr-message",
sender = "alice",
content = "hello over nostr",
timestamp = Date(1),
isPrivate = true,
senderPeerID = conversationID
)
manager.handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID])
}
@Test
fun `canonical conversation sends read receipt through live mesh peer id`() {
val noiseKey = ByteArray(32) { 9 }
val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey)
val message = BitchatMessage(
id = "mesh-message",
sender = "alice",
content = "hello over mesh",
timestamp = Date(1),
isPrivate = true,
senderPeerID = meshPeerID
)
val meshService = mock<MeshService>()
val peerInfo = PeerInfo(
id = meshPeerID,
nickname = "alice",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = null,
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
state.setNickname("bob")
state.setPrivateChats(mapOf(conversationID to listOf(message)))
whenever(meshService.getPeerInfo(meshPeerID)).thenReturn(peerInfo)
whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true)
manager.sendReadReceiptsForPeer(
conversationID = conversationID,
meshPeerID = meshPeerID,
meshService = meshService
)
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
}
}

View File

@ -26,4 +26,6 @@ android.nonTransitiveRClass=false
kotlin.code.style=official
# JVM heap size configuration to prevent OutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true