mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
parent
c8f45408b1
commit
5a38aaf3e8
@ -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()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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("-", "+")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)}" }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user