Files
bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
T
2026-01-05 16:41:41 +07:00

1095 lines
45 KiB
Kotlin

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.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 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,
val meshService: BluetoothMeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
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<String, String>()
private val messageTransferMap = mutableMapOf<String, String>()
// 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 = meshService.hasEstablishedSession(peerID)
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
override fun getMyPeerID(): String = meshService.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,
meshService = meshService,
identityManager = identityManager,
state = state,
notificationManager = notificationManager,
messageManager = messageManager
)
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
// 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 = { meshService.myPeerID },
getMeshService = { meshService }
)
// 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<List<BitchatMessage>> = state.messages
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
val nickname: StateFlow<String> = state.nickname
val isConnected: StateFlow<Boolean> = state.isConnected
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
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() ?: meshService.myPeerID
val unread = mutableSetOf<String>()
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 = meshService.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)
meshService.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, meshService.myPeerID)
}
fun switchToChannel(channel: String?) {
channelManager.switchToChannel(channel)
}
fun leaveChannel(channel: String) {
channelManager.leaveChannel(channel)
meshService.sendMessage("left $channel")
}
// 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, meshService)
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()
}
// MARK: - Open Latest Unread Private Chat
fun openLatestUnreadPrivateChat() {
try {
val unreadKeys = state.getUnreadPrivateMessagesValue()
if (unreadKeys.isEmpty()) return
val me = state.getNicknameValue() ?: meshService.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 -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.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
}
startPrivateChat(openPeer)
// If sidebar visible, hide it to focus on the private chat
if (state.getShowSidebarValue()) {
state.setShowSidebar(false)
}
} 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, meshService, meshService.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,
meshService.myPeerID,
state.getNicknameValue()
)
} else {
// Default: route via mesh
meshService.sendMessage(messageContent, mentions, channel)
}
})
return
}
val mentions = messageManager.parseMentions(content, meshService.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 -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.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, meshService)
}
}
// Send private message
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
privateChatManager.sendPrivateMessage(
content,
selectedPeer,
recipientNickname,
state.getNicknameValue(),
meshService.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
// Route via MessageRouter (mesh when connected+established, else Nostr)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
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, meshService.myPeerID, state.getNicknameValue())
} else {
// Send public/channel message via mesh
val message = BitchatMessage(
sender = state.getNicknameValue() ?: meshService.myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = currentChannelValue
)
if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
// Check if encrypted channel
if (channelManager.hasChannelKey(currentChannelValue)) {
channelManager.sendEncryptedChannelMessage(
content,
mentions,
currentChannelValue,
state.getNicknameValue(),
meshService.myPeerID,
onEncryptedPayload = { encryptedData ->
// This would need proper mesh service integration
meshService.sendMessage(content, mentions, currentChannelValue)
},
onFallback = {
meshService.sendMessage(content, mentions, currentChannelValue)
}
)
} else {
meshService.sendMessage(content, mentions, currentChannelValue)
}
} else {
messageManager.addMessage(message)
meshService.sendMessage(content, mentions, null)
}
}
}
}
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
return meshService.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 = meshService.getPeerNicknames()[peerID] ?: peerID
// Case 1: Live mesh peer with known info
val peerInfo = meshService.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 {
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
// Prefer mesh if session established, else try Nostr
if (meshService.hasEstablishedSession(peerID)) {
// Reuse existing private message path for notifications
meshService.sendPrivateMessage(
announcementContent,
peerID,
nickname,
java.util.UUID.randomUUID().toString()
)
} else {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.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", "==============================")
}
/**
* 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 ->
meshService.getSessionState(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(), meshService)
.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 { meshService.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)
}
}
val nicknames = meshService.getPeerNicknames()
state.setPeerNicknames(nicknames)
val rssiValues = meshService.getPeerRSSI()
state.setPeerRSSI(rssiValues)
// Update directness per peer (driven by PeerManager state)
try {
val directMap = state.getConnectedPeersValue().associateWith { pid ->
meshService.getPeerInfo(pid)?.isDirectConnection == true
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
// Flush any pending QR verification once a Noise session is established
currentPeers.forEach { peerID ->
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
verificationHandler.sendPendingVerificationIfNeeded(peerID)
}
}
}
// MARK: - QR Verification
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): 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<String>): 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 meshService.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.setShowSidebar(true)
}
}
fun showSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(true)
}
fun hideSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(false)
}
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, meshService, 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<String>) {
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 all mesh service data
clearAllMeshServiceData()
// Clear all cryptographic data
clearAllCryptographicData()
// Clear all notifications
notificationManager.clearAllNotifications()
// 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) { }
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)
Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared")
// Note: Mesh service restart is now handled by MainActivity
// This method now only clears data, not mesh service lifecycle
}
/**
* Clear all mesh service related data
*/
private fun clearAllMeshServiceData() {
try {
// Request mesh service to clear all its internal data
meshService.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)
meshService.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<String>) {
geohashViewModel.beginGeohashSampling(geohashes)
}
/**
* End geohash sampling
*/
fun endGeohashSampling() {
// No-op in refactored architecture; sampling subscriptions are short-lived
}
/**
* 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 ->
startPrivateChat(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)
}
fun showSidebar() {
state.setShowSidebar(true)
}
fun hideSidebar() {
state.setShowSidebar(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 sidebar
state.getShowSidebarValue() -> {
hideSidebar()
true
}
// Close password dialog
state.getShowPasswordPromptValue() -> {
state.setShowPasswordPrompt(false)
state.setPasswordPromptChannel(null)
true
}
// Exit private chat
state.getSelectedPrivateChatPeerValue() != 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)
}
}