Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class BluetoothMeshService(private val context: Context) {

// My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars)
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val peerManager = PeerManager()
private val peerManager = PeerManager().apply { myPeerID = [email protected] }
private val fragmentManager = FragmentManager()
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
Expand Down Expand Up @@ -636,6 +636,10 @@ class BluetoothMeshService(private val context: Context) {
if (connectionManager.startServices()) {
isActive = true

// Add ourselves to peer manager for collision detection
val myNickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
peerManager.addOrUpdatePeer(myPeerID, myNickname)
Comment on lines +639 to +641

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t mark mesh connected when only self is present

Adding the local peer to PeerManager on startup means the peer list is never empty while the service is active. MeshDelegateHandler.didUpdatePeerList sets isConnected based on peers.isNotEmpty(), so with this change the app reports a connected mesh even when no other devices are around. That breaks UI/logic that assumes “connected” implies at least one remote peer (status indicators, /who output, etc.). Consider keeping self out of the active peer list or filtering it before computing connection state.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also causes issues like being able to open a chat with yourself, this is best solved at data layer level by filtering getActivePeerIDs() in PeerManager.kt to exclude self. This will solve it for isConnected and all other cases.
return peers.filterValues { it.isConnected && it.id != myPeerID }

    /**
     * Get list of active peer IDs
     */
    fun getActivePeerIDs(): List<String> {
        return peers.filterValues { it.isConnected && it.id != myPeerID  }
            .keys
            .toList()
            .sorted()
    }


// Start periodic announcements for peer discovery and connectivity
sendPeriodicBroadcastAnnounce()
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
Expand Down Expand Up @@ -697,6 +701,14 @@ class BluetoothMeshService(private val context: Context) {
return reusable
}

/**
* Update our own nickname in peer manager and announce it
*/
fun updateSelfNickname(newNickname: String) {
peerManager.addOrUpdatePeer(myPeerID, newNickname)
sendBroadcastAnnounce()
}

/**
* Send public message
*/
Expand Down Expand Up @@ -1175,6 +1187,11 @@ class BluetoothMeshService(private val context: Context) {
* Get peer nicknames
*/
fun getPeerNicknames(): Map<String, String> = peerManager.getAllPeerNicknames()

/**
* Get disambiguated peer nickname (nickname#suffix if collisions exist)
*/
fun getDisambiguatedNickname(peerID: String): String = peerManager.getDisambiguatedNickname(peerID)

/**
* Get peer RSSI values
Expand Down
34 changes: 18 additions & 16 deletions app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ class PeerManager {
// Callback to check if a peer is directly connected (injected by BluetoothMeshService)
var isPeerDirectlyConnected: ((String) -> Boolean)? = null

// My own Peer ID (to treat specially in disambiguation and cleanup)
var myPeerID: String? = null

// Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

Expand Down Expand Up @@ -207,22 +210,7 @@ class PeerManager {
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
if (peerID == "unknown") return false

// Clean up stale peer IDs with the same nickname (exact same logic as iOS)
val now = System.currentTimeMillis()
val stalePeerIDs = mutableListOf<String>()
peers.forEach { (existingPeerID, info) ->
if (info.nickname == nickname && existingPeerID != peerID) {
val wasRecentlySeen = (now - info.lastSeen) < 10000
if (!wasRecentlySeen) {
stalePeerIDs.add(existingPeerID)
}
}
}

// Remove stale peer IDs
stalePeerIDs.forEach { stalePeerID ->
removePeer(stalePeerID, notifyDelegate = false)
}

// Check if this is a new peer announcement
val isFirstAnnounce = !announcedPeers.contains(peerID)
Expand Down Expand Up @@ -313,6 +301,18 @@ class PeerManager {
return peers[peerID]?.nickname
}

/**
* Get disambiguated peer nickname (nickname#suffix if collisions exist)
*/
fun getDisambiguatedNickname(peerID: String): String {
val info = peers[peerID] ?: return peerID
val nick = info.nickname.trim()
val isAmbiguous = peers.values.count { it.nickname.trim().equals(nick, ignoreCase = true) } > 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be made more efficient, every time a peer joins/leaves, all visible messages recompose and each one calls:
peers.values.count { it.nickname.trim().equals(nick, ignoreCase = true) } > 1
Consider using a Set to cache ambiguous names instead of calculating this for every message.
Not really a big issue because only 20 messages are shown on the screen anyway, but it's not optimal.

Assuming we have 50 peers:

Without cache:

  • 20 visible messages × 50 peers = 1,000 string comparisons per peer change

With cache:

  • Rebuild cache: 50 iterations (once)
  • Lookups: 20 Set checks (O(1) each)
  • Total: 70 operations
  • Savings: 93%


// Suffix is appended if ambiguous, UNLESS this is our own Peer ID
return if (isAmbiguous && peerID != myPeerID) "$nick#${peerID.takeLast(4)}" else nick
}

/**
* Get all peer nicknames
*/
Expand Down Expand Up @@ -433,7 +433,9 @@ class PeerManager {
private fun cleanupStalePeers() {
val now = System.currentTimeMillis()

val peersToRemove = peers.filterValues { (now - it.lastSeen) > stalePeerTimeoutMs }
val peersToRemove = peers.filterValues {
it.id != myPeerID && (now - it.lastSeen) > stalePeerTimeoutMs
}
.keys
.toList()

Expand Down
23 changes: 19 additions & 4 deletions app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,25 @@ class GeohashRepository(
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
val parts = targetNickname.split("#")
val baseName = parts[0]
val suffix = if (parts.size > 1) parts[1] else null

if (suffix != null) {
// Exact match attempt: nickname + pubkey suffix
return geoNicknames.entries.find { (id, nick) ->
val base = nick.split("#").firstOrNull() ?: nick
base.equals(baseName, ignoreCase = true) && id.endsWith(suffix, ignoreCase = true)
}?.key
} else {
// No suffix provided. Check for matches.
val matches = geoNicknames.entries.filter { (_, nick) ->
val base = nick.split("#").firstOrNull() ?: nick
base.equals(baseName, ignoreCase = true)
}
// If only one match, return it. If multiple, it's ambiguous (return null or handle)
return if (matches.size == 1) matches.first().key else null
}
}

// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
)

// Messages area - takes up available space, will compress when keyboard appears
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
peerNicknames = peerNicknames,
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
Expand Down Expand Up @@ -167,9 +169,13 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
// Use disambiguated name so actions like /block work with suffixes if collisions exist
val disambiguated = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
viewModel.meshService.getDisambiguatedNickname(message.senderPeerID)
} else {
message.sender
Comment on lines 170 to +176
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enhancement (Optional): Compose UI should be as dumb as possible, we use viewModels as brains to control what is usually displayed on the screen, or helper functions for formatting UI stuff.

I would personally move this to a viewModel

// In ChatViewModel.kt
fun handleMessageLongPress(message: BitchatMessage) {
      val disambiguated = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
          meshService.getDisambiguatedNickname(message.senderPeerID)
      } else {
          message.sender
      }
        ```
}

// In ChatScreen.kt
onMessageLongPress = viewModel::handleMessageLongPress

or use an MVI (model view intent) approach that sends events/ intents from screen UI to the viewModel and proceed this event in the viewModel:

// in ChatScreen
onMessageLongPress = { onAction(MessageLongPressed(message)) }

// in ChatViewmodel
fun onAction(action: ChatActions){
   when(action){
      is MessageLongPressed -> { /** Do some work **/ }
    }
}

}
selectedUserForSheet = disambiguated
selectedMessageForSheet = message
showUserSheet = true
},
Expand Down
Loading
Loading