Merge branch 'main' into gossip-graph

This commit is contained in:
callebtc
2025-09-08 13:49:32 +02:00
31 changed files with 2356 additions and 2466 deletions
@@ -9,11 +9,12 @@ import java.util.*
/**
* 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(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
val peerNostrPublicKey: String?, // npub bech32 string
val peerNostrPublicKey: String?, // npub bech32 string
val peerNickname: String,
val isFavorite: Boolean, // We favorited them
val theyFavoritedUs: Boolean, // They favorited us
@@ -21,22 +22,22 @@ data class FavoriteRelationship(
val lastUpdated: Date
) {
val isMutual: Boolean get() = isFavorite && theyFavoritedUs
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavoriteRelationship
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
if (peerNickname != other.peerNickname) return false
if (isFavorite != other.isFavorite) return false
if (theyFavoritedUs != other.theyFavoritedUs) return false
return true
}
override fun hashCode(): Int {
var result = peerNoisePublicKey.contentHashCode()
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
@@ -54,20 +55,21 @@ interface FavoritesChangeListener {
/**
* Manages favorites with Noise↔Nostr mapping
* Singleton pattern matching iOS implementation
* Singleton pattern matching iOS implementation.
*/
class FavoritesPersistenceService private constructor(private val context: Context) {
companion object {
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
private var INSTANCE: FavoritesPersistenceService? = null
val shared: FavoritesPersistenceService
get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized")
fun initialize(context: Context) {
if (INSTANCE == null) {
synchronized(this) {
@@ -78,46 +80,40 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
}
}
private val stateManager = SecureIdentityStateManager(context)
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>()
init {
loadFavorites()
loadPeerIdIndex()
}
/**
* Get favorite status for Noise public key
*/
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]
}
/**
* Get favorite status for 16-hex peerID
*/
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
// For 16-hex peerIDs, we need to find the corresponding full Noise key
// This is a simplified lookup - in practice you'd use fingerprint matching
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
if (noiseKeyHex.startsWith(peerID)) {
return relationship
}
if (noiseKeyHex.startsWith(pid)) return relationship
}
return null
}
/**
* Update Nostr public key for a peer
*/
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = nostrPubkey,
@@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
)
favorites[keyHex] = updated
} else {
// Create new relationship
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = nostrPubkey,
@@ -137,19 +132,54 @@ class FavoritesPersistenceService private constructor(private val context: Conte
)
favorites[keyHex] = relationship
}
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
}
/**
* Update favorite status
*/
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
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) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
val updated = if (existing != null) {
existing.copy(
peerNickname = nickname,
@@ -168,21 +198,19 @@ class FavoritesPersistenceService private constructor(private val context: Conte
lastUpdated = Date()
)
}
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
}
/**
* Update peer favorited us status
*/
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
theyFavoritedUs = theyFavoritedUs,
@@ -191,101 +219,98 @@ class FavoritesPersistenceService private constructor(private val context: Conte
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
}
}
/**
* Get all mutual favorites
*/
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 getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun clearAllFavorites() {
favorites.clear()
saveFavorites()
peerIdIndex.clear()
savePeerIdIndex()
Log.i(TAG, "Cleared all favorites")
notifyAllCleared()
}
/**
* Find Noise key by Nostr pubkey
*/
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored ->
normalizeNostrKeyToHex(stored)
} == targetHex
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
}?.peerNoisePublicKey
}
/**
* Find Nostr pubkey by Noise key
*/
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]?.peerNostrPublicKey
}
// MARK: - Private Methods
// MARK: - Persistence
private fun loadFavorites() {
try {
// Use public methods instead of reflection to access encrypted preferences
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
if (favoritesJson != null) {
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
val data: Map<String, FavoriteRelationshipData> = gson.fromJson(favoritesJson, type)
favorites.clear()
data.forEach { (key, relationshipData) ->
val relationship = relationshipData.toFavoriteRelationship()
favorites[key] = relationship
favorites[key] = relationshipData.toFavoriteRelationship()
}
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load favorites: ${e.message}")
}
}
private fun saveFavorites() {
try {
// Convert to serializable format
val data = favorites.mapValues { (_, relationship) ->
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
}
val favoritesJson = gson.toJson(data)
// Use public methods instead of reflection to access encrypted preferences
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
} catch (e: Exception) {
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
fun addListener(listener: FavoritesChangeListener) {
synchronized(listeners) {
if (!listeners.contains(listener)) listeners.add(listener)
}
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
}
fun removeListener(listener: FavoritesChangeListener) {
synchronized(listeners) { listeners.remove(listener) }
@@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte
snapshot.forEach { runCatching { it.onAllCleared() } }
}
/**
* Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison
*/
private fun normalizeNostrKeyToHex(value: String): String? {
return try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") return null
data.joinToString("") { "%02x".format(it) }
} else {
// Assume hex
value.lowercase()
}
} catch (_: Exception) { null }
}
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
private fun normalizeNostrKeyToHex(value: String): String? = try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
} else value.lowercase()
} catch (_: Exception) { null }
}
/**
* Serializable data class for JSON storage
*/
/** Serializable data for JSON storage */
private data class FavoriteRelationshipData(
val peerNoisePublicKeyHex: String,
val peerNostrPublicKey: String?,
@@ -341,7 +356,7 @@ private data class FavoriteRelationshipData(
)
}
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return FavoriteRelationship(
@@ -284,6 +284,13 @@ class BluetoothMeshService(private val context: Context) {
// Store fingerprint for the peer via centralized fingerprint manager
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
previousPeerID?.let { oldPeerID ->
@@ -543,25 +550,13 @@ class BluetoothMeshService(private val context: Context) {
*/
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty()) return
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
if (recipientNickname.isEmpty()) return
serviceScope.launch {
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
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
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
try {
@@ -629,15 +624,14 @@ class BluetoothMeshService(private val context: Context) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
// Check if this is a Nostr contact (geohash DM)
if (recipientPeerID.startsWith("nostr_")) {
// Get NostrGeohashService instance and send read receipt via Nostr
try {
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}")
}
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot()
map.containsKey(recipientPeerID)
} catch (_: Exception) { false }
if (isGeoAlias && geo != null) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
@@ -459,7 +459,9 @@ class MessageHandler(private val myPeerID: String) {
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
if (npub != null) {
// Index by noise key and current mesh peerID for fast Nostr routing
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub)
}
// Determine iOS-style guidance text
@@ -344,7 +344,8 @@ object TorManager {
bindRetryAttempts = 0
startInactivityMonitoring()
}
s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
@@ -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
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
// This would need integration with the existing favorites system
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
@@ -86,13 +85,23 @@ class NostrTransport(
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(
content = content,
messageID = messageID,
recipientPeerID = to,
recipientPeerID = recipientPeerIDForEmbed,
senderPeerID = senderPeerID
)
if (embedded == null) {
Log.e(TAG, "NostrTransport: failed to embed PM packet")
return@launch
@@ -412,39 +421,58 @@ class NostrTransport(
fun sendPrivateMessageGeohash(
content: 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 {
try {
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
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = content,
messageID = messageID,
senderPeerID = senderPeerID
)
if (embedded == null) {
) ?: run {
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
return@launch
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded,
recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity
)
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
}
@@ -458,14 +486,15 @@ class NostrTransport(
*/
private fun resolveNostrPublicKey(peerID: String): String? {
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 favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) {
return favoriteStatus.peerNostrPublicKey
}
// Fallback: try with 16-hex peerID lookup
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
// 3) Prefix match on noiseHex from 16-hex peerID
if (peerID.length == 16) {
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
return fallbackStatus?.peerNostrPublicKey
@@ -43,6 +43,7 @@ class MessageRouter private constructor(
// Listener for favorites changes to flush outbox when npub mapping appears/changes
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) {
flushOutboxFor(noiseKeyHex)
// Also try 16-hex short id commonly used in UI if any client used that
@@ -55,18 +56,34 @@ class MessageRouter private constructor(
}
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 hasEstablished = mesh.hasEstablishedSession(toPeerID)
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)
} 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)
} 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() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
mesh.initiateNoiseHandshake(toPeerID)
}
}
@@ -82,7 +99,14 @@ class MessageRouter private constructor(
fun sendDeliveryAck(messageID: String, toPeerID: String) {
// Mesh delivery ACKs are sent by the receiver automatically.
// Only route via Nostr when mesh path isn't available.
// 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))) {
nostr.sendDeliveryAck(messageID, toPeerID)
}
@@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable
* Extracted from ChatScreen.kt for better organization
*/
/**
* Reactive helper to compute favorite state from fingerprint mapping
* This eliminates the need for static isFavorite parameters and makes
@@ -159,7 +161,6 @@ fun PeerCounter(
connectedPeers: List<String>,
joinedChannels: Set<String>,
hasUnreadChannels: Map<String, Int>,
hasUnreadPrivateMessages: Set<String>,
isConnected: Boolean,
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
@@ -189,33 +190,6 @@ fun PeerCounter(
verticalAlignment = Alignment.CenterVertically,
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(
imageVector = Icons.Default.Group,
contentDescription = when (selectedLocationChannel) {
@@ -226,6 +200,7 @@ fun PeerCounter(
tint = countColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "$peopleCount",
style = MaterialTheme.typography.bodyMedium,
@@ -290,7 +265,8 @@ fun ChatHeaderContent(
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
viewModel = viewModel
)
}
currentChannel != null -> {
@@ -326,7 +302,8 @@ private fun PrivateChatHeader(
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
onBackClick: () -> Unit,
onToggleFavorite: () -> Unit
onToggleFavorite: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
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)
val titleText: String = if (isNostrDM) {
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
"$geoPart/@$baseName"
// For geohash DMs, get the actual source geohash and proper display name
val (conversationGeohash, baseName) = try {
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
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 {
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
peerNicknames[peerID] ?: run {
@@ -563,6 +552,19 @@ private fun MainHeader(
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)
LocationChannelsButton(
viewModel = viewModel,
@@ -582,7 +584,6 @@ private fun MainHeader(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
isConnected = isConnected,
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
@@ -74,18 +74,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
// Determine what messages to show based on current context
// Determine what messages to show based on current context (unified timelines)
val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> {
val locationChannel = selectedLocationChannel
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// For geohash channels, get messages from geohash history
val geohash = locationChannel.channel.geohash
viewModel.getGeohashMessages(geohash)
val geokey = "geo:${locationChannel.channel.geohash}"
channelMessages[geokey] ?: emptyList()
} else {
messages // Mesh/public messages
messages // Mesh timeline
}
}
}
@@ -124,6 +124,9 @@ class ChatState {
// Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
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())
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
@@ -155,6 +158,16 @@ class ChatState {
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.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 getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
@@ -183,6 +196,10 @@ class ChatState {
_connectedPeers.value = peers
}
fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported)
}
fun setNickname(nickname: String) {
_nickname.value = nickname
}
@@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.nostr.NostrGeohashService
import kotlinx.coroutines.launch
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
@@ -68,18 +69,20 @@ class ChatViewModel(
getMeshService = { meshService }
)
// Nostr and Geohash service - initialize singleton
private val nostrGeohashService = NostrGeohashService.initialize(
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
val geohashViewModel = GeohashViewModel(
application = application,
state = state,
messageManager = messageManager,
privateChatManager = privateChatManager,
meshDelegateHandler = meshDelegateHandler,
coroutineScope = viewModelScope,
dataManager = dataManager,
notificationManager = notificationManager
)
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers
@@ -168,14 +171,12 @@ class ChatViewModel(
}
}
// Initialize location channel state
nostrGeohashService.initializeLocationChannelState()
// Initialize new geohash architecture
geohashViewModel.initialize()
// Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Initialize Nostr integration
nostrGeohashService.initializeNostrIntegration()
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
@@ -213,6 +214,46 @@ class ChatViewModel(
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)
fun joinChannel(channel: String, password: String? = null): Boolean {
@@ -231,6 +272,11 @@ class ChatViewModel(
// MARK: - Private Chat Management (delegated)
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)
if (success) {
// Notify notification manager about current private chat
@@ -258,6 +304,66 @@ class ChatViewModel(
// Clear mesh mention notifications since user is now back in mesh chat
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
@@ -270,7 +376,7 @@ class ChatViewModel(
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
// Route command-generated public messages via Nostr in geohash channels
nostrGeohashService.sendGeohashMessage(
geohashViewModel.sendGeohashMessage(
messageContent,
selectedLocationForCommand.channel,
meshService.myPeerID,
@@ -298,7 +404,7 @@ class ChatViewModel(
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
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) }
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
@@ -323,7 +429,7 @@ class ChatViewModel(
val selectedLocationChannel = state.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// 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 {
// Send public/channel message via mesh
val message = BitchatMessage(
@@ -376,17 +482,36 @@ class ChatViewModel(
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
privateChatManager.toggleFavorite(peerID)
// Persist relationship in FavoritesPersistenceService when we have Noise key
// Persist relationship in FavoritesPersistenceService
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 noiseKey = peerInfo?.noisePublicKey
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
if (peerInfo?.noisePublicKey != null) {
noiseKey = peerInfo.noisePublicKey
nickname = peerInfo.nickname
} else {
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
try {
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Prefer nickname from favorites store if available
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
if (rel != null) nickname = rel.peerNickname
} catch (_: Exception) { }
}
}
if (noiseKey != null) {
val isNowFavorite = dataManager.favoritePeers.contains(
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
)
// Determine current favorite state from DataManager using fingerprint
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noiseKey,
noisePublicKey = noiseKey!!,
nickname = nickname,
isFavorite = isNowFavorite
)
@@ -596,7 +721,7 @@ class ChatViewModel(
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
try {
nostrGeohashService.panicResetNostrAndGeohash()
geohashViewModel.panicReset()
} catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
}
@@ -638,10 +763,20 @@ class ChatViewModel(
try {
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
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) {
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")
} catch (e: Exception) {
@@ -649,59 +784,52 @@ class ChatViewModel(
}
}
/**
* Get messages for a specific geohash timeline
*/
fun getGeohashMessages(geohash: String): List<BitchatMessage> {
return nostrGeohashService.getGeohashMessages(geohash)
}
/**
* Get participant count for a specific geohash (5-minute activity window)
*/
fun geohashParticipantCount(geohash: String): Int {
return nostrGeohashService.geohashParticipantCount(geohash)
return geohashViewModel.geohashParticipantCount(geohash)
}
/**
* Begin sampling multiple geohashes for participant activity
*/
fun beginGeohashSampling(geohashes: List<String>) {
nostrGeohashService.beginGeohashSampling(geohashes)
geohashViewModel.beginGeohashSampling(geohashes)
}
/**
* End geohash sampling
*/
fun endGeohashSampling() {
nostrGeohashService.endGeohashSampling()
// No-op in refactored architecture; sampling subscriptions are short-lived
}
/**
* Check if a geohash person is teleported (iOS-compatible)
*/
fun isPersonTeleported(pubkeyHex: String): Boolean {
return nostrGeohashService.isPersonTeleported(pubkeyHex)
return geohashViewModel.isPersonTeleported(pubkeyHex)
}
/**
* Start geohash DM with pubkey hex (iOS-compatible)
*/
fun startGeohashDM(pubkeyHex: String) {
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
startPrivateChat(convKey)
}
}
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
nostrGeohashService.selectLocationChannel(channel)
geohashViewModel.selectLocationChannel(channel)
}
/**
* Block a user in geohash channels by their nickname
*/
fun blockUserInGeohash(targetNickname: String) {
nostrGeohashService.blockUserInGeohash(targetNickname)
geohashViewModel.blockUserInGeohash(targetNickname)
}
// MARK: - Navigation Management
@@ -774,6 +902,6 @@ class ChatViewModel(
* Get consistent color for a Nostr pubkey (iOS-compatible)
*/
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_ *"
// 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
// Send as regular message
@@ -0,0 +1,283 @@
package com.bitchat.android.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@OptIn(ExperimentalMaterial3Api::class)
class GeohashPickerActivity : ComponentActivity() {
companion object {
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
const val EXTRA_RESULT_GEOHASH = "result_geohash"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
var geohashToFocus: String? = null
var (initLat, initLon) = 0.0 to 0.0
if (!initialGeohash.isNullOrEmpty()) {
geohashToFocus = initialGeohash
try {
val (lat, lon) = Geohash.decodeToCenter(initialGeohash)
initLat = lat
initLon = lon
} catch (_: Throwable) {}
} else {
// If no initial geohash, try to use the user's coarsest location
val locationManager = LocationChannelManager.getInstance(applicationContext)
val channels = locationManager.availableChannels.value
if (!channels.isNullOrEmpty()) {
val coarsestChannel = channels.minByOrNull { it.geohash.length }
if (coarsestChannel != null) {
geohashToFocus = coarsestChannel.geohash
try {
val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash)
initLat = lat
initLon = lon
} catch (_: Throwable) {}
}
}
}
val initialPrecision = geohashToFocus?.length ?: 5
setContent {
MaterialTheme {
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
// iOS system-like colors used across app
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
Scaffold { padding ->
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.allowFileAccess = true
settings.allowContentAccess = true
webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Initialize to last/initial geohash if provided, otherwise center
if (!geohashToFocus.isNullOrEmpty()) {
evaluateJavascript(
"window.focusGeohash('${geohashToFocus}')",
null
)
} else {
evaluateJavascript(
"window.setCenter(${initLat}, ${initLon})",
null
)
}
// Apply theme to map tiles
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
}
}
addJavascriptInterface(object {
@JavascriptInterface
fun onGeohashChanged(geohash: String) {
runOnUiThread {
currentGeohash = geohash
}
}
}, "Android")
loadUrl("file:///android_asset/geohash_picker.html")
}
},
modifier = Modifier
.fillMaxSize()
.padding(padding),
update = { webView ->
webViewRef = webView
// ensure it fills parent
webView.updateLayoutParams<ViewGroup.LayoutParams> {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
},
onRelease = { webView ->
// Best-effort cleanup to avoid leaks and timers
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
try { webView.stopLoading() } catch (_: Throwable) {}
try { webView.clearHistory() } catch (_: Throwable) {}
try { webView.clearCache(true) } catch (_: Throwable) {}
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
try { webView.removeAllViews() } catch (_: Throwable) {}
try { webView.destroy() } catch (_: Throwable) {}
}
)
// Floating info pill
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 20.dp)
.fillMaxWidth(0.75f),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = "pan and zoom to select a geohash",
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Decrease precision
Button(
onClick = {
precision = (precision - 1).coerceAtLeast(1)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Remove, contentDescription = "Decrease precision")
}
}
// Increase precision
Button(
onClick = {
precision = (precision + 1).coerceAtMost(12)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Add, contentDescription = "Increase precision")
}
}
// Select button
Button(
onClick = {
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
val gh = value?.trim('"') ?: currentGeohash
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = "Select geohash")
Spacer(Modifier.width(6.dp))
Text(
text = "select",
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
}
}
}
}
}
@@ -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()
}
}
}
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material3.*
import androidx.compose.ui.text.font.FontWeight
@@ -30,6 +31,8 @@ import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import java.util.*
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -71,6 +74,18 @@ fun LocationChannelsSheet(
// Scroll state for LazyColumn
val listState = rememberLazyListState()
val mapPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
if (!gh.isNullOrBlank()) {
customGeohash = gh
customError = null
}
}
}
// iOS system colors (matches iOS exactly)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -312,6 +327,26 @@ fun LocationChannelsSheet(
)
val normalized = customGeohash.trim().lowercase().replace("#", "")
// Map picker button
IconButton(onClick = {
val initial = when {
normalized.isNotBlank() -> normalized
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
else -> ""
}
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
}
mapPickerLauncher.launch(intent)
}) {
Icon(
imageVector = Icons.Filled.Map,
contentDescription = "Open map",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
val isValid = validateGeohash(normalized)
// iOS-style teleport button
@@ -107,16 +107,21 @@ class MeshDelegateHandler(
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> peers.contains(pid) },
nostrPubHexForAlias = { alias ->
// Best-effort: derive pub hex from favorites mapping
val prefix = alias.removePrefix("nostr_")
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
} else {
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
val prefix = alias.removePrefix("nostr_")
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
}
}
}
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
}
},
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
@@ -52,8 +52,18 @@ class MessageManager(private val state: ChatState) {
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
// Update unread count if not currently in this channel
if (state.getCurrentChannelValue() != channel) {
// Update unread count if not currently viewing this channel
// Consider both classic channels (state.currentChannel) and geohash location channel selection
val viewingClassicChannel = state.getCurrentChannelValue() == channel
val viewingGeohashChannel = try {
if (channel.startsWith("geo:")) {
val geo = channel.removePrefix("geo:")
val selected = state.selectedLocationChannel.value
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
} else false
} catch (_: Exception) { false }
if (!viewingClassicChannel && !viewingGeohashChannel) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
state.setUnreadChannelMessages(currentUnread)
@@ -3,6 +3,8 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import java.security.MessageDigest
import com.bitchat.android.mesh.BluetoothMeshService
import java.util.*
import android.util.Log
@@ -123,21 +125,40 @@ class PrivateChatManager(
}
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")
val wasFavorite = dataManager.isFavorite(fingerprint)
val wasFavorite = dataManager.isFavorite(fingerprint!!)
Log.d(TAG, "Current favorite status: $wasFavorite")
val currentFavorites = state.getFavoritePeersValue()
Log.d(TAG, "Current UI state favorites: $currentFavorites")
if (wasFavorite) {
dataManager.removeFavorite(fingerprint)
dataManager.removeFavorite(fingerprint!!)
Log.d(TAG, "Removed from favorites: $fingerprint")
} else {
dataManager.addFavorite(fingerprint)
dataManager.addFavorite(fingerprint!!)
Log.d(TAG, "Added to favorites: $fingerprint")
}
@@ -149,6 +170,7 @@ class PrivateChatManager(
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
}
fun isFavorite(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
val isFav = dataManager.isFavorite(fingerprint)
@@ -365,87 +387,12 @@ class PrivateChatManager(
"Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID"
)
meshService.sendAnnouncementToPeer(peerID)
Log.d(TAG, "Sent identity announcement to $peerID starting handshake now from our side")
noiseSessionDelegate.initiateHandshake(peerID)
}
}
// /**
// * Legacy reflection-based implementation for backward compatibility
// */
// private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) {
// try {
// // Check if we already have an established Noise session with this peer
// val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java)
// val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean
//
// if (hasSession) {
// Log.d(TAG, "Noise session already established with $peerID")
// return
// }
//
// Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake")
//
// // Get our peer ID from mesh service for lexicographical comparison
// val myPeerIDField = meshService::class.java.getField("myPeerID")
// val myPeerID = myPeerIDField.get(meshService) as String
//
// // Use lexicographical comparison to decide who initiates (same logic as MessageHandler)
// if (myPeerID < peerID) {
// // We should initiate the handshake
// Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID")
// initiateHandshakeWithPeer(peerID, meshService)
// } else {
// // They should initiate, we send a Noise identity announcement
// Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID")
// sendNoiseIdentityAnnouncement(meshService)
// }
//
// } catch (e: Exception) {
// Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}")
// }
// }
/**
* Initiate handshake with specific peer using the existing delegate pattern
*/
private fun initiateHandshakeWithPeer(peerID: String, meshService: Any) {
try {
// Use the existing MessageHandler delegate approach to initiate handshake
// This calls the same code that's in MessageHandler's delegate.initiateNoiseHandshake()
val messageHandler = meshService::class.java.getDeclaredField("messageHandler")
messageHandler.isAccessible = true
val handler = messageHandler.get(meshService)
val delegate = handler::class.java.getDeclaredField("delegate")
delegate.isAccessible = true
val handlerDelegate = delegate.get(handler)
val method =
handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java)
method.invoke(handlerDelegate, peerID)
Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern")
} catch (e: Exception) {
Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}")
}
}
/**
* Send Noise identity announcement to prompt other peer to initiate handshake
* This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService
*/
private fun sendNoiseIdentityAnnouncement(meshService: Any) {
try {
// Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement
val method =
meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement")
method.invoke(meshService)
Log.d(TAG, "Successfully sent Noise identity announcement")
} catch (e: Exception) {
Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}")
}
}
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
@@ -413,6 +413,22 @@ fun PeopleSection(
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
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,
// 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
@@ -420,13 +436,20 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
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(
peerID = favPeerID,
displayName = dn,
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
@@ -434,21 +457,23 @@ fun PeopleSection(
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
viewModel.toggleFavorite(favPeerID)
},
unreadCount = privateChats[favPeerID]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID)
} ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0,
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
showHashSuffix = showHash
)
appendedOfflineIds.add(favPeerID)
}
// Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet
// This ensures a user can open and read Nostr messages while the sender remains offline
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
/*
val alreadyShownIds = connectedIds + appendedOfflineIds
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
hex64Regex.matches(key) &&
!alreadyShownIds.contains(key) &&
// Skip if this key maps to a connected peer via noiseHex mapping
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
@@ -460,24 +485,27 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
showNostrGlobe = true,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
*/
// End intentional removal
}
}
@@ -538,6 +566,15 @@ private fun PeerItem(
modifier = Modifier.size(16.dp),
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 {
Icon(
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
@@ -262,6 +262,8 @@ fun DebugSettingsSheet(
steps = 30
)
}
val overallCount = connectedDevices.size
Text("connections: $overallCount / $maxOverall", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
@@ -307,29 +309,35 @@ fun DebugSettingsSheet(
}
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
Box(Modifier.fillMaxWidth().height(48.dp)) {
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
// Graph canvas
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = 28.dp.toPx() // leave room on left for ticks
val axisPx = leftGutter.toPx() // reserved left gutter for labels
val barCount = series.size
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
// draw baseline at y=0 (bottom)
// Baseline at bottom (y = 0)
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
)
// Bars from bottom-up; skip zeros entirely
series.forEachIndexed { i, value ->
val ratio = if (maxVal > 0f) (value / maxVal).coerceIn(0f, 1f) else 0f // min always 0
val barHeight = h * ratio
// Draw bars from bottom up, starting after left axis area
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
}
}
// Y-axis ticks (min/max) in the left margin