bitchat-android/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt
callebtc 5a38aaf3e8
feat: stabilize private chat identity across mesh and Nostr (#745)
* feat: stabilize private chat identity across mesh and Nostr

Introduce ContactDirectory and ContactIdentityResolver so DMs and favorites
use stable contact_* conversation IDs instead of ephemeral mesh peer IDs.

* fix: preserve canonical private message routing

* test: complete private chat regression fixtures
2026-07-26 13:06:22 +02:00

536 lines
20 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.bitchat.android.ui
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
import android.util.Log
/**
* Interface for Noise session operations needed by PrivateChatManager
* This avoids reflection and makes dependencies explicit
*/
interface NoiseSessionDelegate {
fun hasEstablishedSession(peerID: String): Boolean
fun initiateHandshake(peerID: String)
fun getMyPeerID(): String
}
enum class PrivateMessageOrigin {
MESH,
NOSTR
}
/**
* Handles private chat functionality including peer management and blocking.
*/
class PrivateChatManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val noiseSessionDelegate: NoiseSessionDelegate
) {
companion object {
private const val TAG = "PrivateChatManager"
}
private val fingerprintManager = PeerFingerprintManager.getInstance()
// Track received private messages that need read receipts
private val unreadReceivedMessages = mutableMapOf<String, MutableList<BitchatMessage>>()
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val route = ContactDirectory.resolve(conversationID)
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (isPeerBlocked(peerID)) {
val peerNickname = route.displayName ?: getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot start chat with $peerNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
if (meshPeerID != null && meshService.getPeerInfo(meshPeerID)?.isConnected == true) {
establishNoiseSessionIfNeeded(meshPeerID, meshService)
}
try {
consolidateNostrTempConversationIfNeeded(conversationID, meshService)
} catch (_: Exception) { }
state.setSelectedPrivateChatPeer(conversationID)
// Clear unread
messageManager.clearPrivateUnreadMessages(conversationID)
// Initialize chat if needed
messageManager.initializePrivateChat(conversationID)
// Send read receipts for all unread messages from this peer
sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
return true
}
fun endPrivateChat() {
state.setSelectedPrivateChatPeer(null)
}
fun sendPrivateMessage(
content: String,
peerID: String,
recipientNickname: String?,
senderNickname: String?,
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot send message to $recipientNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
val message = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
messageManager.addPrivateMessage(conversationID, message)
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
return true
}
// MARK: - Peer Management
fun isPeerBlocked(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
?: ContactDirectory.resolve(peerID).noisePublicKey?.let { ContactIdentityResolver.fingerprintHex(it) }
return fingerprint != null && dataManager.isUserBlocked(fingerprint)
}
fun toggleFavorite(peerID: String) {
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
if (fingerprint == null && ContactIdentityResolver.isNoiseKeyHex(peerID)) {
try {
val pubBytes = ContactIdentityResolver.bytesFromHex(peerID) ?: return
fingerprint = ContactIdentityResolver.fingerprintHex(pubBytes)
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!!)
Log.d(TAG, "Current favorite status: $wasFavorite")
val currentFavorites = state.getFavoritePeersValue()
Log.d(TAG, "Current UI state favorites: $currentFavorites")
if (wasFavorite) {
dataManager.removeFavorite(fingerprint!!)
Log.d(TAG, "Removed from favorites: $fingerprint")
} else {
dataManager.addFavorite(fingerprint!!)
Log.d(TAG, "Added to favorites: $fingerprint")
}
// Always update state to trigger UI refresh - create new set to ensure change detection
val newFavorites = dataManager.favoritePeers.toSet()
state.setFavoritePeers(newFavorites)
Log.d(TAG, "Force updated favorite peers state. New favorites: $newFavorites")
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
}
fun isFavorite(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
?: if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
ContactIdentityResolver.bytesFromHex(peerID)?.let { ContactIdentityResolver.fingerprintHex(it) }
} else {
null
}
if (fingerprint != null && dataManager.isFavorite(fingerprint)) {
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=true")
return true
}
val persistedFavorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isFavorite == true
} catch (_: Exception) {
false
}
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$persistedFavorite")
return persistedFavorite
}
fun getPeerFingerprint(peerID: String): String? {
return fingerprintManager.getFingerprintForPeer(peerID)
}
fun getPeerFingerprints(): Map<String, String> {
return fingerprintManager.getAllPeerFingerprints()
}
// MARK: - Block/Unblock Operations
fun blockPeer(peerID: String, meshService: MeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null) {
dataManager.addBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "blocked user $peerNickname",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
// End private chat if currently in one with this peer
if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat()
}
return true
}
return false
}
fun unblockPeer(peerID: String, meshService: MeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
dataManager.removeBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "unblocked user $peerNickname",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return true
}
return false
}
fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
return blockPeer(peerID, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
}
fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
return unblockPeer(peerID, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' is not blocked",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
}
fun listBlockedUsers(): String {
val blockedCount = dataManager.blockedUsers.size
return if (blockedCount == 0) {
"no blocked users"
} else {
"blocked users: $blockedCount fingerprints"
}
}
// MARK: - Message Handling
fun handleIncomingPrivateMessage(message: BitchatMessage) {
handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.MESH
)
}
fun handleIncomingPrivateMessage(
message: BitchatMessage,
suppressUnread: Boolean,
origin: PrivateMessageOrigin = PrivateMessageOrigin.MESH
) {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
if (!isPeerBlocked(senderPeerID)) {
// Ensure chat exists
messageManager.initializePrivateChat(conversationID)
// Mesh messages are already reflected through AppStateStore by the mesh service.
// Nostr messages originate here and must be added explicitly, even after their
// sender alias has canonicalized to a contact_* conversation ID.
if (origin == PrivateMessageOrigin.NOSTR) {
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(conversationID, message)
} else {
messageManager.addPrivateMessage(conversationID, message)
}
}
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})")
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
}
}
return
}
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
} else {
messageManager.addPrivateMessage(inferredPeer, message)
}
}
/**
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(
conversationID: String,
meshPeerID: String?,
meshService: MeshService
) {
val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID)
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[canonicalConversationID].orEmpty()
if (messages.isEmpty()) {
Log.d(TAG, "No messages found for conversation $canonicalConversationID to send read receipts")
}
val myNickname = state.getNicknameValue() ?: "unknown"
val hasMesh = meshPeerID != null && try {
meshService.getPeerInfo(meshPeerID)?.isConnected == true &&
meshService.hasEstablishedSession(meshPeerID)
} catch (_: Exception) {
false
}
var sentCount = 0
messages.forEach { msg ->
val senderPeerID = msg.senderPeerID
val isFromTarget = senderPeerID != null && (
senderPeerID == meshPeerID ||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
)
if (isFromTarget && meshPeerID != null) {
try {
if (hasMesh) {
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
sentCount += 1
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
}
}
}
// Clear any locally tracked unread queue for this peer
unreadReceivedMessages.remove(canonicalConversationID)
// Also clear UI unread marker for this peer now that chat is focused/read
try { messageManager.clearPrivateUnreadMessages(canonicalConversationID) } catch (_: Exception) { }
Log.d(
TAG,
"Sent $sentCount read receipts for conversation $canonicalConversationID via mesh peer $meshPeerID"
)
}
fun cleanupDisconnectedPeer(peerID: String) {
// End private chat if peer disconnected
if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat()
}
// Clean up unread messages for disconnected peer
unreadReceivedMessages.remove(peerID)
Log.d(TAG, "Cleaned up unread messages for disconnected peer $peerID")
}
// MARK: - Noise Session Management
/**
* Establish Noise session if needed before starting private chat
* Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement
*/
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) {
if (noiseSessionDelegate.hasEstablishedSession(peerID)) {
Log.d(TAG, "Noise session already established with $peerID")
return
}
Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake")
val myPeerID = noiseSessionDelegate.getMyPeerID()
// 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"
)
noiseSessionDelegate.initiateHandshake(peerID)
} else {
// They should initiate, we send identity announcement through standard announce
Log.d(
TAG,
"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)
}
}
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
}
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
return meshService.getPeerNicknames()[peerID] ?: peerID
}
// MARK: - Consolidation
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String, meshService: MeshService) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
if (ContactIdentityResolver.isNostrAlias(targetPeerID)) return
val tryMergeKeys = mutableListOf<String>()
val noiseKey = when {
ContactIdentityResolver.isNoiseKeyHex(targetPeerID) ->
ContactIdentityResolver.bytesFromHex(targetPeerID)
ContactIdentityResolver.isMeshPeerId(targetPeerID) ->
meshService.getPeerInfo(targetPeerID)?.noisePublicKey
else -> null
}
if (noiseKey != null) {
val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
if (!noiseHex.equals(targetPeerID, ignoreCase = true)) {
tryMergeKeys.add(noiseHex)
}
try {
FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
?.let { tryMergeKeys.add(it) }
} catch (_: Exception) { }
}
if (tryMergeKeys.isNotEmpty()) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = targetConversationID,
keysToMerge = tryMergeKeys
)
}
}
// MARK: - Emergency Clear
fun clearAllPrivateChats() {
state.setSelectedPrivateChatPeer(null)
state.setUnreadPrivateMessages(emptySet())
// Clear unread messages tracking
unreadReceivedMessages.clear()
// Clear fingerprints via centralized manager (only if needed for emergency clear)
// Note: This will be handled by the parent PeerManager.clearAllPeers()
}
// MARK: - Public Getters
fun getAllPeerFingerprints(): Map<String, String> {
return fingerprintManager.getAllPeerFingerprints()
}
}