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:
callebtc
2025-08-25 18:38:10 +02:00
committed by GitHub
parent 941be1b98f
commit 6fe6e049ad
15 changed files with 1153 additions and 173 deletions
@@ -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()
)
}