This commit is contained in:
callebtc 2026-07-29 03:41:16 +02:00
parent f5915c4c31
commit 7fad2f2202
13 changed files with 919 additions and 122 deletions

View File

@ -17,6 +17,13 @@
-keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; }
# Room loads generated database implementations by name and invokes their no-argument
# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's
# constructor, causing AndroidX Startup to crash before Application.onCreate.
-keepclassmembers class * extends androidx.room.RoomDatabase {
<init>();
}
# Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }

View File

@ -33,6 +33,13 @@ class BitchatApplication : Application() {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
} catch (_: Exception) { }
// Restore private conversations before background transports can deliver new messages.
// AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace
// newer transport state with an older database snapshot.
try {
com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this)
} catch (_: Exception) { }
// Warm up Nostr identity to ensure npub is available for favorite notifications
try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)

View File

@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.core.content.FileProvider
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
@ -323,4 +325,54 @@ object FileUtils {
Log.e(TAG, "Failed to clear media files", e)
}
}
/**
* Delete app-owned media referenced only by the conversation being removed.
*
* Canonical-path checks prevent message content from turning this into an arbitrary-file
* deletion primitive. Shared paths remain intact while any retained message still references
* them.
*/
fun deleteConversationMedia(
context: Context,
deletedMessages: Collection<BitchatMessage>,
retainedMessages: Collection<BitchatMessage>
) {
val mediaTypes = setOf(
BitchatMessageType.Audio,
BitchatMessageType.Image,
BitchatMessageType.File
)
val roots = listOf(context.filesDir, context.cacheDir)
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
val retainedPaths = retainedMessages
.asSequence()
.filter { it.type in mediaTypes }
.mapNotNull { message ->
runCatching { File(message.content.trim()).canonicalPath }.getOrNull()
}
.toSet()
deletedMessages
.asSequence()
.filter { it.type in mediaTypes }
.mapNotNull { message ->
runCatching { File(message.content.trim()).canonicalFile }.getOrNull()
}
.distinctBy(File::getPath)
.filter { file ->
file.path !in retainedPaths &&
roots.any { root ->
file.path == root.path ||
file.path.startsWith(root.path + File.separator)
}
}
.forEach { file ->
runCatching {
if (file.isFile && !file.delete()) {
Log.w(TAG, "Unable to delete conversation media")
}
}
}
}
}

View File

