mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
Nostr refactor simplify (#390)
* fix bug * geoDM receive works, send doesnt, and incoming message doesnt make sender appear in peer list * fix nostr dm * geohash dms work * Geohash DM UI: stop mixing Nostr DM temp chats into mesh offline list; ensure geohash DM senders are added to geohash people list only. Removed nostr_* sidebar append in PeopleSection; kept 64-hex mesh offline favorites. Verified build. * refactor * nice * works * merging nostr -> mesh works * tripple click to delete all * fix sidebar icon * remove hash * dms have correct recipient * works * wip unread badge * geohash dms wip * dms work
This commit is contained in:
@@ -9,7 +9,8 @@ import java.util.*
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridging Noise and Nostr favorites
|
* Bridging Noise and Nostr favorites
|
||||||
* Direct port from iOS FavoritesPersistenceService.swift
|
* Direct port from iOS FavoritesPersistenceService.swift, with Android-specific
|
||||||
|
* peerID (16-hex) -> npub indexing for Nostr DM routing.
|
||||||
*/
|
*/
|
||||||
data class FavoriteRelationship(
|
data class FavoriteRelationship(
|
||||||
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||||
@@ -54,13 +55,14 @@ interface FavoritesChangeListener {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages favorites with Noise↔Nostr mapping
|
* Manages favorites with Noise↔Nostr mapping
|
||||||
* Singleton pattern matching iOS implementation
|
* Singleton pattern matching iOS implementation.
|
||||||
*/
|
*/
|
||||||
class FavoritesPersistenceService private constructor(private val context: Context) {
|
class FavoritesPersistenceService private constructor(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "FavoritesPersistenceService"
|
private const val TAG = "FavoritesPersistenceService"
|
||||||
private const val FAVORITES_KEY = "favorite_relationships"
|
private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship
|
||||||
|
private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: FavoritesPersistenceService? = null
|
private var INSTANCE: FavoritesPersistenceService? = null
|
||||||
@@ -81,39 +83,33 @@ 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>() // noiseHex -> relationship
|
||||||
|
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
|
||||||
|
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
|
||||||
private val listeners = mutableListOf<FavoritesChangeListener>()
|
private val listeners = mutableListOf<FavoritesChangeListener>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
loadPeerIdIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get favorite status for Noise public key */
|
||||||
* Get favorite status for Noise public key
|
|
||||||
*/
|
|
||||||
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
return favorites[keyHex]
|
return favorites[keyHex]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
|
||||||
* Get favorite status for 16-hex peerID
|
|
||||||
*/
|
|
||||||
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
||||||
// For 16-hex peerIDs, we need to find the corresponding full Noise key
|
val pid = peerID.lowercase()
|
||||||
// This is a simplified lookup - in practice you'd use fingerprint matching
|
|
||||||
for ((_, relationship) in favorites) {
|
for ((_, relationship) in favorites) {
|
||||||
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
if (noiseKeyHex.startsWith(peerID)) {
|
if (noiseKeyHex.startsWith(pid)) return relationship
|
||||||
return relationship
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Update Nostr public key for a peer (indexed by Noise key) */
|
||||||
* Update Nostr public key for a peer
|
|
||||||
*/
|
|
||||||
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
val existing = favorites[keyHex]
|
val existing = favorites[keyHex]
|
||||||
@@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
)
|
)
|
||||||
favorites[keyHex] = updated
|
favorites[keyHex] = updated
|
||||||
} else {
|
} else {
|
||||||
// Create new relationship
|
|
||||||
val relationship = FavoriteRelationship(
|
val relationship = FavoriteRelationship(
|
||||||
peerNoisePublicKey = noisePublicKey,
|
peerNoisePublicKey = noisePublicKey,
|
||||||
peerNostrPublicKey = nostrPubkey,
|
peerNostrPublicKey = nostrPubkey,
|
||||||
@@ -143,11 +138,46 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
|
||||||
* Update favorite status
|
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
|
||||||
*/
|
val pid = peerID.lowercase()
|
||||||
|
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
|
||||||
|
peerIdIndex[pid] = nostrPubkey
|
||||||
|
savePeerIdIndex()
|
||||||
|
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
|
||||||
|
fun findNostrPubkeyForPeerID(peerID: String): String? {
|
||||||
|
return peerIdIndex[peerID.lowercase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
|
||||||
|
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
|
||||||
|
// First, try direct match in peerIdIndex (values are stored as npub strings)
|
||||||
|
peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key }
|
||||||
|
|
||||||
|
// Attempt legacy mapping via favorites Noise key association
|
||||||
|
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
|
||||||
|
if (targetHex != null) {
|
||||||
|
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
|
||||||
|
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
|
||||||
|
if (rel != null) {
|
||||||
|
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
|
// Return 16-hex prefix as best-effort if no explicit mapping exists
|
||||||
|
return noiseHex.take(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update favorite status */
|
||||||
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
|
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
val existing = favorites[keyHex]
|
val existing = favorites[keyHex]
|
||||||
|
|
||||||
val updated = if (existing != null) {
|
val updated = if (existing != null) {
|
||||||
@@ -176,9 +206,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Update peer favorited-us flag */
|
||||||
* Update peer favorited us status
|
|
||||||
*/
|
|
||||||
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
val existing = favorites[keyHex]
|
val existing = favorites[keyHex]
|
||||||
@@ -196,55 +224,36 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
|
||||||
* Get all mutual favorites
|
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
|
||||||
*/
|
|
||||||
fun getMutualFavorites(): List<FavoriteRelationship> {
|
|
||||||
return favorites.values.filter { it.isMutual }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all favorites we have
|
|
||||||
*/
|
|
||||||
fun getOurFavorites(): List<FavoriteRelationship> {
|
|
||||||
return favorites.values.filter { it.isFavorite }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all favorites
|
|
||||||
*/
|
|
||||||
fun clearAllFavorites() {
|
fun clearAllFavorites() {
|
||||||
favorites.clear()
|
favorites.clear()
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
peerIdIndex.clear()
|
||||||
|
savePeerIdIndex()
|
||||||
Log.i(TAG, "Cleared all favorites")
|
Log.i(TAG, "Cleared all favorites")
|
||||||
notifyAllCleared()
|
notifyAllCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Find Noise key by Nostr pubkey */
|
||||||
* Find Noise key by Nostr pubkey
|
|
||||||
*/
|
|
||||||
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||||
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
||||||
return favorites.values.firstOrNull { rel ->
|
return favorites.values.firstOrNull { rel ->
|
||||||
rel.peerNostrPublicKey?.let { stored ->
|
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
|
||||||
normalizeNostrKeyToHex(stored)
|
|
||||||
} == targetHex
|
|
||||||
}?.peerNoisePublicKey
|
}?.peerNoisePublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Find Nostr pubkey by Noise key */
|
||||||
* Find Nostr pubkey by Noise key
|
|
||||||
*/
|
|
||||||
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
||||||
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
||||||
return favorites[keyHex]?.peerNostrPublicKey
|
return favorites[keyHex]?.peerNostrPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Persistence
|
||||||
|
|
||||||
private fun loadFavorites() {
|
private fun loadFavorites() {
|
||||||
try {
|
try {
|
||||||
// Use public methods instead of reflection to access encrypted preferences
|
|
||||||
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
|
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
|
||||||
if (favoritesJson != null) {
|
if (favoritesJson != null) {
|
||||||
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
||||||
@@ -252,10 +261,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
|
|
||||||
favorites.clear()
|
favorites.clear()
|
||||||
data.forEach { (key, relationshipData) ->
|
data.forEach { (key, relationshipData) ->
|
||||||
val relationship = relationshipData.toFavoriteRelationship()
|
favorites[key] = relationshipData.toFavoriteRelationship()
|
||||||
favorites[key] = relationship
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -265,27 +272,45 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
|
|
||||||
private fun saveFavorites() {
|
private fun saveFavorites() {
|
||||||
try {
|
try {
|
||||||
// Convert to serializable format
|
|
||||||
val data = favorites.mapValues { (_, relationship) ->
|
val data = favorites.mapValues { (_, relationship) ->
|
||||||
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
||||||
}
|
}
|
||||||
|
|
||||||
val favoritesJson = gson.toJson(data)
|
val favoritesJson = gson.toJson(data)
|
||||||
|
|
||||||
// Use public methods instead of reflection to access encrypted preferences
|
|
||||||
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
|
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
|
||||||
|
|
||||||
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadPeerIdIndex() {
|
||||||
|
try {
|
||||||
|
val json = stateManager.getSecureValue(PEERID_INDEX_KEY)
|
||||||
|
if (json != null) {
|
||||||
|
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
val data: Map<String, String> = gson.fromJson(json, type)
|
||||||
|
peerIdIndex.clear()
|
||||||
|
peerIdIndex.putAll(data)
|
||||||
|
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load peerID index: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savePeerIdIndex() {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(peerIdIndex)
|
||||||
|
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
|
||||||
|
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save peerID index: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Listeners
|
// MARK: - Listeners
|
||||||
fun addListener(listener: FavoritesChangeListener) {
|
fun addListener(listener: FavoritesChangeListener) {
|
||||||
synchronized(listeners) {
|
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
|
||||||
if (!listeners.contains(listener)) listeners.add(listener)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fun removeListener(listener: FavoritesChangeListener) {
|
fun removeListener(listener: FavoritesChangeListener) {
|
||||||
synchronized(listeners) { listeners.remove(listener) }
|
synchronized(listeners) { listeners.remove(listener) }
|
||||||
@@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
snapshot.forEach { runCatching { it.onAllCleared() } }
|
snapshot.forEach { runCatching { it.onAllCleared() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
|
||||||
* Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison
|
private fun normalizeNostrKeyToHex(value: String): String? = try {
|
||||||
*/
|
|
||||||
private fun normalizeNostrKeyToHex(value: String): String? {
|
|
||||||
return try {
|
|
||||||
if (value.startsWith("npub1")) {
|
if (value.startsWith("npub1")) {
|
||||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
|
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
|
||||||
if (hrp != "npub") return null
|
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
|
||||||
data.joinToString("") { "%02x".format(it) }
|
} else value.lowercase()
|
||||||
} else {
|
|
||||||
// Assume hex
|
|
||||||
value.lowercase()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Serializable data for JSON storage */
|
||||||
* Serializable data class for JSON storage
|
|
||||||
*/
|
|
||||||
private data class FavoriteRelationshipData(
|
private data class FavoriteRelationshipData(
|
||||||
val peerNoisePublicKeyHex: String,
|
val peerNoisePublicKeyHex: String,
|
||||||
val peerNostrPublicKey: String?,
|
val peerNostrPublicKey: String?,
|
||||||
|
|||||||
@@ -285,6 +285,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
// Store fingerprint for the peer via centralized fingerprint manager
|
// Store fingerprint for the peer via centralized fingerprint manager
|
||||||
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
||||||
|
|
||||||
|
// Index existing Nostr mapping by the new peerID if we have it
|
||||||
|
try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
// If there was a previous peer ID, remove it to avoid duplicates
|
// If there was a previous peer ID, remove it to avoid duplicates
|
||||||
previousPeerID?.let { oldPeerID ->
|
previousPeerID?.let { oldPeerID ->
|
||||||
peerManager.removePeer(oldPeerID)
|
peerManager.removePeer(oldPeerID)
|
||||||
@@ -539,25 +546,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
||||||
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||||
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
|
if (recipientNickname.isEmpty()) return
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||||
|
|
||||||
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
||||||
|
|
||||||
// Check if this is a Nostr contact (geohash DM)
|
|
||||||
if (recipientPeerID.startsWith("nostr_")) {
|
|
||||||
// Get NostrGeohashService instance and send via Nostr
|
|
||||||
try {
|
|
||||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
|
||||||
nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}")
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have an established Noise session
|
// Check if we have an established Noise session
|
||||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||||
try {
|
try {
|
||||||
@@ -625,15 +620,14 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||||
|
|
||||||
// Check if this is a Nostr contact (geohash DM)
|
// Route geohash read receipts via MessageRouter instead of here
|
||||||
if (recipientPeerID.startsWith("nostr_")) {
|
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
||||||
// Get NostrGeohashService instance and send read receipt via Nostr
|
val isGeoAlias = try {
|
||||||
try {
|
val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot()
|
||||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
map.containsKey(recipientPeerID)
|
||||||
nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID)
|
} catch (_: Exception) { false }
|
||||||
} catch (e: Exception) {
|
if (isGeoAlias && geo != null) {
|
||||||
Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}")
|
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,9 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
if (noiseKey != null) {
|
if (noiseKey != null) {
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
||||||
if (npub != null) {
|
if (npub != null) {
|
||||||
|
// Index by noise key and current mesh peerID for fast Nostr routing
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine iOS-style guidance text
|
// Determine iOS-style guidance text
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeohashAliasRegistry
|
||||||
|
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
|
||||||
|
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
|
||||||
|
*/
|
||||||
|
object GeohashAliasRegistry {
|
||||||
|
private val map: MutableMap<String, String> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
fun put(alias: String, pubkeyHex: String) {
|
||||||
|
map[alias] = pubkeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(alias: String): String? = map[alias]
|
||||||
|
|
||||||
|
fun contains(alias: String): Boolean = map.containsKey(alias)
|
||||||
|
|
||||||
|
fun snapshot(): Map<String, String> = HashMap(map)
|
||||||
|
|
||||||
|
fun clear() { map.clear() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.ui.ChatState
|
||||||
|
import com.bitchat.android.ui.MessageManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeohashMessageHandler
|
||||||
|
* - Processes kind=20000 Nostr events for geohash channels
|
||||||
|
* - Updates repository for participants + nicknames
|
||||||
|
* - Emits messages to MessageManager
|
||||||
|
*/
|
||||||
|
class GeohashMessageHandler(
|
||||||
|
private val application: Application,
|
||||||
|
private val state: ChatState,
|
||||||
|
private val messageManager: MessageManager,
|
||||||
|
private val repo: GeohashRepository,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "GeohashMessageHandler" }
|
||||||
|
|
||||||
|
// Simple event deduplication
|
||||||
|
private val processedIds = ArrayDeque<String>()
|
||||||
|
private val seen = HashSet<String>()
|
||||||
|
private val max = 2000
|
||||||
|
|
||||||
|
private fun dedupe(id: String): Boolean {
|
||||||
|
if (seen.contains(id)) return true
|
||||||
|
seen.add(id)
|
||||||
|
processedIds.addLast(id)
|
||||||
|
if (processedIds.size > max) {
|
||||||
|
val old = processedIds.removeFirst()
|
||||||
|
seen.remove(old)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
if (event.kind != 20000) return@launch
|
||||||
|
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||||
|
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||||
|
if (dedupe(event.id)) return@launch
|
||||||
|
|
||||||
|
// PoW validation (if enabled)
|
||||||
|
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||||
|
if (pow.enabled && pow.difficulty > 0) {
|
||||||
|
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked users check
|
||||||
|
if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch
|
||||||
|
|
||||||
|
// Update repository (participants, nickname, teleport)
|
||||||
|
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||||
|
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||||
|
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
||||||
|
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
||||||
|
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
||||||
|
try {
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Skip our own events for message emission
|
||||||
|
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
||||||
|
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
||||||
|
|
||||||
|
val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } &&
|
||||||
|
event.content.trim().isEmpty()
|
||||||
|
if (isTeleportPresence) return@launch
|
||||||
|
|
||||||
|
val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey)
|
||||||
|
val msg = BitchatMessage(
|
||||||
|
id = event.id,
|
||||||
|
sender = senderName,
|
||||||
|
content = event.content,
|
||||||
|
timestamp = Date(event.createdAt * 1000L),
|
||||||
|
isRelay = false,
|
||||||
|
originalSender = repo.displayNameForNostrPubkey(event.pubkey),
|
||||||
|
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
||||||
|
mentions = null,
|
||||||
|
channel = "#$subscribedGeohash",
|
||||||
|
powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null }
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "onEvent error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.bitchat.android.ui.ChatState
|
||||||
|
import com.bitchat.android.ui.GeoPerson
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeohashRepository
|
||||||
|
* - Owns geohash participant tracking and nickname caching
|
||||||
|
* - Maintains lightweight state for geohash-related UI
|
||||||
|
*/
|
||||||
|
class GeohashRepository(
|
||||||
|
private val application: Application,
|
||||||
|
private val state: ChatState
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "GeohashRepository" }
|
||||||
|
|
||||||
|
// geohash -> (participant pubkeyHex -> lastSeen)
|
||||||
|
private val geohashParticipants: MutableMap<String, MutableMap<String, Date>> = mutableMapOf()
|
||||||
|
|
||||||
|
|
||||||
|
// pubkeyHex(lowercase) -> nickname (without #hash)
|
||||||
|
private val geoNicknames: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
|
||||||
|
private val conversationGeohash: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
fun setConversationGeohash(convKey: String, geohash: String) {
|
||||||
|
if (geohash.isNotEmpty()) {
|
||||||
|
conversationGeohash[convKey] = geohash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
|
||||||
|
|
||||||
|
fun findPubkeyByNickname(targetNickname: String): String? {
|
||||||
|
return geoNicknames.entries.firstOrNull { (_, nickname) ->
|
||||||
|
val base = nickname.split("#").firstOrNull() ?: nickname
|
||||||
|
base == targetNickname
|
||||||
|
}?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
|
||||||
|
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
// Current geohash in view
|
||||||
|
private var currentGeohash: String? = null
|
||||||
|
|
||||||
|
fun setCurrentGeohash(geo: String?) { currentGeohash = geo }
|
||||||
|
fun getCurrentGeohash(): String? = currentGeohash
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
geohashParticipants.clear()
|
||||||
|
geoNicknames.clear()
|
||||||
|
nostrKeyMapping.clear()
|
||||||
|
state.setGeohashPeople(emptyList())
|
||||||
|
state.setTeleportedGeo(emptySet())
|
||||||
|
state.setGeohashParticipantCounts(emptyMap())
|
||||||
|
currentGeohash = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheNickname(pubkeyHex: String, nickname: String) {
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
val previous = geoNicknames[lower]
|
||||||
|
geoNicknames[lower] = nickname
|
||||||
|
if (previous != nickname && currentGeohash != null) {
|
||||||
|
refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()]
|
||||||
|
|
||||||
|
fun markTeleported(pubkeyHex: String) {
|
||||||
|
val set = state.getTeleportedGeoValue().toMutableSet()
|
||||||
|
val key = pubkeyHex.lowercase()
|
||||||
|
if (!set.contains(key)) {
|
||||||
|
set.add(key)
|
||||||
|
// Background safe update
|
||||||
|
state.postTeleportedGeo(set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||||
|
return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
|
||||||
|
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
|
||||||
|
participants[participantId] = lastSeen
|
||||||
|
if (currentGeohash == geohash) refreshGeohashPeople()
|
||||||
|
updateReactiveParticipantCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun geohashParticipantCount(geohash: String): Int {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[geohash] ?: return 0
|
||||||
|
// prune expired
|
||||||
|
val it = participants.iterator()
|
||||||
|
while (it.hasNext()) {
|
||||||
|
val e = it.next()
|
||||||
|
if (e.value.before(cutoff)) it.remove()
|
||||||
|
}
|
||||||
|
return participants.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshGeohashPeople() {
|
||||||
|
val geohash = currentGeohash
|
||||||
|
if (geohash == null) {
|
||||||
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
|
state.postGeohashPeople(emptyList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[geohash] ?: mutableMapOf()
|
||||||
|
// prune expired
|
||||||
|
val it = participants.iterator()
|
||||||
|
while (it.hasNext()) {
|
||||||
|
val e = it.next()
|
||||||
|
if (e.value.before(cutoff)) it.remove()
|
||||||
|
}
|
||||||
|
geohashParticipants[geohash] = participants
|
||||||
|
val people = participants.map { (pubkeyHex, lastSeen) ->
|
||||||
|
val base = getCachedNickname(pubkeyHex) ?: "anon"
|
||||||
|
GeoPerson(
|
||||||
|
id = pubkeyHex.lowercase(),
|
||||||
|
displayName = base, // UI can add #hash if necessary
|
||||||
|
lastSeen = lastSeen
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.lastSeen }
|
||||||
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
|
state.postGeohashPeople(people)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateReactiveParticipantCounts() {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val counts = mutableMapOf<String, Int>()
|
||||||
|
for ((gh, participants) in geohashParticipants) {
|
||||||
|
val active = participants.values.count { !it.before(cutoff) }
|
||||||
|
counts[gh] = active
|
||||||
|
}
|
||||||
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
|
state.postGeohashParticipantCounts(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||||
|
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNostrKeyMapping(): Map<String, String> = nostrKeyMapping.toMap()
|
||||||
|
|
||||||
|
fun displayNameForNostrPubkey(pubkeyHex: String): String {
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
// Self nickname if matches current identity of current geohash
|
||||||
|
val current = currentGeohash
|
||||||
|
if (current != null) {
|
||||||
|
try {
|
||||||
|
val my = NostrIdentityBridge.deriveIdentity(current, application)
|
||||||
|
if (my.publicKeyHex.equals(lower, true)) {
|
||||||
|
return "${state.getNicknameValue()}#$suffix"
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
val nick = geoNicknames[lower] ?: "anon"
|
||||||
|
return "$nick#$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val current = currentGeohash
|
||||||
|
val base: String = try {
|
||||||
|
if (current != null) {
|
||||||
|
val my = NostrIdentityBridge.deriveIdentity(current, application)
|
||||||
|
if (my.publicKeyHex.equals(lower, true)) {
|
||||||
|
state.getNicknameValue() ?: "anon"
|
||||||
|
} else geoNicknames[lower] ?: "anon"
|
||||||
|
} else geoNicknames[lower] ?: "anon"
|
||||||
|
} catch (_: Exception) { geoNicknames[lower] ?: "anon" }
|
||||||
|
if (current == null) return base
|
||||||
|
return try {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[current] ?: emptyMap()
|
||||||
|
var count = 0
|
||||||
|
for ((k, t) in participants) {
|
||||||
|
if (t.before(cutoff)) continue
|
||||||
|
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||||
|
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||||
|
}
|
||||||
|
if (!participants.containsKey(lower)) count += 1
|
||||||
|
if (count > 1) "$base#$suffix" else base
|
||||||
|
} catch (_: Exception) { base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for any geohash (not just current one) for header titles
|
||||||
|
*/
|
||||||
|
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val base = geoNicknames[lower] ?: "anon"
|
||||||
|
return try {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[sourceGeohash] ?: emptyMap()
|
||||||
|
var count = 0
|
||||||
|
for ((k, t) in participants) {
|
||||||
|
if (t.before(cutoff)) continue
|
||||||
|
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||||
|
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||||
|
}
|
||||||
|
if (!participants.containsKey(lower)) count += 1
|
||||||
|
if (count > 1) "$base#$suffix" else base
|
||||||
|
} catch (_: Exception) { base }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
import com.bitchat.android.services.SeenMessageStore
|
||||||
|
import com.bitchat.android.ui.ChatState
|
||||||
|
import com.bitchat.android.ui.MeshDelegateHandler
|
||||||
|
import com.bitchat.android.ui.PrivateChatManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class NostrDirectMessageHandler(
|
||||||
|
private val application: Application,
|
||||||
|
private val state: ChatState,
|
||||||
|
private val privateChatManager: PrivateChatManager,
|
||||||
|
private val meshDelegateHandler: MeshDelegateHandler,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val repo: GeohashRepository
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||||
|
|
||||||
|
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
|
||||||
|
|
||||||
|
// Simple event deduplication
|
||||||
|
private val processedIds = ArrayDeque<String>()
|
||||||
|
private val seen = HashSet<String>()
|
||||||
|
private val max = 2000
|
||||||
|
|
||||||
|
private fun dedupe(id: String): Boolean {
|
||||||
|
if (seen.contains(id)) return true
|
||||||
|
seen.add(id)
|
||||||
|
processedIds.addLast(id)
|
||||||
|
if (processedIds.size > max) {
|
||||||
|
val old = processedIds.removeFirst()
|
||||||
|
seen.remove(old)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
if (dedupe(giftWrap.id)) return@launch
|
||||||
|
|
||||||
|
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||||
|
if (messageAge > 173700) return@launch // 48 hours + 15 mins
|
||||||
|
|
||||||
|
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||||
|
if (decryptResult == null) {
|
||||||
|
Log.w(TAG, "Failed to decrypt Nostr message")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||||
|
if (!content.startsWith("bitchat1:")) return@launch
|
||||||
|
|
||||||
|
val base64Content = content.removePrefix("bitchat1:")
|
||||||
|
val packetData = base64URLDecode(base64Content) ?: return@launch
|
||||||
|
val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch
|
||||||
|
|
||||||
|
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||||
|
|
||||||
|
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||||
|
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||||
|
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||||
|
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||||
|
if (geohash.isNotEmpty()) {
|
||||||
|
// Remember which geohash this conversation belongs to so we can subscribe on-demand
|
||||||
|
repo.setConversationGeohash(convKey, geohash)
|
||||||
|
GeohashConversationRegistry.set(convKey, geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sender appears in geohash people list even if they haven't posted publicly yet
|
||||||
|
if (geohash.isNotEmpty()) {
|
||||||
|
// Cache a best-effort nickname and mark as participant
|
||||||
|
val cached = repo.getCachedNickname(senderPubkey)
|
||||||
|
if (cached == null) {
|
||||||
|
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
|
||||||
|
repo.cacheNickname(senderPubkey, base)
|
||||||
|
}
|
||||||
|
repo.updateParticipant(geohash, senderPubkey, messageTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
|
||||||
|
|
||||||
|
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "onGiftWrap error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processNoisePayload(
|
||||||
|
payload: com.bitchat.android.model.NoisePayload,
|
||||||
|
convKey: String,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
senderPubkey: String,
|
||||||
|
recipientIdentity: NostrIdentity
|
||||||
|
) {
|
||||||
|
when (payload.type) {
|
||||||
|
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||||
|
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
||||||
|
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||||
|
if (existingMessages.any { it.id == pm.messageID }) return
|
||||||
|
|
||||||
|
val message = BitchatMessage(
|
||||||
|
id = pm.messageID,
|
||||||
|
sender = senderNickname,
|
||||||
|
content = pm.content,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isRelay = false,
|
||||||
|
isPrivate = true,
|
||||||
|
recipientNickname = state.getNicknameValue(),
|
||||||
|
senderPeerID = convKey,
|
||||||
|
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
|
||||||
|
val suppressUnread = seenStore.hasRead(pm.messageID)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||||
|
val nostrTransport = NostrTransport.getInstance(application)
|
||||||
|
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||||
|
seenStore.markDelivered(pm.messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViewing && !suppressUnread) {
|
||||||
|
val nostrTransport = NostrTransport.getInstance(application)
|
||||||
|
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||||
|
seenStore.markRead(pm.messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||||
|
val messageId = String(payload.data, Charsets.UTF_8)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||||
|
val messageId = String(payload.data, Charsets.UTF_8)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64URLDecode(input: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val padded = input.replace("-", "+")
|
||||||
|
.replace("_", "/")
|
||||||
|
.let { str ->
|
||||||
|
val padding = (4 - str.length % 4) % 4
|
||||||
|
str + "=".repeat(padding)
|
||||||
|
}
|
||||||
|
android.util.Base64.decode(padded, android.util.Base64.DEFAULT)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode base64url: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NostrSubscriptionManager
|
||||||
|
* - Encapsulates subscription lifecycle with NostrRelayManager
|
||||||
|
*/
|
||||||
|
class NostrSubscriptionManager(
|
||||||
|
private val application: Application,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "NostrSubscriptionManager" }
|
||||||
|
|
||||||
|
private val relayManager get() = NostrRelayManager.getInstance(application)
|
||||||
|
|
||||||
|
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
|
||||||
|
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
|
||||||
|
|
||||||
|
fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
|
||||||
|
relayManager.subscribe(filter, id, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit)
|
||||||
|
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
|
||||||
|
}
|
||||||
@@ -56,8 +56,7 @@ class NostrTransport(
|
|||||||
// Resolve favorite by full noise key or by short peerID fallback
|
// Resolve favorite by full noise key or by short peerID fallback
|
||||||
var recipientNostrPubkey: String? = null
|
var recipientNostrPubkey: String? = null
|
||||||
|
|
||||||
// Try to resolve from favorites persistence service
|
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
|
||||||
// This would need integration with the existing favorites system
|
|
||||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||||
|
|
||||||
if (recipientNostrPubkey == null) {
|
if (recipientNostrPubkey == null) {
|
||||||
@@ -86,13 +85,23 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
|
||||||
|
val recipientPeerIDForEmbed = try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||||
|
.findPeerIDForNostrPubkey(recipientNostrPubkey)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
if (recipientPeerIDForEmbed.isNullOrBlank()) {
|
||||||
|
Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM. npub=${recipientNostrPubkey.take(16)}...")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||||
content = content,
|
content = content,
|
||||||
messageID = messageID,
|
messageID = messageID,
|
||||||
recipientPeerID = to,
|
recipientPeerID = recipientPeerIDForEmbed,
|
||||||
senderPeerID = senderPeerID
|
senderPeerID = senderPeerID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if (embedded == null) {
|
if (embedded == null) {
|
||||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||||
return@launch
|
return@launch
|
||||||
@@ -412,23 +421,43 @@ class NostrTransport(
|
|||||||
fun sendPrivateMessageGeohash(
|
fun sendPrivateMessageGeohash(
|
||||||
content: String,
|
content: String,
|
||||||
toRecipientHex: String,
|
toRecipientHex: String,
|
||||||
fromIdentity: NostrIdentity,
|
messageID: String,
|
||||||
messageID: String
|
sourceGeohash: String? = null
|
||||||
) {
|
) {
|
||||||
|
// Use provided geohash or derive from current location
|
||||||
|
val geohash = sourceGeohash ?: run {
|
||||||
|
val selected = try {
|
||||||
|
com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
if (selected !is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
|
Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected.channel.geohash
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromIdentity = try {
|
||||||
|
NostrIdentityBridge.deriveIdentity(geohash, context)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
transportScope.launch {
|
transportScope.launch {
|
||||||
try {
|
try {
|
||||||
if (toRecipientHex.isEmpty()) return@launch
|
if (toRecipientHex.isEmpty()) return@launch
|
||||||
|
|
||||||
Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}... geohash=$geohash"
|
||||||
|
)
|
||||||
|
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||||
content = content,
|
content = content,
|
||||||
messageID = messageID,
|
messageID = messageID,
|
||||||
senderPeerID = senderPeerID
|
senderPeerID = senderPeerID
|
||||||
)
|
) ?: run {
|
||||||
|
|
||||||
if (embedded == null) {
|
|
||||||
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -444,7 +473,6 @@ class NostrTransport(
|
|||||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -458,14 +486,15 @@ class NostrTransport(
|
|||||||
*/
|
*/
|
||||||
private fun resolveNostrPublicKey(peerID: String): String? {
|
private fun resolveNostrPublicKey(peerID: String): String? {
|
||||||
try {
|
try {
|
||||||
// Try to resolve from favorites persistence service
|
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
|
||||||
|
|
||||||
|
// 2) Legacy path: resolve by noise public key association
|
||||||
val noiseKey = hexStringToByteArray(peerID)
|
val noiseKey = hexStringToByteArray(peerID)
|
||||||
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||||
if (favoriteStatus?.peerNostrPublicKey != null) {
|
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
|
||||||
return favoriteStatus.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try with 16-hex peerID lookup
|
// 3) Prefix match on noiseHex from 16-hex peerID
|
||||||
if (peerID.length == 16) {
|
if (peerID.length == 16) {
|
||||||
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||||
return fallbackStatus?.peerNostrPublicKey
|
return fallbackStatus?.peerNostrPublicKey
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class MessageRouter private constructor(
|
|||||||
|
|
||||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||||
|
|
||||||
override fun onFavoriteChanged(noiseKeyHex: String) {
|
override fun onFavoriteChanged(noiseKeyHex: String) {
|
||||||
flushOutboxFor(noiseKeyHex)
|
flushOutboxFor(noiseKeyHex)
|
||||||
// Also try 16-hex short id commonly used in UI if any client used that
|
// Also try 16-hex short id commonly used in UI if any client used that
|
||||||
@@ -55,16 +56,30 @@ class MessageRouter private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
|
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
|
||||||
|
// First: if this is a geohash DM alias (nostr_<pub16>), route via Nostr using global registry
|
||||||
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
|
||||||
|
Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}…")
|
||||||
|
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
|
||||||
|
if (recipientHex != null) {
|
||||||
|
// Resolve the conversation's source geohash, so we can send from anywhere
|
||||||
|
val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID)
|
||||||
|
|
||||||
|
// If repository knows the source geohash, pass it so NostrTransport derives the correct identity
|
||||||
|
nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
||||||
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
||||||
if (hasMesh && hasEstablished) {
|
if (hasMesh && hasEstablished) {
|
||||||
Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}… id=${messageID.take(8)}…")
|
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
|
||||||
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||||
} else if (canSendViaNostr(toPeerID)) {
|
} else if (canSendViaNostr(toPeerID)) {
|
||||||
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}…")
|
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…")
|
||||||
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…")
|
Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
|
||||||
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
||||||
q.add(Triple(content, recipientNickname, messageID))
|
q.add(Triple(content, recipientNickname, messageID))
|
||||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
|
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
|
||||||
@@ -84,7 +99,14 @@ class MessageRouter private constructor(
|
|||||||
|
|
||||||
fun sendDeliveryAck(messageID: String, toPeerID: String) {
|
fun sendDeliveryAck(messageID: String, toPeerID: String) {
|
||||||
// Mesh delivery ACKs are sent by the receiver automatically.
|
// Mesh delivery ACKs are sent by the receiver automatically.
|
||||||
// Only route via Nostr when mesh path isn't available.
|
// Only route via Nostr when mesh path isn't available or when this is a geohash alias
|
||||||
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
|
||||||
|
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
|
||||||
|
if (recipientHex != null) {
|
||||||
|
nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
|
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
|
||||||
nostr.sendDeliveryAck(messageID, toPeerID)
|
nostr.sendDeliveryAck(messageID, toPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
@@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
|||||||
* Extracted from ChatScreen.kt for better organization
|
* Extracted from ChatScreen.kt for better organization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive helper to compute favorite state from fingerprint mapping
|
* Reactive helper to compute favorite state from fingerprint mapping
|
||||||
* This eliminates the need for static isFavorite parameters and makes
|
* This eliminates the need for static isFavorite parameters and makes
|
||||||
@@ -159,7 +161,6 @@ fun PeerCounter(
|
|||||||
connectedPeers: List<String>,
|
connectedPeers: List<String>,
|
||||||
joinedChannels: Set<String>,
|
joinedChannels: Set<String>,
|
||||||
hasUnreadChannels: Map<String, Int>,
|
hasUnreadChannels: Map<String, Int>,
|
||||||
hasUnreadPrivateMessages: Set<String>,
|
|
||||||
isConnected: Boolean,
|
isConnected: Boolean,
|
||||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||||
geohashPeople: List<GeoPerson>,
|
geohashPeople: List<GeoPerson>,
|
||||||
@@ -189,33 +190,6 @@ fun PeerCounter(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
||||||
) {
|
) {
|
||||||
if (hasUnreadChannels.values.any { it > 0 }) {
|
|
||||||
// Channel icon in a Box to ensure consistent size with other icons
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "#",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color(0xFF0080FF),
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
|
||||||
// Filled mail icon to match sidebar style
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Email,
|
|
||||||
contentDescription = "Unread private messages",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color(0xFFFF9500) // Orange to match private message theme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Group,
|
imageVector = Icons.Default.Group,
|
||||||
contentDescription = when (selectedLocationChannel) {
|
contentDescription = when (selectedLocationChannel) {
|
||||||
@@ -226,6 +200,7 @@ fun PeerCounter(
|
|||||||
tint = countColor
|
tint = countColor
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "$peopleCount",
|
text = "$peopleCount",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -290,7 +265,8 @@ fun ChatHeaderContent(
|
|||||||
selectedLocationChannel = selectedLocationChannel,
|
selectedLocationChannel = selectedLocationChannel,
|
||||||
geohashPeople = geohashPeople,
|
geohashPeople = geohashPeople,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
||||||
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
currentChannel != null -> {
|
currentChannel != null -> {
|
||||||
@@ -326,7 +302,8 @@ private fun PrivateChatHeader(
|
|||||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||||
geohashPeople: List<GeoPerson>,
|
geohashPeople: List<GeoPerson>,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit
|
onToggleFavorite: () -> Unit,
|
||||||
|
viewModel: ChatViewModel
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||||
@@ -345,12 +322,24 @@ private fun PrivateChatHeader(
|
|||||||
|
|
||||||
// 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) {
|
||||||
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
// For geohash DMs, get the actual source geohash and proper display name
|
||||||
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
|
val (conversationGeohash, baseName) = try {
|
||||||
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
|
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
||||||
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
|
repoField.isAccessible = true
|
||||||
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
|
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||||
"$geoPart/@$baseName"
|
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
|
||||||
|
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
|
||||||
|
val displayName = if (fullPubkey.isNotEmpty()) {
|
||||||
|
repo.displayNameForGeohashConversation(fullPubkey, gh)
|
||||||
|
} else {
|
||||||
|
peerNicknames[peerID] ?: "unknown"
|
||||||
|
}
|
||||||
|
Pair(gh, displayName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair("geohash", peerNicknames[peerID] ?: "unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
"#$conversationGeohash/@$baseName"
|
||||||
} else {
|
} else {
|
||||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||||
peerNicknames[peerID] ?: run {
|
peerNicknames[peerID] ?: run {
|
||||||
@@ -563,6 +552,19 @@ private fun MainHeader(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
// Unread private messages badge (click to open most recent DM)
|
||||||
|
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||||
|
// Render icon directly to avoid symbol resolution issues
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Email,
|
||||||
|
contentDescription = "Unread private messages",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
||||||
|
tint = Color(0xFFFF9500)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Location channels button (matching iOS implementation)
|
// Location channels button (matching iOS implementation)
|
||||||
LocationChannelsButton(
|
LocationChannelsButton(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
@@ -582,7 +584,6 @@ private fun MainHeader(
|
|||||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||||
joinedChannels = joinedChannels,
|
joinedChannels = joinedChannels,
|
||||||
hasUnreadChannels = hasUnreadChannels,
|
hasUnreadChannels = hasUnreadChannels,
|
||||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
|
||||||
isConnected = isConnected,
|
isConnected = isConnected,
|
||||||
selectedLocationChannel = selectedLocationChannel,
|
selectedLocationChannel = selectedLocationChannel,
|
||||||
geohashPeople = geohashPeople,
|
geohashPeople = geohashPeople,
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ class ChatState {
|
|||||||
// Geohash people state (iOS-compatible)
|
// Geohash people state (iOS-compatible)
|
||||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||||
|
// For background thread updates by repositories/handlers in their own scopes
|
||||||
|
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
|
||||||
|
|
||||||
|
|
||||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||||
@@ -155,6 +158,16 @@ class ChatState {
|
|||||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
||||||
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
||||||
|
// Thread-safe posting helpers for background updates
|
||||||
|
fun postGeohashPeople(people: List<GeoPerson>) {
|
||||||
|
_geohashPeople.postValue(people)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||||
|
_geohashParticipantCounts.postValue(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getCurrentChannelValue() = _currentChannel.value
|
fun getCurrentChannelValue() = _currentChannel.value
|
||||||
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
||||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
||||||
@@ -183,6 +196,10 @@ class ChatState {
|
|||||||
_connectedPeers.value = peers
|
_connectedPeers.value = peers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun postTeleportedGeo(teleported: Set<String>) {
|
||||||
|
_teleportedGeo.postValue(teleported)
|
||||||
|
}
|
||||||
|
|
||||||
fun setNickname(nickname: String) {
|
fun setNickname(nickname: String) {
|
||||||
_nickname.value = nickname
|
_nickname.value = nickname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
|
|||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.nostr.NostrGeohashService
|
|
||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.bitchat.android.util.NotificationIntervalManager
|
import com.bitchat.android.util.NotificationIntervalManager
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -68,18 +69,20 @@ class ChatViewModel(
|
|||||||
getMeshService = { meshService }
|
getMeshService = { meshService }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Nostr and Geohash service - initialize singleton
|
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||||
private val nostrGeohashService = NostrGeohashService.initialize(
|
val geohashViewModel = GeohashViewModel(
|
||||||
application = application,
|
application = application,
|
||||||
state = state,
|
state = state,
|
||||||
messageManager = messageManager,
|
messageManager = messageManager,
|
||||||
privateChatManager = privateChatManager,
|
privateChatManager = privateChatManager,
|
||||||
meshDelegateHandler = meshDelegateHandler,
|
meshDelegateHandler = meshDelegateHandler,
|
||||||
coroutineScope = viewModelScope,
|
|
||||||
dataManager = dataManager,
|
dataManager = dataManager,
|
||||||
notificationManager = notificationManager
|
notificationManager = notificationManager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Expose state through LiveData (maintaining the same interface)
|
// Expose state through LiveData (maintaining the same interface)
|
||||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||||
@@ -168,14 +171,12 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize location channel state
|
// Initialize new geohash architecture
|
||||||
nostrGeohashService.initializeLocationChannelState()
|
geohashViewModel.initialize()
|
||||||
|
|
||||||
// Initialize favorites persistence service
|
// Initialize favorites persistence service
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||||
|
|
||||||
// Initialize Nostr integration
|
|
||||||
nostrGeohashService.initializeNostrIntegration()
|
|
||||||
|
|
||||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||||
try {
|
try {
|
||||||
@@ -213,6 +214,46 @@ class ChatViewModel(
|
|||||||
meshService.sendBroadcastAnnounce()
|
meshService.sendBroadcastAnnounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Nostr DM subscription for a geohash conversation key if known
|
||||||
|
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
|
||||||
|
*/
|
||||||
|
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
|
||||||
|
try {
|
||||||
|
val repoField = GeohashViewModel::class.java.getDeclaredField("repo")
|
||||||
|
repoField.isAccessible = true
|
||||||
|
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||||
|
val gh = repo.getConversationGeohash(convKey)
|
||||||
|
if (!gh.isNullOrEmpty()) {
|
||||||
|
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
|
||||||
|
subMgrField.isAccessible = true
|
||||||
|
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
|
||||||
|
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
|
||||||
|
val subId = "geo-dm-$gh"
|
||||||
|
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
|
||||||
|
currentDmSubField.isAccessible = true
|
||||||
|
val currentId = currentDmSubField.get(geohashViewModel) as String?
|
||||||
|
if (currentId != subId) {
|
||||||
|
(currentId)?.let { subMgr.unsubscribe(it) }
|
||||||
|
currentDmSubField.set(geohashViewModel, subId)
|
||||||
|
subMgr.subscribeGiftWraps(
|
||||||
|
pubkey = identity.publicKeyHex,
|
||||||
|
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||||
|
id = subId,
|
||||||
|
handler = { event ->
|
||||||
|
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
|
||||||
|
dmHandlerField.isAccessible = true
|
||||||
|
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||||
|
dmHandler.onGiftWrap(event, gh, identity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Channel Management (delegated)
|
// MARK: - Channel Management (delegated)
|
||||||
|
|
||||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
fun joinChannel(channel: String, password: String? = null): Boolean {
|
||||||
@@ -231,6 +272,11 @@ class ChatViewModel(
|
|||||||
// MARK: - Private Chat Management (delegated)
|
// MARK: - Private Chat Management (delegated)
|
||||||
|
|
||||||
fun startPrivateChat(peerID: String) {
|
fun startPrivateChat(peerID: String) {
|
||||||
|
// For geohash conversation keys, ensure DM subscription is active
|
||||||
|
if (peerID.startsWith("nostr_")) {
|
||||||
|
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||||
if (success) {
|
if (success) {
|
||||||
// Notify notification manager about current private chat
|
// Notify notification manager about current private chat
|
||||||
@@ -259,6 +305,66 @@ class ChatViewModel(
|
|||||||
clearMeshMentionNotifications()
|
clearMeshMentionNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Open Latest Unread Private Chat
|
||||||
|
|
||||||
|
fun openLatestUnreadPrivateChat() {
|
||||||
|
try {
|
||||||
|
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
||||||
|
if (unreadKeys.isEmpty()) return
|
||||||
|
|
||||||
|
val me = state.getNicknameValue() ?: meshService.myPeerID
|
||||||
|
val chats = state.getPrivateChatsValue()
|
||||||
|
|
||||||
|
// Pick the latest incoming message among unread conversations
|
||||||
|
var bestKey: String? = null
|
||||||
|
var bestTime: Long = Long.MIN_VALUE
|
||||||
|
|
||||||
|
unreadKeys.forEach { key ->
|
||||||
|
val list = chats[key]
|
||||||
|
if (!list.isNullOrEmpty()) {
|
||||||
|
// Prefer the latest incoming message (sender != me), fallback to last message
|
||||||
|
val latestIncoming = list.lastOrNull { it.sender != me }
|
||||||
|
val candidateTime = (latestIncoming ?: list.last()).timestamp.time
|
||||||
|
if (candidateTime > bestTime) {
|
||||||
|
bestTime = candidateTime
|
||||||
|
bestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val openPeer: String = if (targetKey.startsWith("nostr_")) {
|
||||||
|
// Use the exact conversation key for geohash DMs and ensure DM subscription
|
||||||
|
ensureGeohashDMSubscriptionIfNeeded(targetKey)
|
||||||
|
targetKey
|
||||||
|
} else {
|
||||||
|
// Resolve to a canonical mesh peer if needed
|
||||||
|
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||||
|
selectedPeerID = targetKey,
|
||||||
|
connectedPeers = state.getConnectedPeersValue(),
|
||||||
|
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||||
|
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||||
|
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||||
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
|
)
|
||||||
|
canonical ?: targetKey
|
||||||
|
}
|
||||||
|
|
||||||
|
startPrivateChat(openPeer)
|
||||||
|
|
||||||
|
// If sidebar visible, hide it to focus on the private chat
|
||||||
|
if (state.getShowSidebarValue()) {
|
||||||
|
state.setShowSidebar(false)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// END - Open Latest Unread Private Chat
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Message Sending
|
// MARK: - Message Sending
|
||||||
|
|
||||||
fun sendMessage(content: String) {
|
fun sendMessage(content: String) {
|
||||||
@@ -270,7 +376,7 @@ class ChatViewModel(
|
|||||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||||
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// Route command-generated public messages via Nostr in geohash channels
|
// Route command-generated public messages via Nostr in geohash channels
|
||||||
nostrGeohashService.sendGeohashMessage(
|
geohashViewModel.sendGeohashMessage(
|
||||||
messageContent,
|
messageContent,
|
||||||
selectedLocationForCommand.channel,
|
selectedLocationForCommand.channel,
|
||||||
meshService.myPeerID,
|
meshService.myPeerID,
|
||||||
@@ -298,7 +404,7 @@ class ChatViewModel(
|
|||||||
connectedPeers = state.getConnectedPeersValue(),
|
connectedPeers = state.getConnectedPeersValue(),
|
||||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||||
nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] },
|
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
).also { canonical ->
|
).also { canonical ->
|
||||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||||
@@ -323,7 +429,7 @@ class ChatViewModel(
|
|||||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// Send to geohash channel via Nostr ephemeral event
|
// Send to geohash channel via Nostr ephemeral event
|
||||||
nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||||
} else {
|
} else {
|
||||||
// Send public/channel message via mesh
|
// Send public/channel message via mesh
|
||||||
val message = BitchatMessage(
|
val message = BitchatMessage(
|
||||||
@@ -376,17 +482,36 @@ 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
|
// Persist relationship in FavoritesPersistenceService
|
||||||
try {
|
try {
|
||||||
|
var noiseKey: ByteArray? = null
|
||||||
|
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
|
||||||
|
|
||||||
|
// Case 1: Live mesh peer with known info
|
||||||
val peerInfo = meshService.getPeerInfo(peerID)
|
val peerInfo = meshService.getPeerInfo(peerID)
|
||||||
val noiseKey = peerInfo?.noisePublicKey
|
if (peerInfo?.noisePublicKey != null) {
|
||||||
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
|
noiseKey = peerInfo.noisePublicKey
|
||||||
|
nickname = peerInfo.nickname
|
||||||
|
} else {
|
||||||
|
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
|
||||||
|
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||||
|
try {
|
||||||
|
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
// Prefer nickname from favorites store if available
|
||||||
|
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
|
||||||
|
if (rel != null) nickname = rel.peerNickname
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (noiseKey != null) {
|
if (noiseKey != null) {
|
||||||
val isNowFavorite = dataManager.favoritePeers.contains(
|
// Determine current favorite state from DataManager using fingerprint
|
||||||
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
|
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||||
)
|
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
|
||||||
|
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
|
||||||
|
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
|
||||||
noisePublicKey = noiseKey,
|
noisePublicKey = noiseKey!!,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
isFavorite = isNowFavorite
|
isFavorite = isNowFavorite
|
||||||
)
|
)
|
||||||
@@ -596,7 +721,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
||||||
try {
|
try {
|
||||||
nostrGeohashService.panicResetNostrAndGeohash()
|
geohashViewModel.panicReset()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -638,11 +763,21 @@ class ChatViewModel(
|
|||||||
try {
|
try {
|
||||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||||
identityManager.clearIdentityData()
|
identityManager.clearIdentityData()
|
||||||
Log.d(TAG, "✅ Cleared secure identity state")
|
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||||
|
try {
|
||||||
|
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
|
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear FavoritesPersistenceService persistent relationships
|
||||||
|
try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
|
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
Log.d(TAG, "✅ Cleared all cryptographic data")
|
Log.d(TAG, "✅ Cleared all cryptographic data")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
|
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
|
||||||
@@ -653,48 +788,48 @@ class ChatViewModel(
|
|||||||
* Get participant count for a specific geohash (5-minute activity window)
|
* Get participant count for a specific geohash (5-minute activity window)
|
||||||
*/
|
*/
|
||||||
fun geohashParticipantCount(geohash: String): Int {
|
fun geohashParticipantCount(geohash: String): Int {
|
||||||
return nostrGeohashService.geohashParticipantCount(geohash)
|
return geohashViewModel.geohashParticipantCount(geohash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin sampling multiple geohashes for participant activity
|
* Begin sampling multiple geohashes for participant activity
|
||||||
*/
|
*/
|
||||||
fun beginGeohashSampling(geohashes: List<String>) {
|
fun beginGeohashSampling(geohashes: List<String>) {
|
||||||
nostrGeohashService.beginGeohashSampling(geohashes)
|
geohashViewModel.beginGeohashSampling(geohashes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End geohash sampling
|
* End geohash sampling
|
||||||
*/
|
*/
|
||||||
fun endGeohashSampling() {
|
fun endGeohashSampling() {
|
||||||
nostrGeohashService.endGeohashSampling()
|
// No-op in refactored architecture; sampling subscriptions are short-lived
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a geohash person is teleported (iOS-compatible)
|
* Check if a geohash person is teleported (iOS-compatible)
|
||||||
*/
|
*/
|
||||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||||
return nostrGeohashService.isPersonTeleported(pubkeyHex)
|
return geohashViewModel.isPersonTeleported(pubkeyHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start geohash DM with pubkey hex (iOS-compatible)
|
* Start geohash DM with pubkey hex (iOS-compatible)
|
||||||
*/
|
*/
|
||||||
fun startGeohashDM(pubkeyHex: String) {
|
fun startGeohashDM(pubkeyHex: String) {
|
||||||
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
|
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
|
||||||
startPrivateChat(convKey)
|
startPrivateChat(convKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||||
nostrGeohashService.selectLocationChannel(channel)
|
geohashViewModel.selectLocationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block a user in geohash channels by their nickname
|
* Block a user in geohash channels by their nickname
|
||||||
*/
|
*/
|
||||||
fun blockUserInGeohash(targetNickname: String) {
|
fun blockUserInGeohash(targetNickname: String) {
|
||||||
nostrGeohashService.blockUserInGeohash(targetNickname)
|
geohashViewModel.blockUserInGeohash(targetNickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation Management
|
// MARK: - Navigation Management
|
||||||
@@ -767,6 +902,6 @@ class ChatViewModel(
|
|||||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||||
*/
|
*/
|
||||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||||
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
|
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ class CommandProcessor(
|
|||||||
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
|
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
|
||||||
|
|
||||||
// If we're in a geohash location channel, don't add a local echo here.
|
// If we're in a geohash location channel, don't add a local echo here.
|
||||||
// NostrGeohashService.sendGeohashMessage() will add the local echo with proper metadata.
|
// GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata.
|
||||||
val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||||
|
|
||||||
// Send as regular message
|
// Send as regular message
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||||
|
import com.bitchat.android.nostr.GeohashRepository
|
||||||
|
import com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||||
|
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||||
|
import com.bitchat.android.nostr.NostrProtocol
|
||||||
|
import com.bitchat.android.nostr.NostrRelayManager
|
||||||
|
import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||||
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class GeohashViewModel(
|
||||||
|
application: Application,
|
||||||
|
private val state: ChatState,
|
||||||
|
private val messageManager: MessageManager,
|
||||||
|
private val privateChatManager: PrivateChatManager,
|
||||||
|
private val meshDelegateHandler: MeshDelegateHandler,
|
||||||
|
private val dataManager: DataManager,
|
||||||
|
private val notificationManager: NotificationManager
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
companion object { private const val TAG = "GeohashViewModel" }
|
||||||
|
|
||||||
|
private val repo = GeohashRepository(application, state)
|
||||||
|
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||||
|
private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope)
|
||||||
|
private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo)
|
||||||
|
|
||||||
|
private var currentGeohashSubId: String? = null
|
||||||
|
private var currentDmSubId: String? = null
|
||||||
|
private var geoTimer: Job? = null
|
||||||
|
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||||
|
|
||||||
|
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||||
|
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||||
|
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
subscriptionManager.connect()
|
||||||
|
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||||
|
if (identity != null) {
|
||||||
|
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
|
||||||
|
subscriptionManager.subscribeGiftWraps(
|
||||||
|
pubkey = identity.publicKeyHex,
|
||||||
|
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||||
|
id = "chat-messages",
|
||||||
|
handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||||
|
locationChannelManager?.selectedChannel?.observeForever { channel ->
|
||||||
|
state.setSelectedLocationChannel(channel)
|
||||||
|
switchLocationChannel(channel)
|
||||||
|
}
|
||||||
|
locationChannelManager?.teleported?.observeForever { teleported ->
|
||||||
|
state.setIsTeleported(teleported)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||||
|
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||||
|
state.setIsTeleported(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun panicReset() {
|
||||||
|
repo.clearAll()
|
||||||
|
subscriptionManager.disconnect()
|
||||||
|
currentGeohashSubId = null
|
||||||
|
currentDmSubId = null
|
||||||
|
geoTimer?.cancel()
|
||||||
|
geoTimer = null
|
||||||
|
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
|
||||||
|
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||||
|
val localMsg = com.bitchat.android.model.BitchatMessage(
|
||||||
|
id = tempId,
|
||||||
|
sender = nickname ?: myPeerID,
|
||||||
|
content = content,
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false,
|
||||||
|
senderPeerID = "geohash:${channel.geohash}",
|
||||||
|
channel = "#${channel.geohash}",
|
||||||
|
powDifficulty = if (pow.enabled) pow.difficulty else null
|
||||||
|
)
|
||||||
|
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
|
||||||
|
if (pow.enabled && pow.difficulty > 0) {
|
||||||
|
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
|
||||||
|
}
|
||||||
|
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||||
|
val teleported = state.isTeleported.value ?: false
|
||||||
|
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||||
|
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||||
|
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginGeohashSampling(geohashes: List<String>) {
|
||||||
|
if (geohashes.isEmpty()) return
|
||||||
|
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
||||||
|
viewModelScope.launch {
|
||||||
|
geohashes.forEach { geohash ->
|
||||||
|
subscriptionManager.subscribeGeohash(
|
||||||
|
geohash = geohash,
|
||||||
|
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||||
|
limit = 200,
|
||||||
|
id = "sampling-$geohash",
|
||||||
|
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
|
||||||
|
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
||||||
|
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
||||||
|
|
||||||
|
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||||
|
val convKey = "nostr_${pubkeyHex.take(16)}"
|
||||||
|
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||||
|
onStartPrivateChat(convKey)
|
||||||
|
Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||||
|
|
||||||
|
fun blockUserInGeohash(targetNickname: String) {
|
||||||
|
val pubkey = repo.findPubkeyByNickname(targetNickname)
|
||||||
|
if (pubkey != null) {
|
||||||
|
dataManager.addGeohashBlockedUser(pubkey)
|
||||||
|
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "blocked $targetNickname in geohash channels",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||||
|
val convKey = "nostr_${'$'}{pubkeyHex.take(16)}"
|
||||||
|
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||||
|
// Record the conversation's geohash using the currently selected location channel (if any)
|
||||||
|
val current = state.selectedLocationChannel.value
|
||||||
|
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||||
|
if (!gh.isNullOrEmpty()) {
|
||||||
|
repo.setConversationGeohash(convKey, gh)
|
||||||
|
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
|
||||||
|
}
|
||||||
|
onStartPrivateChat(convKey)
|
||||||
|
Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)")
|
||||||
|
}
|
||||||
|
|
||||||
|
messageManager.addMessage(sysMsg)
|
||||||
|
} else {
|
||||||
|
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "user '$targetNickname' not found in current geohash",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addMessage(sysMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||||
|
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||||
|
|
||||||
|
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||||
|
val seed = "nostr:${pubkeyHex.lowercase()}"
|
||||||
|
return colorForPeerSeed(seed, isDark).copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||||
|
geoTimer?.cancel(); geoTimer = null
|
||||||
|
currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null }
|
||||||
|
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
|
||||||
|
|
||||||
|
when (channel) {
|
||||||
|
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||||
|
Log.d(TAG, "📡 Switched to mesh channel")
|
||||||
|
repo.setCurrentGeohash(null)
|
||||||
|
notificationManager.setCurrentGeohash(null)
|
||||||
|
notificationManager.clearMeshMentionNotifications()
|
||||||
|
repo.refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||||
|
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
|
||||||
|
repo.setCurrentGeohash(channel.channel.geohash)
|
||||||
|
notificationManager.setCurrentGeohash(channel.channel.geohash)
|
||||||
|
notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
|
||||||
|
try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||||
|
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||||
|
val teleported = state.isTeleported.value ?: false
|
||||||
|
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||||
|
|
||||||
|
startGeoParticipantsTimer()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val geohash = channel.channel.geohash
|
||||||
|
val subId = "geohash-$geohash"; currentGeohashSubId = subId
|
||||||
|
subscriptionManager.subscribeGeohash(
|
||||||
|
geohash = geohash,
|
||||||
|
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||||
|
limit = 200,
|
||||||
|
id = subId,
|
||||||
|
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||||
|
)
|
||||||
|
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||||
|
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
|
||||||
|
subscriptionManager.subscribeGiftWraps(
|
||||||
|
pubkey = dmIdentity.publicKeyHex,
|
||||||
|
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||||
|
id = dmSubId,
|
||||||
|
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||||
|
)
|
||||||
|
// Also register alias in global registry for routing convenience
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null -> {
|
||||||
|
Log.d(TAG, "📡 No channel selected")
|
||||||
|
repo.setCurrentGeohash(null)
|
||||||
|
repo.refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startGeoParticipantsTimer() {
|
||||||
|
geoTimer = viewModelScope.launch {
|
||||||
|
while (repo.getCurrentGeohash() != null) {
|
||||||
|
delay(30000)
|
||||||
|
repo.refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,11 @@ class MeshDelegateHandler(
|
|||||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||||
meshHasPeer = { pid -> peers.contains(pid) },
|
meshHasPeer = { pid -> peers.contains(pid) },
|
||||||
nostrPubHexForAlias = { alias ->
|
nostrPubHexForAlias = { alias ->
|
||||||
// Best-effort: derive pub hex from favorites mapping
|
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||||
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||||
|
} else {
|
||||||
|
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||||
val prefix = alias.removePrefix("nostr_")
|
val prefix = alias.removePrefix("nostr_")
|
||||||
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
||||||
favs.firstNotNullOfOrNull { rel ->
|
favs.firstNotNullOfOrNull { rel ->
|
||||||
@@ -117,6 +121,7 @@ class MeshDelegateHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.bitchat.android.ui
|
|||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -123,21 +125,40 @@ class PrivateChatManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toggleFavorite(peerID: String) {
|
fun toggleFavorite(peerID: String) {
|
||||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return
|
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||||
|
|
||||||
|
// Fallback: if this looks like a 64-hex Noise public key (offline favorite entry),
|
||||||
|
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
|
||||||
|
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||||
|
try {
|
||||||
|
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
val fpBytes = digest.digest(pubBytes)
|
||||||
|
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
|
||||||
|
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fingerprint == null) {
|
||||||
|
Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
|
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
|
||||||
|
|
||||||
val wasFavorite = dataManager.isFavorite(fingerprint)
|
val wasFavorite = dataManager.isFavorite(fingerprint!!)
|
||||||
Log.d(TAG, "Current favorite status: $wasFavorite")
|
Log.d(TAG, "Current favorite status: $wasFavorite")
|
||||||
|
|
||||||
val currentFavorites = state.getFavoritePeersValue()
|
val currentFavorites = state.getFavoritePeersValue()
|
||||||
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
||||||
|
|
||||||
if (wasFavorite) {
|
if (wasFavorite) {
|
||||||
dataManager.removeFavorite(fingerprint)
|
dataManager.removeFavorite(fingerprint!!)
|
||||||
Log.d(TAG, "Removed from favorites: $fingerprint")
|
Log.d(TAG, "Removed from favorites: $fingerprint")
|
||||||
} else {
|
} else {
|
||||||
dataManager.addFavorite(fingerprint)
|
dataManager.addFavorite(fingerprint!!)
|
||||||
Log.d(TAG, "Added to favorites: $fingerprint")
|
Log.d(TAG, "Added to favorites: $fingerprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +170,7 @@ class PrivateChatManager(
|
|||||||
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
|
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun isFavorite(peerID: String): Boolean {
|
fun isFavorite(peerID: String): Boolean {
|
||||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
||||||
val isFav = dataManager.isFavorite(fingerprint)
|
val isFav = dataManager.isFavorite(fingerprint)
|
||||||
|
|||||||
@@ -413,6 +413,22 @@ fun PeopleSection(
|
|||||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||||
if (isMappedToConnected) return@forEach
|
if (isMappedToConnected) return@forEach
|
||||||
|
|
||||||
|
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
||||||
|
val nostrConvKey: String? = try {
|
||||||
|
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||||
|
if (npubOrHex != null) {
|
||||||
|
val hex = if (npubOrHex.startsWith("npub")) {
|
||||||
|
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||||
|
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||||
|
} else {
|
||||||
|
npubOrHex.lowercase()
|
||||||
|
}
|
||||||
|
hex?.let { "nostr_${it.take(16)}" }
|
||||||
|
} else null
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||||
|
|
||||||
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
// 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
|
// open chat with the connected peerID instead of the noise hex for a seamless window
|
||||||
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
||||||
@@ -420,13 +436,20 @@ fun PeopleSection(
|
|||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||||
|
|
||||||
|
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||||
|
val unreadCount = (
|
||||||
|
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
||||||
|
) + (
|
||||||
|
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
PeerItem(
|
PeerItem(
|
||||||
peerID = favPeerID,
|
peerID = favPeerID,
|
||||||
displayName = dn,
|
displayName = dn,
|
||||||
isDirect = false,
|
isDirect = false,
|
||||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
|
hasUnreadDM = hasUnread,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||||
@@ -434,21 +457,23 @@ fun PeopleSection(
|
|||||||
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
||||||
viewModel.toggleFavorite(favPeerID)
|
viewModel.toggleFavorite(favPeerID)
|
||||||
},
|
},
|
||||||
unreadCount = privateChats[favPeerID]?.count { msg ->
|
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
||||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID)
|
|
||||||
} ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0,
|
|
||||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||||
showHashSuffix = showHash
|
showHashSuffix = showHash
|
||||||
)
|
)
|
||||||
appendedOfflineIds.add(favPeerID)
|
appendedOfflineIds.add(favPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet
|
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||||
// This ensures a user can open and read Nostr messages while the sender remains offline
|
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
||||||
|
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
||||||
|
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
||||||
|
/*
|
||||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||||
privateChats.keys
|
privateChats.keys
|
||||||
.filter { key ->
|
.filter { key ->
|
||||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
||||||
|
hex64Regex.matches(key) &&
|
||||||
!alreadyShownIds.contains(key) &&
|
!alreadyShownIds.contains(key) &&
|
||||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
// Skip if this key maps to a connected peer via noiseHex mapping
|
||||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||||
@@ -474,10 +499,13 @@ fun PeopleSection(
|
|||||||
unreadCount = privateChats[convKey]?.count { msg ->
|
unreadCount = privateChats[convKey]?.count { msg ->
|
||||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
||||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
||||||
showNostrGlobe = true,
|
showNostrGlobe = false,
|
||||||
showHashSuffix = showHash
|
showHashSuffix = showHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
// End intentional removal
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +566,15 @@ private fun PeerItem(
|
|||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = Color(0xFF9C27B0) // Purple
|
tint = Color(0xFF9C27B0) // Purple
|
||||||
)
|
)
|
||||||
|
} else if (!isDirect && isFavorite) {
|
||||||
|
// Offline favorited user: show outlined circle icon
|
||||||
|
Icon(
|
||||||
|
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
|
||||||
|
imageVector = Icons.Outlined.Circle,
|
||||||
|
contentDescription = "Offline favorite",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = Color.Gray
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Announcement Gossip TLV (Direct Neighbors)
|
||||||
|
|
||||||
|
This document specifies an optional TLV extension to the BitChat `ANNOUNCE` message that allows peers to gossip which other peers they are currently connected to directly over Bluetooth. Implementations can use this to build a mesh topology view (nodes = peers, edges = direct connections).
|
||||||
|
|
||||||
|
Status: optional and backward-compatible.
|
||||||
|
|
||||||
|
## Layering Overview
|
||||||
|
|
||||||
|
- Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged.
|
||||||
|
- Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility.
|
||||||
|
- Signature: The packet MAY be signed using the Ed25519 public key carried in TLV `0x03`. The gossip TLV (if present) is part of the payload and therefore covered by the signature.
|
||||||
|
|
||||||
|
## TLV Format
|
||||||
|
|
||||||
|
Each TLV uses a compact layout:
|
||||||
|
|
||||||
|
- `type`: 1 byte
|
||||||
|
- `length`: 1 byte (0..255)
|
||||||
|
- `value`: `length` bytes
|
||||||
|
|
||||||
|
Existing TLVs (unchanged):
|
||||||
|
|
||||||
|
- `0x01` NICKNAME: UTF‑8 string (≤ 255 bytes)
|
||||||
|
- `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519)
|
||||||
|
- `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes)
|
||||||
|
|
||||||
|
New TLV (optional):
|
||||||
|
|
||||||
|
- `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored.
|
||||||
|
|
||||||
|
### Peer ID Binary Encoding (8 bytes)
|
||||||
|
|
||||||
|
Peer IDs are represented as 8 raw bytes (16 hex chars) in “network order” (left‑to‑right):
|
||||||
|
|
||||||
|
- Take the peer ID hex string, lowercase/truncate to at most 16 hex chars.
|
||||||
|
- Convert each 2 hex chars to 1 byte from left to right.
|
||||||
|
- If fewer than 16 hex chars are available, pad the remaining bytes with `0x00` at the end to reach 8 bytes.
|
||||||
|
|
||||||
|
This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in the BitChat packet header.
|
||||||
|
|
||||||
|
## Sender Behavior
|
||||||
|
|
||||||
|
- Build the base announcement payload by emitting TLVs `0x01`..`0x03` as usual.
|
||||||
|
- Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs.
|
||||||
|
- Remove duplicates before encoding.
|
||||||
|
- Order is arbitrary and not semantically significant.
|
||||||
|
- Sign the ANNOUNCE packet so the gossip TLV is covered (recommended):
|
||||||
|
- Signature algorithm: Ed25519 using the key in TLV `0x03`.
|
||||||
|
- Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature.
|
||||||
|
- The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression.
|
||||||
|
|
||||||
|
## Receiver Behavior
|
||||||
|
|
||||||
|
- Decompress payload if the packet’s compression flag is set, then parse TLVs in order.
|
||||||
|
- Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs.
|
||||||
|
- If a `0x04` TLV is present:
|
||||||
|
- Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes).
|
||||||
|
- Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase).
|
||||||
|
- De‑duplicate neighbors.
|
||||||
|
- Topology maintenance guidance (optional, but recommended for consistent behavior):
|
||||||
|
- Maintain, for each announcing peer A, the last announcement timestamp and the neighbor list from TLV `0x04`.
|
||||||
|
- When a newer announcement from A arrives (use the 8‑byte unsigned `timestamp` in the BitChat packet header), replace A’s previously recorded neighbor list with the new one. If older or equal, ignore the neighbor update.
|
||||||
|
- Treat the neighbor list as a set of undirected edges `{A, B}` in your topology visualization; i.e., if A reports direct peers `[B, C]`, add edges A–B and A–C.
|
||||||
|
|
||||||
|
## Limits and Compatibility
|
||||||
|
|
||||||
|
- Max neighbors per TLV: 10. Senders MAY send fewer; receivers MUST accept any number `N ≥ 0` implied by `length / 8` up to the received `length`.
|
||||||
|
- Omission: If the TLV `0x04` is omitted, the announce remains valid. Peers can still chat and interoperate normally; the topology graph will just not include edges reported by that peer (other peers that include A in their neighbor lists can still introduce edges to A).
|
||||||
|
- Unknown TLVs MUST be ignored. This makes the extension safe for older implementations.
|
||||||
|
|
||||||
|
## Minimal Example (conceptual)
|
||||||
|
|
||||||
|
ANNOUNCE payload TLVs (concatenated):
|
||||||
|
|
||||||
|
- `01 [len=N] [UTF‑8 nickname]`
|
||||||
|
- `02 [len=32] [32 bytes X25519 pubkey]`
|
||||||
|
- `03 [len=32] [32 bytes Ed25519 pubkey]`
|
||||||
|
- `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional)
|
||||||
|
|
||||||
|
Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above.
|
||||||
|
|
||||||
|
That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged.
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Source-Based Routing for BitChat Packets
|
||||||
|
|
||||||
|
This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying.
|
||||||
|
|
||||||
|
Status: optional and backward-compatible.
|
||||||
|
|
||||||
|
## Layering Overview
|
||||||
|
|
||||||
|
- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength).
|
||||||
|
- Flags: adds a new bit `HAS_ROUTE (0x08)`.
|
||||||
|
- Variable sections (when present, in order):
|
||||||
|
1) `SenderID` (8 bytes)
|
||||||
|
2) `RecipientID` (8 bytes) if `HAS_RECIPIENT`
|
||||||
|
3) `Route` (if `HAS_ROUTE`): `count` (1 byte) + `count * 8` bytes hop IDs
|
||||||
|
4) `Payload` (with optional compression preamble)
|
||||||
|
5) `Signature` (64 bytes) if `HAS_SIGNATURE`
|
||||||
|
|
||||||
|
Unknown flags are ignored by older implementations (they will simply not see a route and continue broadcasting as before).
|
||||||
|
|
||||||
|
## Route Field Encoding
|
||||||
|
|
||||||
|
- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`.
|
||||||
|
- Layout (immediately after optional `RecipientID`):
|
||||||
|
- `count`: 1 byte (0..255)
|
||||||
|
- `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes
|
||||||
|
- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the on‑wire `senderID`/`recipientID` encoding.
|
||||||
|
- Size impact: `1 + 8*N` bytes, where `N = count`.
|
||||||
|
- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it).
|
||||||
|
|
||||||
|
## Sender Behavior
|
||||||
|
|
||||||
|
- Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route.
|
||||||
|
- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`.
|
||||||
|
- Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`.
|
||||||
|
- Signing: The route is covered by the Ed25519 signature (recommended):
|
||||||
|
- Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol.
|
||||||
|
|
||||||
|
## Relay Behavior
|
||||||
|
|
||||||
|
When receiving a packet that is not addressed to you:
|
||||||
|
|
||||||
|
1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies).
|
||||||
|
2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list:
|
||||||
|
- If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it.
|
||||||
|
- If successful, do NOT broadcast this packet further.
|
||||||
|
- If not directly connected (or the send fails), fall back to broadcast relaying.
|
||||||
|
- If you are the last hop (no `i+1`), proceed with standard handling (e.g., if not addressed to you, do not relay further).
|
||||||
|
|
||||||
|
TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay.
|
||||||
|
|
||||||
|
## Receiver Behavior (Destination)
|
||||||
|
|
||||||
|
- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.).
|
||||||
|
- Signature verification MUST include the route field when present; route tampering will invalidate the signature.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`.
|
||||||
|
- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol.
|
||||||
|
|
||||||
|
## Minimal Example (conceptual)
|
||||||
|
|
||||||
|
- Header (fixed 13 bytes): unchanged.
|
||||||
|
- Variable sections (ordered):
|
||||||
|
- `SenderID(8)`
|
||||||
|
- `RecipientID(8)` (if present)
|
||||||
|
- `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes
|
||||||
|
- Payload (optionally compressed)
|
||||||
|
- Signature (64)
|
||||||
|
|
||||||
|
Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed.
|
||||||
|
- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop.
|
||||||
|
- Implementations may introduce policy controls (e.g., disable source routing, cap max route length).
|
||||||
|
|
||||||
Reference in New Issue
Block a user