mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:05:22 +00:00
* persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design
362 lines
15 KiB
Kotlin
362 lines
15 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import com.bitchat.android.model.BitchatMessage
|
|
import com.bitchat.android.model.DeliveryStatus
|
|
import java.util.*
|
|
import java.util.Collections
|
|
|
|
/**
|
|
* Handles all message-related operations including deduplication and organization
|
|
*/
|
|
class MessageManager(private val state: ChatState) {
|
|
|
|
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
|
|
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
|
|
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
|
|
private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds
|
|
private val SYSTEM_EVENT_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.SYSTEM_EVENT_DEDUP_TIMEOUT_MS // 5 seconds
|
|
|
|
// MARK: - Public Message Management
|
|
|
|
fun addMessage(message: BitchatMessage) {
|
|
val currentMessages = state.getMessagesValue().toMutableList()
|
|
currentMessages.add(message)
|
|
state.setMessages(currentMessages)
|
|
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
|
|
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
|
|
}
|
|
|
|
// Log a system message into the main chat (visible to user)
|
|
fun addSystemMessage(text: String) {
|
|
val sys = BitchatMessage(
|
|
sender = "system",
|
|
content = text,
|
|
timestamp = Date(),
|
|
isRelay = false
|
|
)
|
|
addMessage(sys)
|
|
}
|
|
|
|
fun clearMessages() {
|
|
state.setMessages(emptyList())
|
|
state.setChannelMessages(emptyMap())
|
|
}
|
|
|
|
// MARK: - Channel Message Management
|
|
|
|
fun addChannelMessage(channel: String, message: BitchatMessage) {
|
|
val currentChannelMessages = state.getChannelMessagesValue().toMutableMap()
|
|
if (!currentChannelMessages.containsKey(channel)) {
|
|
currentChannelMessages[channel] = mutableListOf()
|
|
}
|
|
|
|
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
|
|
channelMessageList.add(message)
|
|
currentChannelMessages[channel] = channelMessageList
|
|
state.setChannelMessages(currentChannelMessages)
|
|
// Reflect into process-wide store
|
|
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
|
|
|
|
// Update unread count if not currently viewing this channel
|
|
// Consider both classic channels (state.currentChannel) and geohash location channel selection
|
|
val viewingClassicChannel = state.getCurrentChannelValue() == channel
|
|
val viewingGeohashChannel = try {
|
|
if (channel.startsWith("geo:")) {
|
|
val geo = channel.removePrefix("geo:")
|
|
val selected = state.selectedLocationChannel.value
|
|
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
|
|
} else false
|
|
} catch (_: Exception) { false }
|
|
|
|
if (!viewingClassicChannel && !viewingGeohashChannel) {
|
|
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
|
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
|
state.setUnreadChannelMessages(currentUnread)
|
|
}
|
|
}
|
|
|
|
fun clearChannelMessages(channel: String) {
|
|
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
|
|
updatedChannelMessages[channel] = emptyList()
|
|
state.setChannelMessages(updatedChannelMessages)
|
|
}
|
|
|
|
fun removeChannelMessages(channel: String) {
|
|
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
|
|
updatedChannelMessages.remove(channel)
|
|
state.setChannelMessages(updatedChannelMessages)
|
|
|
|
val updatedUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
|
updatedUnread.remove(channel)
|
|
state.setUnreadChannelMessages(updatedUnread)
|
|
}
|
|
|
|
fun clearChannelUnreadCount(channel: String) {
|
|
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
|
currentUnread.remove(channel)
|
|
state.setUnreadChannelMessages(currentUnread)
|
|
}
|
|
|
|
// MARK: - Private Message Management
|
|
|
|
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
|
|
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
|
if (!currentPrivateChats.containsKey(peerID)) {
|
|
currentPrivateChats[peerID] = mutableListOf()
|
|
}
|
|
|
|
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
|
chatMessages.add(message)
|
|
currentPrivateChats[peerID] = chatMessages
|
|
state.setPrivateChats(currentPrivateChats)
|
|
// Reflect into process-wide store
|
|
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
|
|
|
// Mark as unread if not currently viewing this chat
|
|
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
|
|
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
|
currentUnread.add(peerID)
|
|
state.setUnreadPrivateMessages(currentUnread)
|
|
}
|
|
}
|
|
|
|
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
|
|
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
|
|
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
|
if (!currentPrivateChats.containsKey(peerID)) {
|
|
currentPrivateChats[peerID] = mutableListOf()
|
|
}
|
|
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
|
chatMessages.add(message)
|
|
currentPrivateChats[peerID] = chatMessages
|
|
state.setPrivateChats(currentPrivateChats)
|
|
// Reflect into process-wide store
|
|
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
|
}
|
|
|
|
fun clearPrivateMessages(peerID: String) {
|
|
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
|
updatedChats[peerID] = emptyList()
|
|
state.setPrivateChats(updatedChats)
|
|
}
|
|
|
|
fun initializePrivateChat(peerID: String) {
|
|
if (state.getPrivateChatsValue().containsKey(peerID)) return
|
|
|
|
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
|
updatedChats[peerID] = emptyList()
|
|
state.setPrivateChats(updatedChats)
|
|
}
|
|
|
|
fun clearPrivateUnreadMessages(peerID: String) {
|
|
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
|
updatedUnread.remove(peerID)
|
|
state.setUnreadPrivateMessages(updatedUnread)
|
|
}
|
|
|
|
// MARK: - Message Deduplication
|
|
|
|
/**
|
|
* Generate a unique key for message deduplication
|
|
*/
|
|
fun generateMessageKey(message: BitchatMessage): String {
|
|
val senderKey = message.senderPeerID ?: message.sender
|
|
val contentHash = message.content.hashCode()
|
|
return "$senderKey-${message.timestamp.time}-$contentHash"
|
|
}
|
|
|
|
/**
|
|
* Check if a message has already been processed
|
|
*/
|
|
fun isMessageProcessed(messageKey: String): Boolean {
|
|
return processedUIMessages.contains(messageKey)
|
|
}
|
|
|
|
/**
|
|
* Mark a message as processed
|
|
*/
|
|
fun markMessageProcessed(messageKey: String) {
|
|
processedUIMessages.add(messageKey)
|
|
}
|
|
|
|
/**
|
|
* Check if a system event is a duplicate within the timeout window
|
|
*/
|
|
fun isDuplicateSystemEvent(eventType: String, peerID: String): Boolean {
|
|
val now = System.currentTimeMillis()
|
|
val eventKey = "$eventType-$peerID"
|
|
val lastEvent = recentSystemEvents[eventKey]
|
|
|
|
if (lastEvent != null && (now - lastEvent) < SYSTEM_EVENT_DEDUP_TIMEOUT) {
|
|
return true // Duplicate event
|
|
}
|
|
|
|
recentSystemEvents[eventKey] = now
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Clean up old entries from deduplication caches
|
|
*/
|
|
fun cleanupDeduplicationCaches() {
|
|
val now = System.currentTimeMillis()
|
|
|
|
// Clean up processed UI messages (remove entries older than 30 seconds)
|
|
if (processedUIMessages.size > 1000) {
|
|
processedUIMessages.clear()
|
|
}
|
|
|
|
// Clean up recent system events (remove entries older than timeout)
|
|
recentSystemEvents.entries.removeAll { (_, timestamp) ->
|
|
(now - timestamp) > SYSTEM_EVENT_DEDUP_TIMEOUT * 2
|
|
}
|
|
}
|
|
|
|
// MARK: - Delivery Status Updates
|
|
|
|
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
|
null -> 0
|
|
is DeliveryStatus.Sending -> 1
|
|
is DeliveryStatus.Sent -> 2
|
|
is DeliveryStatus.PartiallyDelivered -> 3
|
|
is DeliveryStatus.Delivered -> 4
|
|
is DeliveryStatus.Read -> 5
|
|
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
|
|
}
|
|
|
|
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
|
|
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
|
|
return if (statusPriority(new) >= statusPriority(old)) new else old
|
|
}
|
|
|
|
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
|
|
// Update in private chats
|
|
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
|
var updated = false
|
|
|
|
updatedPrivateChats.forEach { (peerID, messages) ->
|
|
val updatedMessages = messages.toMutableList()
|
|
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
|
if (messageIndex >= 0) {
|
|
val current = updatedMessages[messageIndex].deliveryStatus
|
|
val finalStatus = chooseStatus(current, status)
|
|
if (finalStatus !== current) {
|
|
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
|
updatedPrivateChats[peerID] = updatedMessages
|
|
updated = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated) {
|
|
state.setPrivateChats(updatedPrivateChats)
|
|
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
|
|
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
|
|
}
|
|
|
|
// Update in main messages
|
|
val updatedMessages = state.getMessagesValue().toMutableList()
|
|
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
|
if (messageIndex >= 0) {
|
|
val current = updatedMessages[messageIndex].deliveryStatus
|
|
val finalStatus = chooseStatus(current, status)
|
|
if (finalStatus !== current) {
|
|
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
|
state.setMessages(updatedMessages)
|
|
}
|
|
}
|
|
|
|
// Update in channel messages
|
|
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
|
|
updatedChannelMessages.forEach { (channel, messages) ->
|
|
val channelMessagesList = messages.toMutableList()
|
|
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
|
|
if (channelMessageIndex >= 0) {
|
|
val current = channelMessagesList[channelMessageIndex].deliveryStatus
|
|
val finalStatus = chooseStatus(current, status)
|
|
if (finalStatus !== current) {
|
|
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
|
|
updatedChannelMessages[channel] = channelMessagesList
|
|
}
|
|
}
|
|
}
|
|
state.setChannelMessages(updatedChannelMessages)
|
|
}
|
|
|
|
// Remove a message from all locations (main timeline, private chats, channels)
|
|
fun removeMessageById(messageID: String) {
|
|
// Main timeline
|
|
run {
|
|
val list = state.getMessagesValue().toMutableList()
|
|
val idx = list.indexOfFirst { it.id == messageID }
|
|
if (idx >= 0) {
|
|
list.removeAt(idx)
|
|
state.setMessages(list)
|
|
}
|
|
}
|
|
// Private chats
|
|
run {
|
|
val chats = state.getPrivateChatsValue().toMutableMap()
|
|
var changed = false
|
|
chats.keys.toList().forEach { key ->
|
|
val msgs = chats[key]?.toMutableList() ?: mutableListOf()
|
|
val idx = msgs.indexOfFirst { it.id == messageID }
|
|
if (idx >= 0) {
|
|
msgs.removeAt(idx)
|
|
chats[key] = msgs
|
|
changed = true
|
|
}
|
|
}
|
|
if (changed) state.setPrivateChats(chats)
|
|
}
|
|
// Channels
|
|
run {
|
|
val chans = state.getChannelMessagesValue().toMutableMap()
|
|
var changed = false
|
|
chans.keys.toList().forEach { ch ->
|
|
val msgs = chans[ch]?.toMutableList() ?: mutableListOf()
|
|
val idx = msgs.indexOfFirst { it.id == messageID }
|
|
if (idx >= 0) {
|
|
msgs.removeAt(idx)
|
|
chans[ch] = msgs
|
|
changed = true
|
|
}
|
|
}
|
|
if (changed) state.setChannelMessages(chans)
|
|
}
|
|
}
|
|
|
|
// MARK: - Utility Functions
|
|
|
|
fun parseMentions(content: String, peerNicknames: Set<String>, currentNickname: String?): List<String> {
|
|
val mentionRegex = "@([a-zA-Z0-9_]+)".toRegex()
|
|
val allNicknames = peerNicknames + (currentNickname ?: "")
|
|
|
|
return mentionRegex.findAll(content)
|
|
.map { it.groupValues[1] }
|
|
.filter { allNicknames.contains(it) }
|
|
.distinct()
|
|
.toList()
|
|
}
|
|
|
|
fun parseChannels(content: String): List<String> {
|
|
val channelRegex = "#([a-zA-Z0-9_]+)".toRegex()
|
|
return channelRegex.findAll(content)
|
|
.map { it.groupValues[0] } // Include the #
|
|
.distinct()
|
|
.toList()
|
|
}
|
|
|
|
// MARK: - Emergency Clear
|
|
|
|
fun clearAllMessages() {
|
|
state.setMessages(emptyList())
|
|
state.setPrivateChats(emptyMap())
|
|
state.setChannelMessages(emptyMap())
|
|
state.setUnreadPrivateMessages(emptySet())
|
|
state.setUnreadChannelMessages(emptyMap())
|
|
processedUIMessages.clear()
|
|
recentSystemEvents.clear()
|
|
}
|
|
}
|