@ -64,6 +64,12 @@ object AppShutdownCoordinator {
val torStop = async {
try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
}
val conversationFlush = async {
try {
com.bitchat.android.services.AppStateStore
.awaitConversationPersistence()
} catch (_: Exception) { }
}
// Clear AppState in-memory store
try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
@ -75,6 +81,7 @@ object AppShutdownCoordinator {
// Wait up to 5 seconds for shutdown tasks
withTimeoutOrNull(5000) {
try { torStop.await() } catch (_: Exception) { }
try { conversationFlush.await() } catch (_: Exception) { }
delay(100)
}

View File

@ -1,5 +1,6 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.flow.MutableStateFlow
@ -15,6 +16,7 @@ object AppStateStore {
private val seenMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
private var privateWritesSinceGlobalPrune = 0
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
private val _directPeers = MutableStateFlow<Set<String>>(emptySet())
@ -30,6 +32,11 @@ object AppStateStore {
// Private messages by peerID
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
private val _readPrivateMessageIDs = MutableStateFlow<Set<String>>(emptySet())
val readPrivateMessageIDs: StateFlow<Set<String>> = _readPrivateMessageIDs.asStateFlow()
@Volatile
private var conversationRepository: ConversationRepository? = null
private val _nickname = MutableStateFlow("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
@ -55,6 +62,26 @@ object AppStateStore {
_selectedPrivateChatPeer.value = peerID
}
fun initializeConversationPersistence(context: Context) {
val repository = ConversationRepository.getInstance(context.applicationContext)
conversationRepository = repository
repository.initialize(::restorePrivateConversations)
}
/**
* Restores database state again for a newly created UI, even if Android reused this process
* after a controlled shutdown cleared the process-wide state.
*/
fun reloadConversationPersistence(context: Context) {
val repository = ConversationRepository.getInstance(context.applicationContext)
conversationRepository = repository
repository.reload(::restorePrivateConversations)
}
suspend fun awaitConversationPersistence() {
conversationRepository?.awaitPendingWrites()
}
fun setTransportPeers(transportId: String, ids: List<String>) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
@ -116,17 +143,51 @@ object AppStateStore {
}
}
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
PrivateMessageArrivalOrder.record(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
fun addPrivateMessage(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean = false
): Boolean = synchronized(this) {
if (seenMessageIds.contains(msg.id)) return@synchronized false
seenMessageIds.add(msg.id)
PrivateMessageArrivalOrder.record(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
val isRead = forceRead ||
msg.sender == "system" ||
msg.sender == _nickname.value ||
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(conversationID, ignoreCase = true) == true
if (isRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
}
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(conversationID) +
listOfNotNull(msg.senderPeerID)
}.getOrDefault(setOf(peerID, conversationID))
val displayName = ContactDirectory.resolve(conversationID).displayName
?: msg.sender.takeUnless {
it.isBlank() || it == "system" || it == _nickname.value
}
conversationRepository?.upsertMessage(
conversationID = conversationID,
aliases = aliases,
displayName = displayName,
message = msg,
isRead = isRead
)
prunePrivateMessagesLocked(conversationID)
true
}
fun hasSeenMessage(messageID: String): Boolean = synchronized(this) {
messageID in seenMessageIds
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
@ -158,6 +219,7 @@ object AppStateStore {
}
if (changed) {
_privateMessages.value = map
conversationRepository?.updateDeliveryStatus(messageID, status)
}
}
}
@ -166,6 +228,14 @@ object AppStateStore {
if (keysToMerge.isEmpty()) return
synchronized(this) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID)
.flatMap { key ->
runCatching {
ContactDirectory.aliasesForConversation(key).toList()
}.getOrDefault(listOf(key))
}
.toSet()
conversationRepository?.mergeAliases(targetConversationID, persistenceAliases)
val map = _privateMessages.value.toMutableMap()
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
val targetIds = targetList.map { it.id }.toMutableSet()
@ -213,6 +283,85 @@ object AppStateStore {
}
}
fun markPrivateMessageRead(messageID: String) {
synchronized(this) {
if (messageID in _readPrivateMessageIDs.value) return
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID
conversationRepository?.markRead(messageID)
}
}
fun isPrivateMessageRead(messageID: String): Boolean =
messageID in _readPrivateMessageIDs.value
fun deletePrivateConversation(peerOrConversationID: String): Set<String> {
synchronized(this) {
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
val aliases = (matchingKeys + peerOrConversationID + canonicalID)
.flatMap { key ->
runCatching {
ContactDirectory.aliasesForConversation(key).toList()
}.getOrDefault(listOf(key))
}
.toSet()
val messageIDs = matchingKeys
.flatMapTo(linkedSetOf()) { _privateMessages.value[it].orEmpty().map { it.id } }
// Queue the database deletion while holding the same lock used by addPrivateMessage.
// A genuinely new arrival is therefore queued after the delete and starts a fresh chat.
conversationRepository?.deleteConversation(canonicalID, aliases)
val updated = _privateMessages.value.toMutableMap()
matchingKeys.forEach(updated::remove)
_privateMessages.value = updated
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs
if (
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(canonicalID, ignoreCase = true) == true
) {
_selectedPrivateChatPeer.value = null
}
return messageIDs
}
}
fun removePrivateMessage(messageID: String) {
synchronized(this) {
val updated = _privateMessages.value.toMutableMap()
var changed = false
updated.keys.toList().forEach { conversationID ->
val messages = updated[conversationID].orEmpty()
if (messages.any { it.id == messageID }) {
val remaining = messages.filterNot { it.id == messageID }
if (remaining.isEmpty()) {
updated.remove(conversationID)
} else {
updated[conversationID] = remaining
}
changed = true
}
}
if (!changed) return
conversationRepository?.deleteMessage(messageID)
_privateMessages.value = updated
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID
}
}
fun clearPersistedPrivateConversations() {
synchronized(this) {
conversationRepository?.clearAll()
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_selectedPrivateChatPeer.value = null
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
@ -230,12 +379,14 @@ object AppStateStore {
seenMessageIds.clear()
seenPublicMessageKeys.clear()
PrivateMessageArrivalOrder.clear()
privateWritesSinceGlobalPrune = 0
peerIdsByTransport.clear()
directPeerIdsByTransport.clear()
_peers.value = emptyList()
_directPeers.value = emptySet()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_channelMessages.value = emptyMap()
_nickname.value = ""
_selectedPrivateChatPeer.value = null
@ -252,4 +403,147 @@ object AppStateStore {
msg.content
).joinToString("\u001F")
}
private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
synchronized(this) {
val liveChats = _privateMessages.value
val liveMessageIDs = liveChats.values.flatten().map { it.id }
PrivateMessageArrivalOrder.restore(snapshot.arrivalOrder, liveMessageIDs)
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
snapshot.chats.forEach { (conversationID, messages) ->
merged.getOrPut(conversationID) { mutableListOf() }.addAll(messages)
}
liveChats.forEach { (conversationID, messages) ->
val target = merged.getOrPut(conversationID) { mutableListOf() }
messages
.filterNot { it.id in snapshot.deletedMessageIDs }
.forEach { live ->
val existingIndex = target.indexOfFirst { it.id == live.id }
if (existingIndex >= 0) {
target[existingIndex] = live
} else {
target.add(live)
}
}
}
merged.values.flatten().forEach { seenMessageIds.add(it.id) }
seenMessageIds.addAll(snapshot.deletedMessageIDs)
_readPrivateMessageIDs.value =
(snapshot.readMessageIDs + _readPrivateMessageIDs.value) -
snapshot.deletedMessageIDs
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(
merged.mapValues { (_, messages) ->
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
}
)
}
}
private fun prunePrivateMessagesLocked(recentConversationID: String) {
val chats = _privateMessages.value.toMutableMap()
val removedIDs = linkedSetOf<String>()
val recentKey = chats.keys.firstOrNull { key ->
ContactDirectory.canonicalConversationId(key)
.equals(recentConversationID, ignoreCase = true)
}
if (recentKey != null) {
val messages = chats[recentKey].orEmpty()
val excess =
messages.size - ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION
if (excess > 0) {
val removable = messages
.dropLast(1)
.sortedWith(privateMessagePruneComparator())
.take(excess)
.mapTo(linkedSetOf()) { it.id }
removedIDs.addAll(removable)
chats[recentKey] = messages.filterNot { it.id in removable }
}
}
privateWritesSinceGlobalPrune += 1
if (privateWritesSinceGlobalPrune >= 64) {
privateWritesSinceGlobalPrune = 0
var totalMessages = chats.values.sumOf { it.size }
var totalPayloadBytes = chats.values
.asSequence()
.flatten()
.sumOf(::privateMessagePayloadBytes)
if (
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
) {
val candidates = chats.values
.asSequence()
.flatMap { messages -> messages.dropLast(1).asSequence() }
.sortedWith(privateMessagePruneComparator())
.iterator()
while (
candidates.hasNext() &&
(
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
)
) {
val candidate = candidates.next()
if (!removedIDs.add(candidate.id)) continue
totalMessages -= 1
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
}
if (
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
) {
// Enforce the hard bound even when every conversation contains only its
// newest message. Read conversations still sort ahead of unread ones.
val latestCandidates = chats.values
.asSequence()
.flatten()
.filterNot { it.id in removedIDs }
.sortedWith(privateMessagePruneComparator())
.iterator()
while (
latestCandidates.hasNext() &&
(
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
)
) {
val candidate = latestCandidates.next()
if (!removedIDs.add(candidate.id)) continue
totalMessages -= 1
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
}
}
if (removedIDs.isNotEmpty()) {
chats.keys.toList().forEach { key ->
chats[key] = chats[key].orEmpty().filterNot {
it.id in removedIDs
}
}
}
}
}
if (removedIDs.isNotEmpty()) {
_privateMessages.value = chats.filterValues { it.isNotEmpty() }
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - removedIDs
}
}
private fun privateMessagePruneComparator(): Comparator<BitchatMessage> =
compareByDescending<BitchatMessage> {
it.id in _readPrivateMessageIDs.value
}.thenBy {
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MAX_VALUE
}
private fun privateMessagePayloadBytes(message: BitchatMessage): Long =
message.content.toByteArray(Charsets.UTF_8).size.toLong() +
(message.encryptedContent?.size ?: 0) +
message.mentions.orEmpty().sumOf {
it.toByteArray(Charsets.UTF_8).size
}
}

View File

@ -21,6 +21,22 @@ internal object PrivateMessageArrivalOrder {
}
}
fun restore(persistedOrder: List<String>, liveMessageIDs: List<String>) {
synchronized(this) {
sequenceByMessageID.clear()
nextSequence = 0L
(persistedOrder + liveMessageIDs).forEach { messageID ->
if (messageID !in sequenceByMessageID) {
sequenceByMessageID[messageID] = nextSequence++
}
}
}
}
fun sequenceOf(messageID: String): Long? = synchronized(this) {
sequenceByMessageID[messageID]
}
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
synchronized(this) {
if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) {

View File

@ -1,5 +1,6 @@
package com.bitchat.android.services
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
@ -20,6 +21,8 @@ class SeenMessageStore private constructor(private val context: Context) {
private const val STORAGE_KEY = "seen_message_store_v1"
private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS
// The constructor always receives applicationContext, so process lifetime is intentional.
@SuppressLint("StaticFieldLeak")
@Volatile private var INSTANCE: SeenMessageStore? = null
fun getInstance(appContext: Context): SeenMessageStore {
return INSTANCE ?: synchronized(this) {
@ -54,6 +57,7 @@ class SeenMessageStore private constructor(private val context: Context) {
locallyRead.add(id)
trim(locallyRead)
}
AppStateStore.markPrivateMessageRead(id)
persist()
}
@ -65,6 +69,14 @@ class SeenMessageStore private constructor(private val context: Context) {
persist()
}
@Synchronized fun remove(ids: Set<String>) {
if (ids.isEmpty()) return
delivered.removeAll(ids)
locallyRead.removeAll(ids)
readReceiptsSent.removeAll(ids)
persist()
}
@Synchronized fun clear() {
delivered.clear()
locallyRead.clear()

View File

@ -193,20 +193,29 @@ class ChatViewModel(
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
internal val conversations: StateFlow<List<ConversationSummary>> = combine(
state.unreadPrivateMessages,
state.privateChats,
state.nickname,
state.connectedPeers
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
val seenStore = seenMessageStore
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
buildUnreadConversationSummaries(
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 -> seenStore.hasBeenReadLocally(message.id) }
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
@ -221,6 +230,11 @@ class ChatViewModel(
?.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
@ -230,13 +244,14 @@ class ChatViewModel(
?: summary.displayName,
nostrPubkey = resolvedNostrPubkey,
identityAliases = aliases,
isConnected = aliases.any(connectedPeerIDSet::contains),
isConnected = connectedPeerID != null,
connectedPeerID = connectedPeerID,
sourceGeohash = aliases
.asSequence()
.mapNotNull(GeohashConversationRegistry::get)
.firstOrNull()
)
}
}.let(::sortConversationSummaries)
}
.flowOn(Dispatchers.IO)
.stateIn(
@ -294,6 +309,12 @@ class ChatViewModel(
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 ->
@ -327,6 +348,8 @@ class ChatViewModel(
messages.any { message ->
message.sender != myNick &&
message.sender != "system" &&
!com.bitchat.android.services.AppStateStore
.isPrivateMessageRead(message.id) &&
!seenMessageStore.hasBeenReadLocally(message.id)
}
}
@ -445,7 +468,6 @@ class ChatViewModel(
override fun onCleared() {
geohashViewModel.shutdownUiSubscriptions()
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null)
super.onCleared()
// Note: Mesh service lifecycle is now managed by MainActivity
}
@ -518,6 +540,62 @@ class ChatViewModel(
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() {
@ -1055,6 +1133,8 @@ class ChatViewModel(
mediaSendingManager.clearPendingPrivateMediaConsent()
// Clear all UI managers
com.bitchat.android.services.AppStateStore.clearPersistedPrivateConversations()
com.bitchat.android.services.AppStateStore.clear()
messageManager.clearAllMessages()
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()

View File

@ -189,6 +189,9 @@ class CommandProcessor(
// Clear private chat
val peerID = state.getSelectedPrivateChatPeerValue()!!
messageManager.clearPrivateMessages(peerID)
// `/clear` removes history but should not navigate away from the chat the
// command was issued in. A later message will repopulate this conversation.
state.setSelectedPrivateChatPeer(peerID)
}
state.getCurrentChannelValue() != null -> {
// Clear channel messages

View File

@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import android.text.format.DateUtils
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@ -32,9 +33,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -50,6 +57,7 @@ import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
@ -88,16 +96,32 @@ fun MeshPeerListSheet(
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle()
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle()
val geohashPeopleCount = geohashPeople.size
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
val unreadIdentityAliases = remember(unreadConversations) {
unreadConversations
val directPeerIdentityIDs = remember(peerDirect) {
peerDirect
.asSequence()
.filter { (_, isDirect) -> isDirect }
.mapTo(mutableSetOf()) { (peerID, _) -> peerID.lowercase() }
}
val wifiAwareIdentityIDs = remember(wifiAwarePeerIDs) {
wifiAwarePeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
}
val conversationIdentityAliases = remember(conversations) {
conversations
.flatMapTo(mutableSetOf()) { it.identityAliases }
}
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
peerID.lowercase() in unreadIdentityAliases
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID)
}.getOrDefault(setOf(peerID))
aliases.any { it.lowercase() in conversationIdentityAliases }
}
var pendingConversationDelete by remember {
mutableStateOf<ConversationSummary?>(null)
}
// Bottom sheet state
@ -134,15 +158,20 @@ fun MeshPeerListSheet(
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
}
if (unreadConversations.isNotEmpty()) {
item(key = "unread_private_messages_section") {
UnreadDirectMessagesSection(
conversations = unreadConversations,
if (conversations.isNotEmpty()) {
item(key = "private_conversations_section") {
DirectMessagesSection(
conversations = conversations,
directPeerIdentityIDs = directPeerIdentityIDs,
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
viewModel = viewModel,
onPrivateChatStart = { conversationID ->
viewModel.showPrivateChatSheet(conversationID)
onDismiss()
},
onDeleteRequested = { conversation ->
pendingConversationDelete = conversation
},
modifier = Modifier.padding(top = 8.dp)
)
}
@ -156,7 +185,7 @@ fun MeshPeerListSheet(
iconRes = R.drawable.ic_spec_chat_bubbles,
title = stringResource(R.string.channels),
modifier = Modifier.padding(
top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp
top = if (conversations.isNotEmpty()) 20.dp else 8.dp
)
)
Surface(
@ -209,11 +238,11 @@ fun MeshPeerListSheet(
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss,
excludedIdentityAliases = unreadIdentityAliases,
excludedIdentityAliases = conversationIdentityAliases,
modifier = Modifier.padding(
top = if (
joinedChannels.isNotEmpty() ||
unreadConversations.isNotEmpty()
conversations.isNotEmpty()
) 20.dp else 8.dp
)
)
@ -224,7 +253,7 @@ fun MeshPeerListSheet(
modifier = Modifier.padding(
top = if (
joinedChannels.isNotEmpty() ||
unreadConversations.isNotEmpty()
conversations.isNotEmpty()
) 20.dp else 8.dp
),
connectedPeers = visibleConnectedPeers,
@ -235,7 +264,7 @@ fun MeshPeerListSheet(
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
excludedIdentityAliases = unreadIdentityAliases,
excludedIdentityAliases = conversationIdentityAliases,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@ -273,6 +302,54 @@ fun MeshPeerListSheet(
}
}
pendingConversationDelete?.let { conversation ->
AlertDialog(
onDismissRequest = { pendingConversationDelete = null },
icon = {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null
)
},
title = {
Text(
text = stringResource(R.string.delete_conversation_title),
fontFamily = BitchatFontFamily
)
},
text = {
Text(
text = stringResource(
R.string.delete_conversation_message,
conversation.displayName
),
fontFamily = BitchatFontFamily
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.deletePrivateConversation(conversation.conversationID)
pendingConversationDelete = null
}
) {
Text(
text = stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error,
fontFamily = BitchatFontFamily
)
}
},
dismissButton = {
TextButton(onClick = { pendingConversationDelete = null }) {
Text(
text = stringResource(android.R.string.cancel),
fontFamily = BitchatFontFamily
)
}
}
)
}
}
}
@ -612,23 +689,29 @@ fun PeopleSection(
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UnreadDirectMessagesSection(
conversations: List<UnreadConversationSummary>,
private fun DirectMessagesSection(
conversations: List<ConversationSummary>,
directPeerIdentityIDs: Set<String>,
wifiAwareIdentityIDs: Set<String>,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit,
onDeleteRequested: (ConversationSummary) -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val hapticFeedback = LocalHapticFeedback.current
val deleteDescription = stringResource(R.string.delete_conversation_action)
Column(modifier = modifier) {
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_envelope,
title = stringResource(R.string.cd_unread_private_messages)
title = stringResource(R.string.conversations)
)
Surface(
@ -646,87 +729,63 @@ private fun UnreadDirectMessagesSection(
Column {
if (index > 0) SheetCardDivider()
val subtitle = when {
conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}"
conversation.transport == DirectMessageTransport.NOSTR ->
stringResource(R.string.cd_reachable_via_nostr)
!conversation.isConnected ->
stringResource(R.string.cd_offline_mesh_chat)
else -> null
val dismissState = rememberSwipeToDismissBoxState()
LaunchedEffect(dismissState.currentValue, conversation.conversationID) {
if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteRequested(conversation)
dismissState.reset()
}
}
val peerIdentity = conversation.nostrPubkey
?.let(viewModel::peerIdentityForNostrPubkey)
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
val assignedColor = colorForPeer(peerIdentity, palette)
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onPrivateChatStart(conversation.conversationID)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
val alignment = when (dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
else -> Alignment.CenterEnd
}
.padding(
horizontal = SheetRowHorizontal,
vertical = SheetRowVertical
),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.ic_spec_envelope),
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentOrange
)
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier
.fillMaxSize()
.background(colorScheme.errorContainer)
.padding(horizontal = SheetRowHorizontal),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = truncateNickname(baseNameRaw),
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = assignedColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
)
horizontalArrangement = when (alignment) {
Alignment.CenterStart -> Arrangement.Start
else -> Arrangement.End
}
}
if (subtitle != null) {
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = deleteDescription,
tint = colorScheme.onErrorContainer,
modifier = Modifier.size(PeerRowIconSize)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = subtitle,
text = stringResource(R.string.delete),
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = palette.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = colorScheme.onErrorContainer
)
}
}
UnreadBadge(
count = conversation.unreadCount,
colorScheme = colorScheme
) {
ConversationRow(
conversation = conversation,
directPeerIdentityIDs = directPeerIdentityIDs,
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
viewModel = viewModel,
deleteDescription = deleteDescription,
onClick = {
onPrivateChatStart(conversation.conversationID)
},
onDeleteRequested = {
onDeleteRequested(conversation)
}
)
}
}
@ -735,6 +794,171 @@ private fun UnreadDirectMessagesSection(
}
}
@Composable
private fun ConversationRow(
conversation: ConversationSummary,
directPeerIdentityIDs: Set<String>,
wifiAwareIdentityIDs: Set<String>,
viewModel: ChatViewModel,
deleteDescription: String,
onClick: () -> Unit,
onDeleteRequested: () -> Unit
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val liveIdentityIDs = conversation.identityAliases +
listOfNotNull(conversation.connectedPeerID?.lowercase())
val isWifiAware = liveIdentityIDs.any(wifiAwareIdentityIDs::contains)
val isDirect = liveIdentityIDs.any(directPeerIdentityIDs::contains) ||
conversation.connectedPeerID?.let { peerID ->
runCatching {
viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true
}.getOrDefault(false)
} == true
val connectionDescription = meshConnectionDescription(
isWifiAware = isWifiAware,
isDirect = isDirect
)
val messagePreview = when (conversation.latestMessageType) {
BitchatMessageType.Image -> stringResource(R.string.notification_sent_image)
BitchatMessageType.Audio -> stringResource(R.string.notification_sent_voice)
BitchatMessageType.File -> conversation.latestMessagePreview
.takeIf(String::isNotBlank)
?.let { "📎 $it" }
?: stringResource(R.string.notification_sent_file)
BitchatMessageType.Message -> conversation.latestMessagePreview.ifBlank { "" }
}
val presenceDescription = when {
conversation.isConnected -> connectionDescription
conversation.transport == DirectMessageTransport.NOSTR ->
stringResource(R.string.offline_reachable_via_nostr)
else -> stringResource(R.string.offline_not_in_mesh)
}
val peerIdentity = conversation.nostrPubkey
?.let(viewModel::peerIdentityForNostrPubkey)
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
val assignedColor = colorForPeer(peerIdentity, palette)
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
val relativeTime = remember(conversation.latestMessageAt) {
DateUtils.getRelativeTimeSpanString(
conversation.latestMessageAt,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
).toString()
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(colorScheme.surface)
.semantics {
stateDescription = presenceDescription
customActions = listOf(
CustomAccessibilityAction(deleteDescription) {
onDeleteRequested()
true
}
)
}
.clickable(onClick = onClick)
.padding(
horizontal = SheetRowHorizontal,
vertical = SheetRowVertical
),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
when {
conversation.isConnected -> Icon(
painter = painterResource(
conversationTransportIcon(
isReachedOverInternet = false,
isWifiAware = isWifiAware,
isDirect = isDirect
)
),
contentDescription = connectionDescription,
modifier = Modifier.size(PeerRowIconSize),
tint = colorScheme.onSurfaceVariant
)
conversation.transport == DirectMessageTransport.NOSTR -> Icon(
painter = painterResource(R.drawable.ic_spec_globe),
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentPurple
)
else -> Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(R.string.offline_not_in_mesh),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textTertiary
)
}
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = truncateNickname(baseNameRaw),
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = if (conversation.unreadCount > 0) {
FontWeight.Bold
} else {
FontWeight.Medium
},
color = assignedColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
)
}
}
Text(
text = messagePreview,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = palette.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = relativeTime,
fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = palette.textTertiary,
maxLines = 1
)
UnreadBadge(
count = conversation.unreadCount,
colorScheme = colorScheme
)
}
}
}
@Composable
private fun PeerItem(
peerID: String,
@ -755,6 +979,10 @@ private fun PeerItem(
showHashSuffix: Boolean = true
) {
val currentNickname by viewModel.nickname.collectAsStateWithLifecycle()
val connectionDescription = meshConnectionDescription(
isWifiAware = isWifiAware,
isDirect = isDirect
)
// Split display name for hashtag suffix support (iOS-compatible)
val (baseNameRaw, suffixRaw) = splitSuffix(displayName)
val baseName = truncateNickname(baseNameRaw)
@ -816,11 +1044,7 @@ private fun PeerItem(
isDirect = isDirect
)
),
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
else -> "Routed"
},
contentDescription = connectionDescription,
modifier = Modifier.size(PeerRowIconSize),
tint = colorScheme.onSurfaceVariant
)
@ -884,6 +1108,18 @@ private fun PeerItem(
}
}
@Composable
private fun meshConnectionDescription(
isWifiAware: Boolean,
isDirect: Boolean
): String = stringResource(
when {
isWifiAware -> R.string.cd_direct_wifi_aware
isDirect -> R.string.cd_direct_bluetooth
else -> R.string.cd_routed_mesh
}
)
/**
* Reusable unread badge component for both channels and private messages
*/

View File

@ -3,7 +3,6 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.PrivateMessageArrivalOrder
import java.util.*
import java.util.Collections
@ -103,6 +102,15 @@ class MessageManager(private val state: ChatState) {
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val accepted = try {
com.bitchat.android.services.AppStateStore.addPrivateMessage(
conversationID,
message
)
} catch (_: Exception) {
false
}
if (!accepted) return
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
@ -111,14 +119,15 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
// Record the local arrival sequence before canonicalizing UI aliases.
PrivateMessageArrivalOrder.record(message.id)
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) {
val selectedConversationID = state.getSelectedPrivateChatPeerValue()
?.let(ContactDirectory::canonicalConversationId)
if (
selectedConversationID != conversationID &&
message.sender != state.getNicknameValue()
) {
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
@ -128,6 +137,16 @@ class MessageManager(private val state: ChatState) {
// 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 conversationID = ContactDirectory.canonicalConversationId(peerID)
val accepted = try {
com.bitchat.android.services.AppStateStore.addPrivateMessage(
peerID = conversationID,
msg = message,
forceRead = true
)
} catch (_: Exception) {
false
}
if (!accepted) return
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
@ -135,18 +154,19 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
// Record the local arrival sequence before canonicalizing UI aliases.
PrivateMessageArrivalOrder.record(message.id)
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
}
fun clearPrivateMessages(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
com.bitchat.android.services.AppStateStore.deletePrivateConversation(conversationID)
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[conversationID] = emptyList()
updatedChats.keys.removeAll { key ->
ContactDirectory.canonicalConversationId(key)
.equals(conversationID, ignoreCase = true)
}
state.setPrivateChats(updatedChats)
clearPrivateUnreadMessages(conversationID)
}
fun initializePrivateChat(peerID: String) {
@ -325,7 +345,10 @@ class MessageManager(private val state: ChatState) {
changed = true
}
}
if (changed) state.setPrivateChats(chats)
if (changed) {
state.setPrivateChats(chats.filterValues { it.isNotEmpty() })
com.bitchat.android.services.AppStateStore.removePrivateMessage(messageID)
}
}
// Channels
run {

View File

@ -74,6 +74,13 @@
<!-- Chat header &amp; accessibility -->
<string name="cd_nostr_reachable">Nostr reachable</string>
<string name="cd_unread_private_messages">Messages</string>
<string name="conversations" tools:ignore="MissingTranslation">Conversations</string>
<string name="offline_not_in_mesh" tools:ignore="MissingTranslation">Offline · not in mesh</string>
<string name="offline_reachable_via_nostr" tools:ignore="MissingTranslation">Offline from mesh · reachable via Nostr</string>
<string name="delete" tools:ignore="MissingTranslation">Delete</string>
<string name="delete_conversation_action" tools:ignore="MissingTranslation">Delete conversation</string>
<string name="delete_conversation_title" tools:ignore="MissingTranslation">Delete conversation?</string>
<string name="delete_conversation_message" tools:ignore="MissingTranslation">Delete your conversation with %1$s from this device?</string>
<string name="cd_location_notes">Location notes</string>
<string name="cd_teleported">Teleported</string>
<string name="cd_tor_status">Tor status</string>
@ -252,6 +259,9 @@
<string name="cd_checking_battery_optimization">Checking Battery Optimization</string>
<string name="cd_not_supported_battery_optimization">Battery Optimization Not Supported</string>
<string name="cd_bluetooth">Bluetooth</string>
<string name="cd_direct_wifi_aware" tools:ignore="MissingTranslation">Direct Wi-Fi Aware</string>
<string name="cd_direct_bluetooth" tools:ignore="MissingTranslation">Direct Bluetooth</string>
<string name="cd_routed_mesh" tools:ignore="MissingTranslation">Routed mesh</string>
<string name="cd_unread_message">Unread message</string>
<string name="cd_open_map">Open map</string>
<string name="cd_remove_bookmark">Remove bookmark</string>

View File

@ -4,6 +4,7 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -189,4 +190,53 @@ class AppStateStoreTest {
.deliveryStatus
assertTrue(status is DeliveryStatus.Read)
}
@Test
fun `long lived process applies the same bounded private history policy`() {
AppStateStore.setNickname("me")
repeat(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION) { index ->
AppStateStore.addPrivateMessage(
"peer-a",
BitchatMessage(
id = "incoming-$index",
sender = "alice",
content = "message",
timestamp = Date(index.toLong()),
isPrivate = true
)
)
}
AppStateStore.addPrivateMessage(
"peer-a",
BitchatMessage(
id = "latest-outgoing",
sender = "me",
content = "latest",
timestamp = Date(Long.MAX_VALUE),
isPrivate = true
)
)
val retained = AppStateStore.privateMessages.value.getValue("peer-a")
assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, retained.size)
assertEquals("incoming-1", retained.first().id)
assertEquals("latest-outgoing", retained.last().id)
}
@Test
fun `private message admission is atomic and can durably classify known read messages`() {
val message = BitchatMessage(
id = "same-transport-message",
sender = "alice",
content = "hello",
timestamp = Date(1L),
isPrivate = true
)
assertTrue(AppStateStore.addPrivateMessage("peer-a", message, forceRead = true))
assertFalse(AppStateStore.addPrivateMessage("peer-a", message))
assertEquals(1, AppStateStore.privateMessages.value.getValue("peer-a").size)
assertTrue(AppStateStore.isPrivateMessageRead(message.id))
}
}