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