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.protocol.BitchatPacket import kotlinx.coroutines.launch import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay 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.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString /** * 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 approveLegacyPrivateMedia(requestId: String) { mediaSendingManager.approveLegacyPrivateMedia(requestId) } fun cancelLegacyPrivateMedia(requestId: String) { mediaSendingManager.cancelLegacyPrivateMedia(requestId) } 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 seenMessageStore by lazy { com.bitchat.android.services.SeenMessageStore.getInstance(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, hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent, markMessageReadLocally = seenMessageStore::markReadLocally ) 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, viewModelScope ) { 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 }, markMessageReadLocally = seenMessageStore::markReadLocally ) // 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 peerFavoritedUs: StateFlow> = state.peerFavoritedUs 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 legacyPrivateMediaConsent: StateFlow = mediaSendingManager.legacyPrivateMediaConsent 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() ContactDirectory.initialize(getApplication()) { mesh } com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() // Mark queued private messages as failed when the router gives up on them try { com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID -> messageManager.updateMessageDeliveryStatus( messageID, com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery") ) } } catch (_: Exception) { } // 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 -> val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer) state.setPrivateChats(canonicalChats) // Recompute unread set using SeenMessageStore for robustness across Activity recreation try { val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() canonicalChats.forEach { (peer, list) -> if (list.any { msg -> msg.sender != myNick && msg.sender != "system" && !seenMessageStore.hasBeenReadLocally(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()) // Reflect "they favorited us" changes into reactive UI state (drives star celebrations) refreshPeerFavoritedUs() try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( object : com.bitchat.android.favorites.FavoritesChangeListener { override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs() override fun onAllCleared() = refreshPeerFavoritedUs() } ) } catch (_: Exception) { } // 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 // 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 */ private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey) } // MARK: - Channel Management (delegated) fun joinChannel(channel: String, password: String? = null): Boolean { return channelManager.joinChannel(channel, password, mesh.myPeerID) } fun switchToChannel(channel: String?) { channelManager.switchToChannel(channel) } 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) { val conversationID = ContactDirectory.canonicalConversationId(peerID) // Notify notification manager about current private chat setCurrentPrivateChatPeer(conversationID) // Clear notifications for this sender since user is now viewing the chat clearNotificationsForSender(conversationID) } } 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 }, 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()) 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 = ContactDirectory.canonicalConversationId( com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = selectedPeer, connectedPeers = state.getConnectedPeersValue(), meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey }, 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 -> val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh) val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) { messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent) } } } 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 { // 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 val peerInfo = mesh.getPeerInfo(peerID) if (peerInfo?.noisePublicKey != null) { noiseKey = peerInfo.noisePublicKey nickname = peerInfo.nickname } else if (ContactIdentityResolver.isNoiseKeyHex(peerID)) { noiseKey = ContactIdentityResolver.bytesFromHex(peerID) val rel = noiseKey?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) } if (rel != null) nickname = rel.peerNickname } else { val contact = ContactDirectory.resolve(peerID) noiseKey = contact.noisePublicKey contact.displayName?.let { nickname = it } } if (noiseKey != null) { 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 ) try { com.bitchat.android.services.MessageRouter .getInstance(getApplication(), mesh) .sendFavoriteNotification(peerID, isNowFavorite) } catch (_: Exception) { } } } catch (_: Exception) { } // Log current state after toggle logCurrentFavoriteState() } private fun refreshPeerFavoritedUs() { try { val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared .getAllRelationships() .filter { it.theyFavoritedUs } .mapNotNull { relationship -> runCatching { ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) }.getOrNull() } .toSet() state.setPeerFavoritedUs(fingerprints) } catch (_: Exception) { } } 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? { val contact = ContactDirectory.resolve(peerID) val meshPeerID = contact.meshPeerID ?: peerID return contact.displayName ?: state.peerNicknames.value[meshPeerID] ?: try { mesh.getPeerNicknames()[meshPeerID] } 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) { val conversationID = ContactDirectory.canonicalConversationId(peerID) state.setPrivateChatSheetPeer(conversationID) } 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 didResolvePrivateMediaPolicy(peerID: String) { mediaSendingManager.retryPendingPrivateMedia(peerID) } 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) } // MARK: - Emergency Clear fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") try { com.bitchat.android.geohash.LocationChannelManager .getInstance(getApplication()) .disableLocationServices() } catch (_: Exception) { } // A pending one-shot downgrade confirmation must not survive panic or // become actionable against the fresh post-wipe identity. mediaSendingManager.clearPendingPrivateMediaConsent() // 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( liveLocationGeohashes: Collection, userSelectedGeohashes: Collection, ) { geohashViewModel.beginGeohashSampling( liveLocationGeohashes = liveLocationGeohashes, userSelectedGeohashes = userSelectedGeohashes ) } /** * 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: - Canonical peer identities /** * Return the stable identity used by every UI surface to color a mesh peer. */ fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID) /** * Return the stable identity used by every UI surface to color a Nostr peer. */ fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) }