package com.bitchat.android.ui import android.app.Application import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.MeshService import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProfileManager import com.bitchat.android.nostr.NostrKind import com.bitchat.android.nostr.NostrRelayManager import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.launch import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import kotlin.random.Random import com.bitchat.android.services.VerificationService import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString import java.security.MessageDigest /** * Refactored ChatViewModel - Main coordinator for bitchat functionality * Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility */ class ChatViewModel( application: Application, initialMeshService: BluetoothMeshService, initialUnifiedMeshService: MeshService ) : AndroidViewModel(application), BluetoothMeshDelegate { // Made var to support mesh service replacement after panic clear var meshService: BluetoothMeshService = initialMeshService private set private var unifiedMeshService: MeshService = initialUnifiedMeshService private val mesh: MeshService get() = unifiedMeshService private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { private const val TAG = "ChatViewModel" } fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath) } fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath) } fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } fun getCurrentNpub(): String? { return try { NostrIdentityBridge .getCurrentNostrIdentity(getApplication()) ?.npub } catch (_: Exception) { null } } fun buildMyQRString(nickname: String, npub: String?): String { return VerificationService.buildMyQRString(nickname, npub) ?: "" } // MARK: - State management private val state = ChatState( scope = viewModelScope, ) // Transfer progress tracking private val transferMessageMap = mutableMapOf() private val messageTransferMap = mutableMapOf() // Specialized managers private val dataManager = DataManager(application.applicationContext) private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID) override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID) override fun getMyPeerID(): String = mesh.myPeerID } val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager( application.applicationContext, NotificationManagerCompat.from(application.applicationContext), NotificationIntervalManager() ) private val verificationHandler = VerificationHandler( context = application.applicationContext, scope = viewModelScope, getMeshService = { mesh }, identityManager = identityManager, state = state, notificationManager = notificationManager, messageManager = messageManager ) val verifiedFingerprints = verificationHandler.verifiedFingerprints // Media file sending manager private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh } // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( state = state, messageManager = messageManager, channelManager = channelManager, privateChatManager = privateChatManager, notificationManager = notificationManager, coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, getMyPeerID = { mesh.myPeerID }, getMeshService = { mesh } ) // 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, dataManager = dataManager, notificationManager = notificationManager ) val messages: StateFlow> = state.messages val connectedPeers: StateFlow> = state.connectedPeers val nickname: StateFlow = state.nickname val isConnected: StateFlow = state.isConnected val privateChats: StateFlow>> = state.privateChats val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages val joinedChannels: StateFlow> = state.joinedChannels val currentChannel: StateFlow = state.currentChannel val channelMessages: StateFlow>> = state.channelMessages val unreadChannelMessages: StateFlow> = state.unreadChannelMessages val passwordProtectedChannels: StateFlow> = state.passwordProtectedChannels val showPasswordPrompt: StateFlow = state.showPasswordPrompt val passwordPromptChannel: StateFlow = state.passwordPromptChannel val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val showCommandSuggestions: StateFlow = state.showCommandSuggestions val commandSuggestions: StateFlow> = state.commandSuggestions val showMentionSuggestions: StateFlow = state.showMentionSuggestions val mentionSuggestions: StateFlow> = state.mentionSuggestions val favoritePeers: StateFlow> = state.favoritePeers val peerSessionStates: StateFlow> = state.peerSessionStates val peerFingerprints: StateFlow> = state.peerFingerprints val peerNicknames: StateFlow> = state.peerNicknames val peerRSSI: StateFlow> = state.peerRSSI val peerDirect: StateFlow> = state.peerDirect val showAppInfo: StateFlow = state.showAppInfo val showMeshPeerList: StateFlow = state.showMeshPeerList val privateChatSheetPeer: StateFlow = state.privateChatSheetPeer val showVerificationSheet: StateFlow = state.showVerificationSheet val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet val selectedLocationChannel: StateFlow = state.selectedLocationChannel val isTeleported: StateFlow = state.isTeleported val geohashPeople: StateFlow> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts val meshServiceFacade: MeshService get() = mesh val myPeerID: String get() = mesh.myPeerID fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID) fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID) fun initiateMeshHandshake(peerID: String) { mesh.initiateNoiseHandshake(peerID) } init { // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() // Hydrate UI state from process-wide AppStateStore to survive Activity recreation viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.peers.collect { peers -> state.setConnectedPeers(peers) state.setIsConnected(peers.isNotEmpty()) } } catch (_: Exception) { } } viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs -> // Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn state.setMessages(msgs) } } catch (_: Exception) { } } viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> // Replace with store snapshot state.setPrivateChats(byPeer) // Recompute unread set using SeenMessageStore for robustness across Activity recreation try { val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() byPeer.forEach { (peer, list) -> if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) } state.setUnreadPrivateMessages(unread) } catch (_: Exception) { } } } catch (_: Exception) { } } viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel -> // Replace with store snapshot state.setChannelMessages(byChannel) } } catch (_: Exception) { } } // Subscribe to BLE transfer progress and reflect in message deliveryStatus viewModelScope.launch { com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> mediaSendingManager.handleTransferProgressEvent(evt) } } // Removed background location notes subscription. Notes now load only when sheet opens. } fun cancelMediaSend(messageId: String) { // Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state mediaSendingManager.cancelMediaSend(messageId) } private fun loadAndInitialize() { // Load nickname val nickname = dataManager.loadNickname() state.setNickname(nickname) // Load data val (joinedChannels, protectedChannels) = channelManager.loadChannelData() state.setJoinedChannels(joinedChannels) state.setPasswordProtectedChannels(protectedChannels) // Initialize channel messages joinedChannels.forEach { channel -> if (!state.getChannelMessagesValue().containsKey(channel)) { val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() updatedChannelMessages[channel] = emptyList() state.setChannelMessages(updatedChannelMessages) } } // Load other data dataManager.loadFavorites() state.setFavoritePeers(dataManager.favoritePeers.toSet()) dataManager.loadBlockedUsers() dataManager.loadGeohashBlockedUsers() // Log all favorites at startup dataManager.logAllFavorites() logCurrentFavoriteState() // Initialize session state monitoring initializeSessionStateMonitoring() // Bridge DebugSettingsManager -> Chat messages when verbose logging is on viewModelScope.launch { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().debugMessages.collect { msgs -> if (com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().verboseLoggingEnabled.value) { // Only show debug logs in the Mesh chat timeline to avoid leaking into geohash chats val selectedLocation = state.selectedLocationChannel.value if (selectedLocation is com.bitchat.android.geohash.ChannelID.Mesh) { // Append only latest debug message as system message to avoid flooding msgs.lastOrNull()?.let { dm -> messageManager.addSystemMessage(dm.content) } } } } } // Initialize new geohash architecture geohashViewModel.initialize() // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) // Load verified fingerprints from secure storage verificationHandler.loadVerifiedFingerprints() // Ensure NostrTransport knows our mesh peer ID for embedded packets try { val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) nostrTransport.senderPeerID = mesh.myPeerID } catch (_: Exception) { } // Note: Mesh service is now started by MainActivity // Initialize NostrProfileManager with relay/mesh hooks try { NostrProfileManager.initialize( context = getApplication(), meshService = meshService, subscribe = { filter, id, handler -> NostrRelayManager.getInstance(getApplication()).subscribe(filter, id, handler) }, unsubscribe = { id -> NostrRelayManager.getInstance(getApplication()).unsubscribe(id) }, sendEvent = { event, relays -> NostrRelayManager.getInstance(getApplication()).sendEvent(event, relays) } ) } catch (_: Exception) { } // BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch. } override fun onCleared() { super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } // MARK: - Nickname Management fun setNickname(newNickname: String) { state.setNickname(newNickname) dataManager.saveNickname(newNickname) mesh.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 { return channelManager.joinChannel(channel, password, mesh.myPeerID) } fun switchToChannel(channel: String?) { // Manage subscription lifecycle for our Nostr-profile channel (#me) val prev = state.getCurrentChannelValue() if (prev == "#me" && channel != "#me") { try { NostrProfileManager.unsubscribeProfile() } catch (_: Exception) { } } channelManager.switchToChannel(channel) if (channel == "#me") { try { NostrProfileManager.subscribeMyProfile { event -> try { // Accept all valid kind=1 text notes from our profile if (event.kind == NostrKind.TEXT_NOTE && event.isValidSignature()) { val meNick = state.getNicknameValue() ?: meshService.myPeerID val originalContent = event.content val eventId = event.id // Parse nostr: URIs to human-readable format (uses cache) val parsedContent = NostrIdentityBridge.parseNostrContent(originalContent) val msg = BitchatMessage( id = eventId, sender = meNick, content = parsedContent, timestamp = Date(event.createdAt.toLong() * 1000L), isRelay = true, senderPeerID = meshService.myPeerID, channel = "#me" ) channelManager.addChannelMessage("#me", msg, meshService.myPeerID) // Resolve references asynchronously and update message when done NostrIdentityBridge.resolveNostrReferences(originalContent) { // Re-parse with updated cache and update the message val updatedContent = NostrIdentityBridge.parseNostrContent(originalContent) if (updatedContent != parsedContent) { channelManager.updateChannelMessageContent("#me", eventId, updatedContent) } } } } catch (e: Exception) { Log.w(TAG, "Failed to process profile event: ${e.message}") } } } catch (e: Exception) { Log.w(TAG, "Failed to subscribe to #me profile: ${e.message}") } } } fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) mesh.sendMessage("left $channel", emptyList(), null) } // 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, mesh) if (success) { // Notify notification manager about current private chat setCurrentPrivateChatPeer(peerID) // Clear notifications for this sender since user is now viewing the chat clearNotificationsForSender(peerID) // Persistently mark all messages in this conversation as read so Nostr fetches // after app restarts won't re-mark them as unread. try { val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) val chats = state.getPrivateChatsValue() val messages = chats[peerID] ?: emptyList() messages.forEach { msg -> try { seen.markRead(msg.id) } catch (_: Exception) { } } } catch (_: Exception) { } } } fun endPrivateChat() { privateChatManager.endPrivateChat() // Notify notification manager that no private chat is active setCurrentPrivateChatPeer(null) // Clear mesh mention notifications since user is now back in mesh chat clearMeshMentionNotifications() // Ensure sheet is hidden hidePrivateChatSheet() } // MARK: - Open Latest Unread Private Chat fun openLatestUnreadPrivateChat() { try { val unreadKeys = state.getUnreadPrivateMessagesValue() if (unreadKeys.isEmpty()) return val me = state.getNicknameValue() ?: mesh.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 -> mesh.getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> mesh.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 } showPrivateChatSheet(openPeer) } catch (e: Exception) { Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") } } // END - Open Latest Unread Private Chat // MARK: - Message Sending fun sendMessage(content: String) { if (content.isEmpty()) return // Check for commands if (content.startsWith("/")) { val selectedLocationForCommand = state.selectedLocationChannel.value commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel -> if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { // Route command-generated public messages via Nostr in geohash channels geohashViewModel.sendGeohashMessage( messageContent, selectedLocationForCommand.channel, mesh.myPeerID, state.getNicknameValue() ) } else { mesh.sendMessage(messageContent, mentions, channel) } }, this) return } val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue()) // REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions // This was causing messages like "test @jack#1234 test" to auto-join channel "#1234" var selectedPeer = state.getSelectedPrivateChatPeerValue() val currentChannelValue = state.getCurrentChannelValue() if (selectedPeer != null) { // If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = selectedPeer, connectedPeers = state.getConnectedPeersValue(), meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true }, 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()) { privateChatManager.startPrivateChat(canonical, mesh) // If we're in the private chat sheet, update its active peer too if (state.getPrivateChatSheetPeerValue() != null) { showPrivateChatSheet(canonical) } } } // Send private message val recipientNickname = nicknameForPeer(selectedPeer) privateChatManager.sendPrivateMessage( content, selectedPeer, recipientNickname, state.getNicknameValue(), mesh.myPeerID ) { messageContent, peerID, recipientNicknameParam, messageId -> // Route via MessageRouter (mesh when connected+established, else Nostr) val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh) router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) } } else { // Check if we're in a location channel val selectedLocationChannel = state.selectedLocationChannel.value if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { // Send to geohash channel via Nostr ephemeral event geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue()) } else { // If we're in the special profile channel #me, publish via NostrProfileManager Log.d(TAG, "sendMessage: currentChannelValue=$currentChannelValue") if (currentChannelValue == "#me") { val nick = state.getNicknameValue() Log.d(TAG, "Publishing to #me profile channel") // Local echo val echo = BitchatMessage( sender = nick ?: meshService.myPeerID, content = content, timestamp = Date(), isRelay = false, senderPeerID = meshService.myPeerID, mentions = if (mentions.isNotEmpty()) mentions else null, channel = "#me" ) channelManager.addChannelMessage("#me", echo, meshService.myPeerID) // Publish to Nostr (async with result handling) viewModelScope.launch { val result = NostrProfileManager.publishProfileMessage(content, nick) when (result) { is NostrProfileManager.PublishResult.PowRequired -> { // Show system message explaining PoW is needed val systemMessage = BitchatMessage( sender = "system", content = "⚠️ Para enviar mensajes en #me sin conexión, debes activar Proof of Work en Configuración → Nostr → PoW", timestamp = Date(), isRelay = false, channel = "#me" ) channelManager.addChannelMessage("#me", systemMessage, null) } is NostrProfileManager.PublishResult.PowMiningFailed -> { val systemMessage = BitchatMessage( sender = "system", content = "❌ Error: El minado de PoW falló. Intenta con una dificultad menor.", timestamp = Date(), isRelay = false, channel = "#me" ) channelManager.addChannelMessage("#me", systemMessage, null) } is NostrProfileManager.PublishResult.Error -> { Log.w(TAG, "Failed to publish profile message: ${result.message}") } else -> { /* Success or other cases */ } } } return } // Send public/channel message via mesh val message = BitchatMessage( sender = state.getNicknameValue() ?: mesh.myPeerID, content = content, timestamp = Date(), isRelay = false, senderPeerID = mesh.myPeerID, mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) if (currentChannelValue != null) { channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { channelManager.sendEncryptedChannelMessage( content, mentions, currentChannelValue, state.getNicknameValue(), mesh.myPeerID, onEncryptedPayload = { encryptedData -> mesh.sendMessage(content, mentions, currentChannelValue) }, onFallback = { mesh.sendMessage(content, mentions, currentChannelValue) } ) } else { mesh.sendMessage(content, mentions, currentChannelValue) } } else { messageManager.addMessage(message) mesh.sendMessage(content, mentions, null) } } } } // MARK: - Utility Functions fun getPeerIDForNickname(nickname: String): String? { return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key } fun toggleFavorite(peerID: String) { Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) // Persist relationship in FavoritesPersistenceService try { var noiseKey: ByteArray? = null var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID // Case 1: Live mesh peer with known info val peerInfo = mesh.getPeerInfo(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) { // 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!!, nickname = nickname, isFavorite = isNowFavorite ) // Send favorite notification via mesh or Nostr with our npub if available try { com.bitchat.android.services.MessageRouter .getInstance(getApplication(), mesh) .sendFavoriteNotification(peerID, isNowFavorite) } catch (_: Exception) { } } } catch (_: Exception) { } // Log current state after toggle logCurrentFavoriteState() } private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") } private fun isConnectedOnMesh(peerID: String): Boolean { return try { mesh.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } } private fun hasEstablishedSessionOnMesh(peerID: String): Boolean { return try { mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) } catch (_: Exception) { false } } private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean { return hasEstablishedSessionOnMesh(peerID) } private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) { mesh.initiateNoiseHandshake(peerID) } private fun nicknameForPeer(peerID: String): String? { return state.peerNicknames.value[peerID] ?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null } } private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } } /** * Initialize session state monitoring for reactive UI updates */ private fun initializeSessionStateMonitoring() { viewModelScope.launch { while (true) { delay(1000) // Check session states every second updateReactiveStates() } } } // Location notes subscription management moved to LocationNotesViewModelExtensions.kt /** * Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI) */ private fun updateReactiveStates() { val currentPeers = state.getConnectedPeersValue() // Update session states val prevStates = state.getPeerSessionStatesValue() val sessionStates = currentPeers.associateWith { peerID -> sessionStateForPeer(peerID).toString() } state.setPeerSessionStates(sessionStates) // Detect new established sessions and flush router outbox for them and their noiseHex aliases sessionStates.forEach { (peerID, newState) -> val old = prevStates[peerID] if (old != "established" && newState == "established") { com.bitchat.android.services.MessageRouter .getInstance(getApplication(), mesh) .onSessionEstablished(peerID) } } // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) fingerprints.forEach { (peerID, fingerprint) -> identityManager.cachePeerFingerprint(peerID, fingerprint) val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null } val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() if (noiseKeyHex != null) { identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint) } info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname -> identityManager.cacheFingerprintNickname(fingerprint, nickname) } } state.setPeerNicknames(mesh.getPeerNicknames()) state.setPeerRSSI(mesh.getPeerRSSI()) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> mesh.getPeerInfo(pid)?.isDirectConnection == true } state.setPeerDirect(directMap) } catch (_: Exception) { } // Flush any pending QR verification once a Noise session is established currentPeers.forEach { peerID -> if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) { verificationHandler.sendPendingVerificationIfNeeded(peerID) } } } // MARK: - QR Verification fun isPeerVerified(peerID: String, verifiedFingerprints: Set): Boolean { if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) return fingerprint != null && verifiedFingerprints.contains(fingerprint) } fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set): Boolean { val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) return verifiedFingerprints.contains(fingerprint) } fun unverifyFingerprint(peerID: String) { verificationHandler.unverifyFingerprint(peerID) } fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { return verificationHandler.beginQRVerification(qr) } // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { return mesh.getDebugStatus() } fun setCurrentPrivateChatPeer(peerID: String?) { notificationManager.setCurrentPrivateChatPeer(peerID) } fun setCurrentGeohash(geohash: String?) { notificationManager.setCurrentGeohash(geohash) } fun clearNotificationsForSender(peerID: String) { notificationManager.clearNotificationsForSender(peerID) } fun clearNotificationsForGeohash(geohash: String) { notificationManager.clearNotificationsForGeohash(geohash) } fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } private var reopenSidebarAfterVerification = false fun showVerificationSheet(fromSidebar: Boolean = false) { if (fromSidebar) { reopenSidebarAfterVerification = true } state.setShowVerificationSheet(true) } fun hideVerificationSheet() { state.setShowVerificationSheet(false) if (reopenSidebarAfterVerification) { reopenSidebarAfterVerification = false state.setShowMeshPeerList(true) } } fun showSecurityVerificationSheet() { state.setShowSecurityVerificationSheet(true) } fun hideSecurityVerificationSheet() { state.setShowSecurityVerificationSheet(false) } fun showMeshPeerList() { state.setShowMeshPeerList(true) } fun hideMeshPeerList() { state.setShowMeshPeerList(false) } fun showPrivateChatSheet(peerID: String) { state.setPrivateChatSheetPeer(peerID) } fun hidePrivateChatSheet() { state.setPrivateChatSheetPeer(null) } fun getPeerFingerprintForDisplay(peerID: String): String? { return verificationHandler.getPeerFingerprintForDisplay(peerID) } fun getMyFingerprint(): String { return verificationHandler.getMyFingerprint() } fun resolvePeerDisplayNameForFingerprint(peerID: String): String { return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) } fun verifyFingerprintValue(fingerprint: String) { verificationHandler.verifyFingerprintValue(fingerprint) } fun unverifyFingerprintValue(fingerprint: String) { verificationHandler.unverifyFingerprintValue(fingerprint) } // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { commandProcessor.updateCommandSuggestions(input) } fun selectCommandSuggestion(suggestion: CommandSuggestion): String { return commandProcessor.selectCommandSuggestion(suggestion) } // MARK: - Mention Autocomplete fun updateMentionSuggestions(input: String) { commandProcessor.updateMentionSuggestions(input, mesh, this) } fun selectMentionSuggestion(nickname: String, currentText: String): String { return commandProcessor.selectMentionSuggestion(nickname, currentText) } // MARK: - BluetoothMeshDelegate Implementation (delegated) override fun didReceiveMessage(message: BitchatMessage) { meshDelegateHandler.didReceiveMessage(message) } override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { meshDelegateHandler.didReceiveDeliveryAck(messageID, recipientPeerID) } override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) } override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { verificationHandler.didReceiveVerifyChallenge(peerID, payload) } override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { verificationHandler.didReceiveVerifyResponse(peerID, payload) } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) } override fun getNickname(): String? { return meshDelegateHandler.getNickname() } override fun isFavorite(peerID: String): Boolean { return meshDelegateHandler.isFavorite(peerID) } // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager // MARK: - Emergency Clear fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") // Clear all UI managers messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() dataManager.clearAllData() // Clear seen message store try { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } // Clear all mesh service data clearAllMeshServiceData() // Clear all cryptographic data clearAllCryptographicData() // Clear all notifications notificationManager.clearAllNotifications() // Clear all media files com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch try { // Clear geohash bookmarks too (panic should remove everything) try { val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) store.clearAll() } catch (_: Exception) { } try { val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) locationManager.clearPersistedChannel() } catch (_: Exception) { } geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") } // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) // Recreate mesh service with fresh identity recreateMeshServiceAfterPanic() Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } /** * Recreate the mesh service with a fresh identity after panic clear. * This ensures the new cryptographic keys are used for a new peer ID. */ private fun recreateMeshServiceAfterPanic() { val oldPeerID = mesh.myPeerID // Clear the holder so getOrCreate() returns a fresh instance MeshServiceHolder.clear() // Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData) val freshMeshService = MeshServiceHolder.getOrCreate(getApplication()) val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication()) // Replace our reference and set up the new service meshService = freshMeshService unifiedMeshService = freshUnifiedMeshService mesh.delegate = this // Restart mesh operations with new identity mesh.startServices() mesh.sendBroadcastAnnounce() Log.d( TAG, "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}" ) } /** * Clear all mesh service related data */ private fun clearAllMeshServiceData() { try { // Request mesh service to clear all its internal data mesh.clearAllInternalData() Log.d(TAG, "✅ Cleared all mesh service data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}") } } /** * Clear all cryptographic data including persistent identity */ private fun clearAllCryptographicData() { try { // Clear encryption service persistent identity (Ed25519 signing keys) mesh.clearAllEncryptionData() // Clear secure identity state (if used) try { val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() // 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 { FavoritesPersistenceService.shared.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") } catch (_: Exception) { } Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") } } /** * Get participant count for a specific geohash (5-minute activity window) */ fun geohashParticipantCount(geohash: String): Int { return geohashViewModel.geohashParticipantCount(geohash) } /** * Begin sampling multiple geohashes for participant activity */ fun beginGeohashSampling(geohashes: List) { geohashViewModel.beginGeohashSampling(geohashes) } /** * End geohash sampling */ fun endGeohashSampling() { geohashViewModel.endGeohashSampling() } /** * Check if a geohash person is teleported (iOS-compatible) */ fun isPersonTeleported(pubkeyHex: String): Boolean { return geohashViewModel.isPersonTeleported(pubkeyHex) } /** * Start geohash DM with pubkey hex (iOS-compatible) */ fun startGeohashDM(pubkeyHex: String) { geohashViewModel.startGeohashDM(pubkeyHex) { convKey -> showPrivateChatSheet(convKey) } } fun startGeohashDMByNickname(nickname: String) { geohashViewModel.startGeohashDMByNickname(nickname) { convKey -> showPrivateChatSheet(convKey) } } fun startGeohashDMByShortId(shortId: String) { geohashViewModel.startGeohashDMByShortId(shortId) { convKey -> showPrivateChatSheet(convKey) } } fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { geohashViewModel.selectLocationChannel(channel) } /** * Block a user in geohash channels by their nickname */ fun blockUserInGeohash(targetNickname: String) { geohashViewModel.blockUserInGeohash(targetNickname) } // MARK: - Navigation Management fun showAppInfo() { state.setShowAppInfo(true) } fun hideAppInfo() { state.setShowAppInfo(false) } /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system */ fun handleBackPressed(): Boolean { return when { // Close app info dialog state.getShowAppInfoValue() -> { hideAppInfo() true } // Close password dialog state.getShowPasswordPromptValue() -> { state.setShowPasswordPrompt(false) state.setPasswordPromptChannel(null) true } // Exit private chat state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> { endPrivateChat() true } // Exit channel view state.getCurrentChannelValue() != null -> { switchToChannel(null) true } // No special navigation state - let system handle (usually exits app) else -> false } } // MARK: - iOS-Compatible Color System /** * Get consistent color for a mesh peer by ID (iOS-compatible) */ fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color { // Try to get stable Noise key, fallback to peer ID val seed = "noise:${peerID.lowercase()}" return colorForPeerSeed(seed, isDark).copy() } /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) } }