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.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn 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.GeohashConversationRegistry import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 = { messageID -> seenMessageStore.hasReadReceiptBeenSent(messageID) }, markMessageReadLocally = { messageID -> seenMessageStore.markReadLocally(messageID) } ) 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 = { messageID -> seenMessageStore.markReadLocally(messageID) } ) // New Geohash architecture ViewModel (replaces God object service usage in UI path) val geohashViewModel = GeohashViewModel( application = application, state = state, messageManager = messageManager, 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 internal val conversations: StateFlow> = combine( state.unreadPrivateMessages, state.privateChats, state.nickname, state.connectedPeers ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs -> val seenStore = seenMessageStore val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID -> runCatching { ContactDirectory.aliasesForConversation(peerID) + ContactDirectory.canonicalConversationId(peerID) }.getOrDefault(setOf(peerID)) .mapTo(mutableSetOf()) { it.lowercase() } } buildConversationSummaries( unreadConversationIDs = unreadConversationIDs, privateChats = chats, currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID), canonicalize = ContactDirectory::canonicalConversationId, isMessageRead = { message -> com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) || seenStore.hasBeenReadLocally(message.id) } ).map { summary -> val resolution = ContactDirectory.resolve(summary.conversationID) val resolvedNostrPubkey = summary.nostrPubkey ?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex) val aliases = buildSet { addAll(summary.identityAliases) add(summary.conversationID) add(resolution.conversationID) resolution.meshPeerID?.let(::add) resolution.noiseKeyHex?.let(::add) resolvedNostrPubkey ?.let(ContactIdentityResolver::nostrAliasForPubkey) ?.let(::add) }.mapTo(mutableSetOf()) { it.lowercase() } val connectedPeerID = connectedIdentitiesByPeer.entries .firstOrNull { (_, connectedAliases) -> connectedAliases.any(aliases::contains) } ?.key summary.copy( displayName = resolution.displayName ?.takeUnless { it.isBlank() || it.equals("Unknown", ignoreCase = true) } ?: summary.displayName, nostrPubkey = resolvedNostrPubkey, identityAliases = aliases, isConnected = connectedPeerID != null, connectedPeerID = connectedPeerID, sourceGeohash = aliases .asSequence() .mapNotNull(GeohashConversationRegistry::get) .firstOrNull() ) }.let(::sortConversationSummaries) } .flowOn(Dispatchers.IO) .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) 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() // Application startup performs the initial restore. Repeat it for every new UI owner // because a quick reopen can reuse a process whose in-memory state was cleared during // controlled shutdown. com.bitchat.android.services.AppStateStore.reloadConversationPersistence( getApplication() ) // 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, unreadConversationIDs) = withContext(Dispatchers.IO) { val canonical = ContactDirectory.canonicalizePrivateChats(byPeer) val unread = try { val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID } canonical .filterValues { messages -> messages.any { message -> message.sender != myNick && message.sender != "system" && !com.bitchat.android.services.AppStateStore .isPrivateMessageRead(message.id) && !seenMessageStore.hasBeenReadLocally(message.id) } } .keys } catch (_: Exception) { state.getUnreadPrivateMessagesValue() } canonical to unread } state.setPrivateChats(canonicalChats) // Recompute unread set using SeenMessageStore for robustness across Activity recreation state.setUnreadPrivateMessages(unreadConversationIDs) } } 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() { geohashViewModel.shutdownUiSubscriptions() com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) // 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) suspend fun startPrivateChat(peerID: String) { // For geohash conversation keys, ensure DM subscription is active if (peerID.startsWith("nostr_")) { ensureGeohashDMSubscriptionIfNeeded(peerID) } val (conversationID, success) = withContext(Dispatchers.IO) { val canonicalID = ContactDirectory.canonicalConversationId(peerID) val unreadAliases = matchingUnreadAliases( unreadConversationIDs = state.getUnreadPrivateMessagesValue(), canonicalConversationID = canonicalID, canonicalize = ContactDirectory::canonicalConversationId ) canonicalID to privateChatManager.startPrivateChat( peerID = canonicalID, meshService = mesh, unreadAliases = unreadAliases ) } if (success) { // 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() } fun deletePrivateConversation(peerOrConversationID: String) { val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) val deletedMessages = state.getPrivateChatsValue() .filterKeys { key -> ContactDirectory.canonicalConversationId(key) .equals(canonicalID, ignoreCase = true) } .values .flatten() val unreadAliases = matchingUnreadAliases( unreadConversationIDs = state.getUnreadPrivateMessagesValue(), canonicalConversationID = canonicalID, canonicalize = ContactDirectory::canonicalConversationId ) val deletedMessageIDs = com.bitchat.android.services.AppStateStore.deletePrivateConversation(canonicalID) val retainedMessages = com.bitchat.android.services.AppStateStore.privateMessages.value.values.flatten() viewModelScope.launch(Dispatchers.IO) { com.bitchat.android.features.file.FileUtils.deleteConversationMedia( context = getApplication(), deletedMessages = deletedMessages, retainedMessages = retainedMessages ) } state.setPrivateChats( ContactDirectory.canonicalizePrivateChats( com.bitchat.android.services.AppStateStore.privateMessages.value ) ) state.setUnreadPrivateMessages( state.getUnreadPrivateMessagesValue() - unreadAliases ) seenMessageStore.remove(deletedMessageIDs) val selected = state.getSelectedPrivateChatPeerValue() if ( selected != null && ContactDirectory.canonicalConversationId(selected) .equals(canonicalID, ignoreCase = true) ) { privateChatManager.endPrivateChat() setCurrentPrivateChatPeer(null) } val sheetPeer = state.getPrivateChatSheetPeerValue() if ( sheetPeer != null && ContactDirectory.canonicalConversationId(sheetPeer) .equals(canonicalID, ignoreCase = true) ) { hidePrivateChatSheet() } clearNotificationsForSender(canonicalID) } // 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 private var panicClearInProgress = false fun panicClearAllData() { if (panicClearInProgress) return panicClearInProgress = true viewModelScope.launch { try { performPanicClearAllData() } finally { panicClearInProgress = false } } } private suspend fun performPanicClearAllData() { 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() // Stop all message admission before wiping storage. The AppStateStore gate also rejects // any transport callback already in flight until the fresh identity is ready. clearAllMeshServiceData() val conversationsCleared = com.bitchat.android.services.AppStateStore .panicClearPrivateConversations() // Clear all UI managers com.bitchat.android.services.AppStateStore.clear() 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 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) if (!conversationsCleared) { // Privacy wins over availability: keep private-message admission and transports // stopped if SQLite could not prove that the conversation history was erased. Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed") return } // Recreate mesh service with fresh identity com.bitchat.android.services.AppStateStore .resumePrivateConversationsAfterPanic() 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) }