mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 06:25:21 +00:00
Merge branch 'main' into wifi-aware-refactor-mesh-core
# Conflicts: # app/src/main/AndroidManifest.xml # app/src/main/java/com/bitchat/android/BitchatApplication.kt # app/src/main/java/com/bitchat/android/MainActivity.kt # app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt # app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt # app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt # app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt # app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
This commit is contained in:
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
object AppStateStore {
|
||||
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
// Connected peer IDs (mesh ephemeral IDs)
|
||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||
@@ -35,8 +36,10 @@ object AppStateStore {
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
val publicKey = publicMessageKey(msg)
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
seenPublicMessageKeys.add(publicKey)
|
||||
_publicMessages.value = _publicMessages.value + msg
|
||||
}
|
||||
}
|
||||
@@ -100,10 +103,22 @@ object AppStateStore {
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
_peers.value = emptyList()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
_channelMessages.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publicMessageKey(msg: BitchatMessage): String {
|
||||
val sender = msg.senderPeerID ?: msg.sender
|
||||
return listOf(
|
||||
sender,
|
||||
msg.timestamp.time.toString(),
|
||||
msg.type.name,
|
||||
msg.channel ?: "",
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ object ConversationAliasResolver {
|
||||
if (selected != null && keysToMerge.contains(selected)) {
|
||||
state.setSelectedPrivateChatPeer(targetPeerID)
|
||||
}
|
||||
|
||||
// Switch sheet peer if currently viewing an alias that got merged
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
|
||||
state.setPrivateChatSheetPeer(targetPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.bitchat.android.nostr.NostrTransport
|
||||
*/
|
||||
class MessageRouter private constructor(
|
||||
private val context: Context,
|
||||
private val mesh: BluetoothMeshService,
|
||||
private var mesh: BluetoothMeshService,
|
||||
private val nostr: NostrTransport
|
||||
) {
|
||||
companion object {
|
||||
@@ -19,22 +19,22 @@ class MessageRouter private constructor(
|
||||
@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
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: run {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
} catch (_: Exception) {}
|
||||
INSTANCE = instance
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always update mesh reference and sync peer ID
|
||||
instance.mesh = mesh
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
read.clear()
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun trim(set: LinkedHashSet<String>) {
|
||||
if (set.size <= MAX_IDS) return
|
||||
val it = set.iterator()
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.SecureRandom
|
||||
import androidx.core.net.toUri
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* QR verification helpers: schema, signing, and basic challenge/response helpers.
|
||||
*/
|
||||
object VerificationService {
|
||||
private const val CONTEXT = "bitchat-verify-v1"
|
||||
private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1"
|
||||
|
||||
private var encryptionServiceRef: WeakReference<EncryptionService>? = null
|
||||
|
||||
fun configure(encryptionService: EncryptionService) {
|
||||
this.encryptionServiceRef = WeakReference(encryptionService)
|
||||
}
|
||||
|
||||
data class VerificationQR(
|
||||
val v: Int,
|
||||
val noiseKeyHex: String,
|
||||
val signKeyHex: String,
|
||||
val npub: String?,
|
||||
val nickname: String,
|
||||
val ts: Long,
|
||||
val nonceB64: String,
|
||||
val sigHex: String
|
||||
) {
|
||||
fun canonicalBytes(): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
|
||||
fun appendField(value: String) {
|
||||
val data = value.toByteArray(Charsets.UTF_8)
|
||||
val len = minOf(data.size, 255)
|
||||
out.write(len)
|
||||
out.write(data, 0, len)
|
||||
}
|
||||
|
||||
appendField(CONTEXT)
|
||||
appendField(v.toString())
|
||||
appendField(noiseKeyHex.lowercase())
|
||||
appendField(signKeyHex.lowercase())
|
||||
appendField(npub ?: "")
|
||||
appendField(nickname)
|
||||
appendField(ts.toString())
|
||||
appendField(nonceB64)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun toUrlString(): String {
|
||||
val builder = Uri.Builder()
|
||||
.scheme("bitchat")
|
||||
.authority("verify")
|
||||
.appendQueryParameter("v", v.toString())
|
||||
.appendQueryParameter("noise", noiseKeyHex)
|
||||
.appendQueryParameter("sign", signKeyHex)
|
||||
.appendQueryParameter("nick", nickname)
|
||||
.appendQueryParameter("ts", ts.toString())
|
||||
.appendQueryParameter("nonce", nonceB64)
|
||||
.appendQueryParameter("sig", sigHex)
|
||||
if (npub != null) {
|
||||
builder.appendQueryParameter("npub", npub)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromUrlString(urlString: String): VerificationQR? {
|
||||
val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null
|
||||
if (uri.scheme != "bitchat" || uri.host != "verify") return null
|
||||
|
||||
val vStr = uri.getQueryParameter("v") ?: return null
|
||||
val v = vStr.toIntOrNull() ?: return null
|
||||
val noise = uri.getQueryParameter("noise") ?: return null
|
||||
val sign = uri.getQueryParameter("sign") ?: return null
|
||||
val nick = uri.getQueryParameter("nick") ?: return null
|
||||
val tsStr = uri.getQueryParameter("ts") ?: return null
|
||||
val ts = tsStr.toLongOrNull() ?: return null
|
||||
val nonce = uri.getQueryParameter("nonce") ?: return null
|
||||
val sig = uri.getQueryParameter("sig") ?: return null
|
||||
val npub = uri.getQueryParameter("npub")
|
||||
|
||||
return VerificationQR(
|
||||
v = v,
|
||||
noiseKeyHex = noise,
|
||||
signKeyHex = sign,
|
||||
npub = npub,
|
||||
nickname = nick,
|
||||
ts = ts,
|
||||
nonceB64 = nonce,
|
||||
sigHex = sig
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val cache = Cache.last
|
||||
if (cache != null && cache.nickname == nickname && cache.npub == npub) {
|
||||
if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) {
|
||||
return cache.value
|
||||
}
|
||||
}
|
||||
|
||||
val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null
|
||||
val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null
|
||||
val ts = System.currentTimeMillis() / 1000L
|
||||
val nonce = ByteArray(16)
|
||||
SecureRandom().nextBytes(nonce)
|
||||
val nonceB64 = Base64.encodeToString(
|
||||
nonce,
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
|
||||
val payload = VerificationQR(
|
||||
v = 1,
|
||||
noiseKeyHex = noiseKey,
|
||||
signKeyHex = signKey,
|
||||
npub = npub,
|
||||
nickname = nickname,
|
||||
ts = ts,
|
||||
nonceB64 = nonceB64,
|
||||
sigHex = ""
|
||||
)
|
||||
|
||||
val signature = service.signData(payload.canonicalBytes()) ?: return null
|
||||
val signed = payload.copy(sigHex = signature.hexEncodedString())
|
||||
val out = signed.toUrlString()
|
||||
Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
fun verifyScannedQR(
|
||||
urlString: String,
|
||||
maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS
|
||||
): VerificationQR? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val qr = VerificationQR.fromUrlString(urlString) ?: return null
|
||||
val now = System.currentTimeMillis() / 1000L
|
||||
if (now - qr.ts > maxAgeSeconds) return null
|
||||
|
||||
val sig = qr.sigHex.dataFromHexString() ?: return null
|
||||
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
|
||||
val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey)
|
||||
return if (ok) qr else null
|
||||
}
|
||||
|
||||
fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray {
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(0x01)
|
||||
out.write(minOf(noiseData.size, 255))
|
||||
out.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
out.write(0x02)
|
||||
out.write(minOf(nonceA.size, 255))
|
||||
out.write(nonceA, 0, minOf(nonceA.size, 255))
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val msg = ByteArrayOutputStream()
|
||||
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
msg.write(minOf(noiseData.size, 255))
|
||||
msg.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
msg.write(nonceA)
|
||||
val sig = service.signData(msg.toByteArray()) ?: return null
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(0x01)
|
||||
out.write(minOf(noiseData.size, 255))
|
||||
out.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
out.write(0x02)
|
||||
out.write(minOf(nonceA.size, 255))
|
||||
out.write(nonceA, 0, minOf(nonceA.size, 255))
|
||||
out.write(0x03)
|
||||
out.write(minOf(sig.size, 255))
|
||||
out.write(sig, 0, minOf(sig.size, 255))
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun parseVerifyChallenge(data: ByteArray): Pair<String, ByteArray>? {
|
||||
var idx = 0
|
||||
|
||||
fun take(n: Int): ByteArray? {
|
||||
if (idx + n > data.size) return null
|
||||
val out = data.copyOfRange(idx, idx + n)
|
||||
idx += n
|
||||
return out
|
||||
}
|
||||
|
||||
val t1 = take(1) ?: return null
|
||||
if (t1[0].toInt() != 0x01) return null
|
||||
val l1 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val noiseBytes = take(l1) ?: return null
|
||||
val noise = noiseBytes.toString(Charsets.UTF_8)
|
||||
|
||||
val t2 = take(1) ?: return null
|
||||
if (t2[0].toInt() != 0x02) return null
|
||||
val l2 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val nonce = take(l2) ?: return null
|
||||
|
||||
return noise to nonce
|
||||
}
|
||||
|
||||
data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as VerifyResponse
|
||||
|
||||
if (noiseKeyHex != other.noiseKeyHex) return false
|
||||
if (!nonceA.contentEquals(other.nonceA)) return false
|
||||
if (!signature.contentEquals(other.signature)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = noiseKeyHex.hashCode()
|
||||
result = 31 * result + nonceA.contentHashCode()
|
||||
result = 31 * result + signature.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun parseVerifyResponse(data: ByteArray): VerifyResponse? {
|
||||
var idx = 0
|
||||
|
||||
fun take(n: Int): ByteArray? {
|
||||
if (idx + n > data.size) return null
|
||||
val out = data.copyOfRange(idx, idx + n)
|
||||
idx += n
|
||||
return out
|
||||
}
|
||||
|
||||
val t1 = take(1) ?: return null
|
||||
if (t1[0].toInt() != 0x01) return null
|
||||
val l1 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val noiseBytes = take(l1) ?: return null
|
||||
val noise = noiseBytes.toString(Charsets.UTF_8)
|
||||
|
||||
val t2 = take(1) ?: return null
|
||||
if (t2[0].toInt() != 0x02) return null
|
||||
val l2 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val nonce = take(l2) ?: return null
|
||||
|
||||
val t3 = take(1) ?: return null
|
||||
if (t3[0].toInt() != 0x03) return null
|
||||
val l3 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val sig = take(l3) ?: return null
|
||||
|
||||
return VerifyResponse(noise, nonce, sig)
|
||||
}
|
||||
|
||||
fun verifyResponseSignature(
|
||||
noiseKeyHex: String,
|
||||
nonceA: ByteArray,
|
||||
signature: ByteArray,
|
||||
signerPublicKeyHex: String
|
||||
): Boolean {
|
||||
val service = encryptionServiceRef?.get() ?: return false
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val msg = ByteArrayOutputStream()
|
||||
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
msg.write(minOf(noiseData.size, 255))
|
||||
msg.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
msg.write(nonceA)
|
||||
val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false
|
||||
return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey)
|
||||
}
|
||||
|
||||
private data class CacheEntry(
|
||||
val nickname: String,
|
||||
val npub: String?,
|
||||
val builtAtMs: Long,
|
||||
val value: String
|
||||
)
|
||||
|
||||
private object Cache {
|
||||
var last: CacheEntry? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
|
||||
* Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs]
|
||||
*/
|
||||
object GossipTLV {
|
||||
// TLV type for a compact list of direct neighbor peerIDs (each 8 bytes)
|
||||
const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u
|
||||
|
||||
/**
|
||||
* Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value.
|
||||
*/
|
||||
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
|
||||
val unique = peerIDs.distinct().take(10)
|
||||
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
|
||||
if (valueBytes.size > 255) {
|
||||
// Safety check, though 10*8 = 80 bytes, so well under 255
|
||||
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
|
||||
}
|
||||
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a TLV-encoded announce payload and extract neighbor peerIDs.
|
||||
* Returns null if the TLV is not present at all; returns an empty list if present with length 0.
|
||||
*/
|
||||
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String>? {
|
||||
val result = mutableListOf<String>()
|
||||
var offset = 0
|
||||
while (offset + 2 <= payload.size) {
|
||||
val type = payload[offset].toUByte()
|
||||
val len = payload[offset + 1].toUByte().toInt()
|
||||
offset += 2
|
||||
if (offset + len > payload.size) break
|
||||
val value = payload.sliceArray(offset until offset + len)
|
||||
offset += len
|
||||
|
||||
if (type == DIRECT_NEIGHBORS_TYPE) {
|
||||
// Value is N*8 bytes of peer IDs
|
||||
var pos = 0
|
||||
while (pos + 8 <= value.size) {
|
||||
val idBytes = value.sliceArray(pos until pos + 8)
|
||||
result.add(bytesToPeerIdHex(idBytes))
|
||||
pos += 8
|
||||
}
|
||||
return result // present (possibly empty)
|
||||
}
|
||||
}
|
||||
// Not present
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
|
||||
val clean = hexString.lowercase().take(16)
|
||||
val result = ByteArray(8) { 0 }
|
||||
var idx = 0
|
||||
var out = 0
|
||||
while (idx + 1 < clean.length && out < 8) {
|
||||
val byteStr = clean.substring(idx, idx + 2)
|
||||
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
|
||||
result[out++] = b
|
||||
idx += 2
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun bytesToPeerIdHex(bytes: ByteArray): String {
|
||||
val sb = StringBuilder()
|
||||
for (b in bytes.take(8)) {
|
||||
sb.append(String.format("%02x", b))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Maintains an internal graph of the mesh based on gossip.
|
||||
* Nodes are peers (peerID), edges are direct connections.
|
||||
*/
|
||||
class MeshGraphService private constructor() {
|
||||
data class GraphNode(val peerID: String, val nickname: String?)
|
||||
data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null)
|
||||
data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>)
|
||||
|
||||
// Map peerID -> nickname (may be null if unknown)
|
||||
private val nicknames = ConcurrentHashMap<String, String?>()
|
||||
// Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see
|
||||
private val announcements = ConcurrentHashMap<String, Set<String>>()
|
||||
// Latest announcement timestamp per peer (ULong from packet)
|
||||
private val lastUpdate = ConcurrentHashMap<String, ULong>()
|
||||
|
||||
private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList()))
|
||||
val graphState: StateFlow<GraphSnapshot> = _graphState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Update graph from a verified announcement.
|
||||
* Replaces previous neighbors for origin if this is newer (by timestamp).
|
||||
*/
|
||||
fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List<String>?, timestamp: ULong) {
|
||||
synchronized(this) {
|
||||
// Always update nickname if provided
|
||||
if (originNickname != null) nicknames[originPeerID] = originNickname
|
||||
|
||||
// 1. Check timestamp first to ensure this is the latest word from the peer
|
||||
val prevTs = lastUpdate[originPeerID]
|
||||
if (prevTs != null && prevTs >= timestamp) {
|
||||
// Older or equal update: ignore
|
||||
return
|
||||
}
|
||||
lastUpdate[originPeerID] = timestamp
|
||||
|
||||
// 2. Latest announcement determines state.
|
||||
// If neighborsOrNull is null (TLV omitted), it means the peer is not reporting any neighbors (empty list).
|
||||
val neighbors = neighborsOrNull ?: emptyList()
|
||||
|
||||
// Filter out self-loops just in case
|
||||
val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toSet()
|
||||
announcements[originPeerID] = newSet
|
||||
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNickname(peerID: String, nickname: String?) {
|
||||
if (nickname == null) return
|
||||
nicknames[peerID] = nickname
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a peer from the graph completely (e.g. when stale/offline).
|
||||
*/
|
||||
fun removePeer(peerID: String) {
|
||||
synchronized(this) {
|
||||
nicknames.remove(peerID)
|
||||
announcements.remove(peerID)
|
||||
lastUpdate.remove(peerID)
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishSnapshot() {
|
||||
// Collect all known nodes from nicknames and announcements
|
||||
val allNodes = mutableSetOf<String>()
|
||||
allNodes.addAll(nicknames.keys)
|
||||
announcements.forEach { (origin, neighbors) ->
|
||||
allNodes.add(origin)
|
||||
allNodes.addAll(neighbors)
|
||||
}
|
||||
|
||||
val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
|
||||
|
||||
val edges = mutableListOf<GraphEdge>()
|
||||
val processedPairs = mutableSetOf<Pair<String, String>>()
|
||||
|
||||
// We only care about connections that exist in at least one direction.
|
||||
// So iterating through all entries in `announcements` covers every declared edge.
|
||||
announcements.forEach { (source, targets) ->
|
||||
targets.forEach { target ->
|
||||
val pair = if (source <= target) source to target else target to source
|
||||
if (processedPairs.add(pair)) {
|
||||
// This is a new pair we haven't evaluated yet
|
||||
val (a, b) = pair
|
||||
val aAnnouncesB = announcements[a]?.contains(b) == true
|
||||
val bAnnouncesA = announcements[b]?.contains(a) == true
|
||||
|
||||
if (aAnnouncesB && bAnnouncesA) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = true))
|
||||
} else if (aAnnouncesB) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a))
|
||||
} else if (bAnnouncesA) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b }))
|
||||
_graphState.value = GraphSnapshot(nodeList, sortedEdges)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MeshGraphService? = null
|
||||
fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: MeshGraphService().also { INSTANCE = it }
|
||||
}
|
||||
|
||||
@org.jetbrains.annotations.TestOnly
|
||||
fun resetForTesting() {
|
||||
synchronized(this) {
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import android.util.Log
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* Computes shortest paths on the current mesh graph snapshot using Dijkstra.
|
||||
* Assumes unit edge weights.
|
||||
*/
|
||||
object RoutePlanner {
|
||||
private const val TAG = "RoutePlanner"
|
||||
|
||||
/**
|
||||
* Return full path [src, ..., dst] if reachable, else null.
|
||||
*/
|
||||
fun shortestPath(src: String, dst: String): List<String>? {
|
||||
if (src == dst) return listOf(src)
|
||||
val snapshot = MeshGraphService.getInstance().graphState.value
|
||||
val neighbors = mutableMapOf<String, MutableSet<String>>()
|
||||
|
||||
// Only consider confirmed edges for routing
|
||||
snapshot.edges.filter { it.isConfirmed }.forEach { e ->
|
||||
neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b)
|
||||
neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a)
|
||||
}
|
||||
// Ensure nodes known even if isolated
|
||||
snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) }
|
||||
|
||||
if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null
|
||||
|
||||
val dist = mutableMapOf<String, Int>()
|
||||
val prev = mutableMapOf<String, String?>()
|
||||
val pq = PriorityQueue<Pair<String, Int>>(compareBy { it.second })
|
||||
|
||||
neighbors.keys.forEach { v ->
|
||||
dist[v] = if (v == src) 0 else Int.MAX_VALUE
|
||||
prev[v] = null
|
||||
}
|
||||
pq.add(src to 0)
|
||||
|
||||
while (pq.isNotEmpty()) {
|
||||
val top = pq.poll() ?: break
|
||||
val (u, d) = top
|
||||
if (d > (dist[u] ?: Int.MAX_VALUE)) continue
|
||||
if (u == dst) break
|
||||
neighbors[u]?.forEach { v ->
|
||||
val alt = d + 1
|
||||
if (alt < (dist[v] ?: Int.MAX_VALUE)) {
|
||||
dist[v] = alt
|
||||
prev[v] = u
|
||||
pq.add(v to alt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null
|
||||
|
||||
val path = mutableListOf<String>()
|
||||
var cur: String? = dst
|
||||
while (cur != null) {
|
||||
path.add(cur)
|
||||
cur = prev[cur]
|
||||
}
|
||||
path.reverse()
|
||||
Log.d(TAG, "Computed path $path")
|
||||
return path
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user