Merge pull request #806 from permissionlesstech/codex/persistent-conversations

Persist private conversations in People
This commit is contained in:
callebtc 2026-07-29 13:00:57 +02:00 committed by GitHub
commit 1dae188cdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2971 additions and 170 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

@ -480,21 +480,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// Private-message admission is authoritative. In particular, do not forward a
// callback or notify after panic mode rejected the message while wiping state.
if (
!com.bitchat.android.services.IncomingMessageAdmission
.admitToAppState(message)
) return
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)

View File

@ -41,7 +41,11 @@ class MeshCore(
private val hooks: Hooks = Hooks()
) {
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
/**
* Reflects a decoded message into transport-owned state before delegate dispatch.
* Return false to suppress all downstream effects for a rejected message.
*/
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
@ -392,7 +396,7 @@ class MeshCore(
}
override fun onMessageReceived(message: BitchatMessage) {
hooks.onMessageReceived?.invoke(message)
if (hooks.onMessageReceived?.invoke(message) == false) return
delegate?.didReceiveMessage(message)
}

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,12 @@ 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 var privateConversationWritesSuspended = false
private val _nickname = MutableStateFlow("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
@ -55,6 +63,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 +144,52 @@ 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 (privateConversationWritesSuspended) return@synchronized false
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) {
@ -141,6 +204,7 @@ object AppStateStore {
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
synchronized(this) {
if (privateConversationWritesSuspended) return
val map = _privateMessages.value.toMutableMap()
var changed = false
map.keys.toList().forEach { peer ->
@ -158,6 +222,7 @@ object AppStateStore {
}
if (changed) {
_privateMessages.value = map
conversationRepository?.updateDeliveryStatus(messageID, status)
}
}
}
@ -165,7 +230,16 @@ object AppStateStore {
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
if (keysToMerge.isEmpty()) return
synchronized(this) {
if (privateConversationWritesSuspended) return
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 +287,98 @@ object AppStateStore {
}
}
fun markPrivateMessageRead(messageID: String) {
synchronized(this) {
if (privateConversationWritesSuspended) return
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
}
}
/**
* Atomically hides all conversations, rejects in-flight transport deliveries, then waits for
* every earlier database write and the panic wipe itself to finish.
*/
suspend fun panicClearPrivateConversations(): Boolean {
val repository = synchronized(this) {
privateConversationWritesSuspended = true
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_selectedPrivateChatPeer.value = null
conversationRepository
}
return repository?.clearAllAndWait() ?: true
}
fun resumePrivateConversationsAfterPanic() {
synchronized(this) {
privateConversationWritesSuspended = false
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
@ -230,12 +396,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 +420,148 @@ object AppStateStore {
msg.content
).joinToString("\u001F")
}
private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
synchronized(this) {
if (privateConversationWritesSuspended) return
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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
/**
* Reflects an incoming transport message into process-wide state before any downstream effects.
*
* Private-message admission is authoritative: a duplicate or a message rejected while panic mode
* is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications.
* Public and channel messages retain their existing best-effort behavior if state reflection fails.
*/
internal object IncomingMessageAdmission {
fun admitToAppState(message: BitchatMessage): Boolean = try {
when {
message.isPrivate -> {
val peerID = message.senderPeerID?.takeIf(String::isNotBlank)
?: return false
AppStateStore.addPrivateMessage(peerID, message)
}
message.channel != null -> {
AppStateStore.addChannelMessage(message.channel, message)
true
}
else -> {
AppStateStore.addPublicMessage(message)
true
}
}
} catch (_: Exception) {
// Preserve the pre-existing best-effort dispatch for public/channel messages, but never
// bypass private-message admission when persistence or canonicalization fails.
!message.isPrivate
}
}

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() {
@ -1041,8 +1119,22 @@ class ChatViewModel(
}
// MARK: - Emergency Clear
private var panicClearInProgress = false
fun panicClearAllData() {
if (panicClearInProgress) return
panicClearInProgress = true
viewModelScope.launch {
try {
performPanicClearAllData()
} finally {
panicClearInProgress = false
}
}
}
private suspend fun performPanicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
try {
com.bitchat.android.geohash.LocationChannelManager
@ -1053,8 +1145,16 @@ class ChatViewModel(
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
mediaSendingManager.clearPendingPrivateMediaConsent()
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
// any transport callback already in flight until the fresh identity is ready.
clearAllMeshServiceData()
val conversationsCleared =
com.bitchat.android.services.AppStateStore
.panicClearPrivateConversations()
// Clear all UI managers
com.bitchat.android.services.AppStateStore.clear()
messageManager.clearAllMessages()
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
@ -1065,9 +1165,6 @@ class ChatViewModel(
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
// Clear all mesh service data
clearAllMeshServiceData()
// Clear all cryptographic data
clearAllCryptographicData()
@ -1099,8 +1196,17 @@ class ChatViewModel(
val newNickname = "anon${Random.nextInt(1000, 9999)}"
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
if (!conversationsCleared) {
// Privacy wins over availability: keep private-message admission and transports
// stopped if SQLite could not prove that the conversation history was erased.
Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed")
return
}
// Recreate mesh service with fresh identity
com.bitchat.android.services.AppStateStore
.resumePrivateConversationsAfterPanic()
recreateMeshServiceAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")

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

@ -0,0 +1,135 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.PrivateMessageArrivalOrder
/**
* Presence-independent presentation state for every retained private conversation.
*/
internal data class ConversationSummary(
val conversationID: String,
val displayName: String,
val unreadCount: Int,
val latestMessageAt: Long,
val latestActivityOrder: Long,
val latestMessageType: BitchatMessageType,
val latestMessagePreview: String,
val transport: DirectMessageTransport,
val nostrPubkey: String?,
val identityAliases: Set<String>,
val isConnected: Boolean = false,
val connectedPeerID: String? = null,
val sourceGeohash: String? = null
)
internal fun buildConversationSummaries(
unreadConversationIDs: Set<String>,
privateChats: Map<String, List<BitchatMessage>>,
currentUserIdentifiers: Set<String>,
canonicalize: (String) -> String,
isMessageRead: (BitchatMessage) -> Boolean
): List<ConversationSummary> {
if (privateChats.isEmpty()) return emptyList()
val currentUsers = currentUserIdentifiers.filterTo(mutableSetOf()) { it.isNotBlank() }
val unreadCanonicalIDs = unreadConversationIDs
.mapTo(mutableSetOf()) { canonicalize(it).lowercase() }
val aliasesByCanonicalID = linkedMapOf<String, MutableSet<String>>()
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
privateChats.forEach { (sourceID, messages) ->
val canonicalID = canonicalize(sourceID)
aliasesByCanonicalID
.getOrPut(canonicalID) { linkedSetOf() }
.add(sourceID)
messagesByCanonicalID
.getOrPut(canonicalID) { mutableListOf() }
.addAll(messages)
}
return messagesByCanonicalID.mapNotNull { (conversationID, sourceMessages) ->
val messages = sourceMessages.distinctBy { it.id }
if (messages.isEmpty()) return@mapNotNull null
fun activityOrder(message: BitchatMessage): Long =
PrivateMessageArrivalOrder.sequenceOf(message.id) ?: message.timestamp.time
val latest = messages.maxWithOrNull(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
) ?: return@mapNotNull null
val incoming = messages.filterNot {
it.sender in currentUsers || it.sender == "system"
}
val latestIncoming = incoming.maxWithOrNull(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
)
val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs
val unreadCount = if (canonicalUnread) {
incoming.count { !isMessageRead(it) }.coerceAtLeast(1)
} else {
0
}
val aliases = aliasesByCanonicalID[conversationID].orEmpty()
val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey
val isNostrConversation = nostrPubkey != null ||
aliases.any(::isNostrConversationKey) ||
isNostrConversationKey(conversationID)
val displayName = latestIncoming
?.sender
?.takeIf { it.isNotBlank() }
?: latest.recipientNickname
?.takeIf { it.isNotBlank() }
?: latest.sender.takeIf {
it.isNotBlank() && it !in currentUsers && it != "system"
}
?: conversationID.take(12)
ConversationSummary(
conversationID = conversationID,
displayName = displayName,
unreadCount = unreadCount,
latestMessageAt = latest.timestamp.time,
latestActivityOrder = activityOrder(latest),
latestMessageType = latest.type,
latestMessagePreview = latest.conversationPreview(),
transport = if (isNostrConversation) {
DirectMessageTransport.NOSTR
} else {
DirectMessageTransport.MESH
},
nostrPubkey = nostrPubkey,
identityAliases = (aliases + conversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
)
}
}
internal fun sortConversationSummaries(
conversations: List<ConversationSummary>
): List<ConversationSummary> = conversations.sortedWith(
compareByDescending<ConversationSummary> { it.isConnected }
.thenByDescending { it.unreadCount > 0 }
.thenByDescending { it.latestActivityOrder }
.thenBy { it.displayName.lowercase() }
.thenBy { it.conversationID }
)
private fun isNostrConversationKey(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
private fun BitchatMessage.conversationPreview(): String {
val preview = when (type) {
BitchatMessageType.File -> content
.substringAfterLast('/')
.substringAfterLast('\\')
else -> content
}
return preview
.replace(CONVERSATION_PREVIEW_WHITESPACE, " ")
.trim()
.take(MAX_CONVERSATION_PREVIEW_LENGTH)
}
private val CONVERSATION_PREVIEW_WHITESPACE = Regex("\\s+")
private const val MAX_CONVERSATION_PREVIEW_LENGTH = 240

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,32 @@ 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)
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
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 +732,106 @@ 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 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)
}
.padding(
horizontal = SheetRowHorizontal,
vertical = SheetRowVertical
),
verticalAlignment = Alignment.CenterVertically
val dismissState = rememberSwipeToDismissBoxState()
val favoriteTargetID =
conversation.connectedPeerID ?: conversation.conversationID
val favoriteRelationship = remember(
conversation.identityAliases,
favoritePeers,
peerFavoritedUs
) {
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
conversation.identityAliases
.asSequence()
.mapNotNull { alias ->
runCatching {
FavoritesPersistenceService.shared
.getFavoriteStatus(alias)
}.getOrNull()
}
.firstOrNull()
}
val fingerprint = conversation.connectedPeerID
?.let(peerFingerprints::get)
?: conversation.identityAliases
.asSequence()
.mapNotNull(peerFingerprints::get)
.firstOrNull()
?: ContactIdentityResolver
.fingerprintFromContactConversationId(
conversation.conversationID
)
?: favoriteRelationship?.peerNoisePublicKey?.let {
ContactIdentityResolver.fingerprintHex(it)
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Column(modifier = Modifier.weight(1f)) {
val isFavorite = if (fingerprint != null) {
fingerprint in favoritePeers
} else {
viewModel.isFavorite(favoriteTargetID)
}
val theyFavoritedUs =
(fingerprint != null && fingerprint in peerFavoritedUs) ||
favoriteRelationship?.theyFavoritedUs == true
LaunchedEffect(dismissState.currentValue, conversation.conversationID) {
if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteRequested(conversation)
dismissState.reset()
}
}
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
val alignment = when (dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
else -> Alignment.CenterEnd
}
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,
isFavorite = isFavorite,
theyFavoritedUs = theyFavoritedUs,
deleteDescription = deleteDescription,
onClick = {
onPrivateChatStart(conversation.conversationID)
},
onToggleFavorite = {
viewModel.toggleFavorite(favoriteTargetID)
},
onDeleteRequested = {
onDeleteRequested(conversation)
}
)
}
}
@ -735,6 +840,203 @@ private fun UnreadDirectMessagesSection(
}
}
@Composable
private fun ConversationRow(
conversation: ConversationSummary,
directPeerIdentityIDs: Set<String>,
wifiAwareIdentityIDs: Set<String>,
viewModel: ChatViewModel,
isFavorite: Boolean,
theyFavoritedUs: Boolean,
deleteDescription: String,
onClick: () -> Unit,
onToggleFavorite: () -> 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)
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = messagePreview,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = palette.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
Text(
text = " · $relativeTime",
fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = palette.textTertiary,
maxLines = 1
)
}
}
UnreadBadge(
count = conversation.unreadCount,
colorScheme = colorScheme,
modifier = Modifier.padding(start = 4.dp)
)
Box(
modifier = Modifier
.size(36.dp)
.clickable(onClick = onToggleFavorite),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(
if (isFavorite) {
R.drawable.ic_spec_star_filled
} else {
R.drawable.ic_spec_star
}
),
contentDescription = stringResource(
if (isFavorite) {
R.string.cd_remove_favorite
} else {
R.string.cd_add_favorite
}
),
modifier = Modifier.size(PeerRowIconSize),
tint = if (isFavorite || theyFavoritedUs) {
palette.accentOrange
} else {
palette.textTertiary
}
)
}
}
}
@Composable
private fun PeerItem(
peerID: String,
@ -755,6 +1057,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 +1122,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 +1186,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

@ -188,21 +188,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG)
}
private fun handleMessageReceived(message: BitchatMessage) {
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
private fun handleMessageReceived(message: BitchatMessage): Boolean {
// Match BLE admission semantics: a private message rejected during panic or as a
// duplicate must not create a notification after the conversation state was cleared.
if (
!com.bitchat.android.services.IncomingMessageAdmission
.admitToAppState(message)
) return false
if (delegate == null && message.isPrivate) {
try {
@ -215,6 +207,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
} catch (_: Exception) { }
}
return true
}
/**

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

@ -0,0 +1,67 @@
package com.bitchat.android.features.file
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.Date
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class FileUtilsConversationCleanupTest {
@Test
fun `deleting conversation removes only unreferenced app-owned media`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val testDirectory = File(
context.cacheDir,
"conversation-cleanup-${UUID.randomUUID()}"
).apply { mkdirs() }
val deletedOnly = File(testDirectory, "deleted.jpg").apply {
writeBytes(byteArrayOf(1))
}
val shared = File(testDirectory, "shared.jpg").apply {
writeBytes(byteArrayOf(2))
}
val outsideAppStorage = File.createTempFile(
"conversation-cleanup-",
".jpg"
).apply {
writeBytes(byteArrayOf(3))
}
try {
FileUtils.deleteConversationMedia(
context = context,
deletedMessages = listOf(
mediaMessage("deleted", deletedOnly),
mediaMessage("shared-deleted", shared),
mediaMessage("outside", outsideAppStorage)
),
retainedMessages = listOf(mediaMessage("shared-retained", shared))
)
assertFalse(deletedOnly.exists())
assertTrue(shared.exists())
assertTrue(outsideAppStorage.exists())
} finally {
testDirectory.deleteRecursively()
outsideAppStorage.delete()
}
}
private fun mediaMessage(id: String, file: File) = BitchatMessage(
id = id,
sender = "alice",
content = file.absolutePath,
type = BitchatMessageType.Image,
timestamp = Date(1L),
isPrivate = true
)
}

View File

@ -4,19 +4,23 @@ 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
import kotlinx.coroutines.runBlocking
import java.util.Date
class AppStateStoreTest {
@Before
fun setUp() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@After
fun tearDown() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@ -189,4 +193,79 @@ 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))
}
@Test
fun `panic clear rejects private messages until explicitly resumed`() {
val beforePanic = BitchatMessage(
id = "before-panic",
sender = "alice",
content = "erase me",
timestamp = Date(1L),
isPrivate = true
)
val duringPanic = beforePanic.copy(id = "during-panic")
val afterPanic = beforePanic.copy(id = "after-panic")
assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic))
assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() })
assertTrue(AppStateStore.privateMessages.value.isEmpty())
assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic))
AppStateStore.resumePrivateConversationsAfterPanic()
assertTrue(AppStateStore.addPrivateMessage("peer-a", afterPanic))
assertEquals(
listOf(afterPanic),
AppStateStore.privateMessages.value.getValue("peer-a")
)
}
}

View File

@ -0,0 +1,219 @@
package com.bitchat.android.services
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Date
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class ConversationDatabaseTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var database: ConversationDatabase
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "conversation-test-${UUID.randomUUID()}.db"
database = ConversationDatabase(context, databaseName)
}
@After
fun tearDown() {
database.close()
context.deleteDatabase(databaseName)
}
@Test
fun `message fields read state and delivery status survive reopen`() {
val message = BitchatMessage(
id = "round-trip",
sender = "alice",
content = "photo path",
type = BitchatMessageType.Image,
timestamp = Date(123_456L),
isRelay = true,
originalSender = "alice-original",
isPrivate = true,
recipientNickname = "me",
senderPeerID = "0123456789abcdef",
mentions = listOf("me", "bob"),
channel = "private",
encryptedContent = byteArrayOf(1, 2, 3),
isEncrypted = true,
deliveryStatus = DeliveryStatus.Delivered("me", Date(123_999L)),
senderNostrPubkey = "a".repeat(64)
)
database.upsertMessage(
conversationID = "contact_alice",
aliases = setOf("contact_alice", "0123456789abcdef"),
displayName = "alice",
message = message,
isRead = false
)
database.markRead(message.id)
database.updateDeliveryStatus(
message.id,
DeliveryStatus.Read("me", Date(124_000L))
)
database.close()
database = ConversationDatabase(context, databaseName)
val restored = database.loadSnapshot()
val restoredMessage = restored.chats.getValue("contact_alice").single()
assertEquals(message.id, restoredMessage.id)
assertEquals(message.sender, restoredMessage.sender)
assertEquals(message.content, restoredMessage.content)
assertEquals(message.type, restoredMessage.type)
assertEquals(message.timestamp, restoredMessage.timestamp)
assertEquals(message.mentions, restoredMessage.mentions)
assertEquals(message.senderNostrPubkey, restoredMessage.senderNostrPubkey)
assertArrayEquals(message.encryptedContent, restoredMessage.encryptedContent)
assertEquals(
DeliveryStatus.Read("me", Date(124_000L)),
restoredMessage.deliveryStatus
)
assertEquals(setOf(message.id), restored.readMessageIDs)
assertEquals(listOf(message.id), restored.arrivalOrder)
}
@Test
fun `aliases merge into one conversation and duplicate transport delivery stays unique`() {
val first = message("first", "alice", 100L)
val second = message("second", "alice", 200L)
database.upsertMessage("mesh-alias", setOf("mesh-alias"), "alice", first, false)
database.upsertMessage("nostr_alias", setOf("nostr_alias"), "alice", second, false)
database.mergeAliases(
targetConversationID = "contact_alice",
aliases = setOf("mesh-alias", "nostr_alias", "contact_alice")
)
database.upsertMessage(
"contact_alice",
setOf("mesh-alias", "nostr_alias"),
"alice",
first,
false
)
val restored = database.loadSnapshot()
assertEquals(setOf("contact_alice"), restored.chats.keys)
assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id })
}
@Test
fun `conversation deletion cascades messages while a later message starts fresh`() {
database.upsertMessage(
"contact_alice",
setOf("mesh-alias", "contact_alice"),
"alice",
message("old", "alice", 100L),
false
)
database.deleteConversation(
"contact_alice",
setOf("mesh-alias", "contact_alice")
)
assertTrue(database.loadSnapshot().chats.isEmpty())
database.close()
database = ConversationDatabase(context, databaseName)
database.upsertMessage(
"contact_alice",
setOf("mesh-alias", "contact_alice"),
"alice",
message("old", "alice", 100L),
false
)
assertTrue(database.loadSnapshot().chats.isEmpty())
database.upsertMessage(
"contact_alice",
setOf("mesh-alias", "contact_alice"),
"alice",
message("new", "alice", 200L),
false
)
val restored = database.loadSnapshot()
assertEquals(listOf("new"), restored.chats.getValue("contact_alice").map { it.id })
assertFalse(restored.readMessageIDs.contains("old"))
assertTrue(restored.deletedMessageIDs.contains("old"))
}
@Test
fun `per conversation retention keeps the newest bounded history`() {
val total = ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + 5
repeat(total) { index ->
database.upsertMessage(
conversationID = "contact_alice",
aliases = setOf("contact_alice"),
displayName = "alice",
message = message("message-$index", "alice", index.toLong()),
isRead = true
)
}
val snapshot = database.loadSnapshot()
val messages = snapshot.chats.getValue("contact_alice")
assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, messages.size)
assertEquals("message-5", messages.first().id)
assertEquals("message-${total - 1}", messages.last().id)
assertEquals(
(0 until 5).mapTo(mutableSetOf()) { "message-$it" },
snapshot.deletedMessageIDs
)
}
@Test
fun `global retention is hard bounded when every conversation has one message`() {
database.close()
context.deleteDatabase(databaseName)
database = ConversationDatabase(
context = context,
databaseName = databaseName,
maxMessagesPerConversation = 10,
maxMessagesTotal = 3,
maxPayloadBytes = Long.MAX_VALUE
)
repeat(4) { index ->
database.upsertMessage(
conversationID = "peer-$index",
aliases = setOf("peer-$index"),
displayName = "peer $index",
message = message("single-$index", "peer $index", index.toLong()),
isRead = true
)
}
database.pruneToRetentionLimits()
val snapshot = database.loadSnapshot()
assertEquals(3, snapshot.chats.values.sumOf { it.size })
assertFalse(snapshot.chats.containsKey("peer-0"))
assertTrue(snapshot.deletedMessageIDs.contains("single-0"))
}
private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage(
id = id,
sender = sender,
content = "hello-$id",
timestamp = Date(timestamp),
isPrivate = true,
senderPeerID = "0123456789abcdef"
)
}

View File

@ -0,0 +1,109 @@
package com.bitchat.android.services
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Date
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
@RunWith(RobolectricTestRunner::class)
class ConversationRepositoryTest {
private lateinit var context: Context
private lateinit var databaseName: String
private val executor = Executors.newSingleThreadExecutor()
private val dispatcher = executor.asCoroutineDispatcher()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "conversation-repository-test-${UUID.randomUUID()}.db"
}
@After
fun tearDown() {
dispatcher.close()
context.deleteDatabase(databaseName)
}
@Test
fun `reload restores persisted history after initial process restore`() {
val repository = ConversationRepository(
context = context,
dispatcher = dispatcher,
databaseName = databaseName
)
val message = BitchatMessage(
id = "persisted-message",
sender = "alice",
content = "survives restart",
timestamp = Date(100L),
isPrivate = true
)
repository.upsertMessage(
conversationID = "peer-alice",
aliases = setOf("peer-alice"),
displayName = "alice",
message = message,
isRead = true
)
runBlocking { repository.awaitPendingWrites() }
val initialSnapshot = AtomicReference<PersistedConversationSnapshot>()
repository.initialize(initialSnapshot::set)
runBlocking { repository.awaitPendingWrites() }
assertEquals(
listOf(message),
initialSnapshot.get().chats.getValue("peer-alice")
)
val reloadedSnapshot = AtomicReference<PersistedConversationSnapshot>()
repository.reload(reloadedSnapshot::set)
runBlocking { repository.awaitPendingWrites() }
assertEquals(
listOf(message),
reloadedSnapshot.get().chats.getValue("peer-alice")
)
}
@Test
fun `panic clear drains queued writes and leaves database empty`() {
val repository = ConversationRepository(
context = context,
dispatcher = dispatcher,
databaseName = databaseName
)
repository.upsertMessage(
conversationID = "peer-alice",
aliases = setOf("peer-alice"),
displayName = "alice",
message = BitchatMessage(
id = "queued-before-panic",
sender = "alice",
content = "must be erased",
timestamp = Date(100L),
isPrivate = true
),
isRead = true
)
assertTrue(runBlocking { repository.clearAllAndWait() })
val snapshot = AtomicReference<PersistedConversationSnapshot>()
repository.reload(snapshot::set)
runBlocking { repository.awaitPendingWrites() }
assertTrue(snapshot.get().chats.isEmpty())
assertTrue(snapshot.get().readMessageIDs.isEmpty())
assertTrue(snapshot.get().deletedMessageIDs.isEmpty())
}
}

View File

@ -0,0 +1,68 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
class IncomingMessageAdmissionTest {
@Before
fun setUp() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@After
fun tearDown() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@Test
fun `private message rejected during panic cannot continue transport dispatch`() {
assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() })
assertFalse(
IncomingMessageAdmission.admitToAppState(
privateMessage(id = "during-panic")
)
)
assertTrue(AppStateStore.privateMessages.value.isEmpty())
}
@Test
fun `duplicate private transport delivery is rejected before downstream effects`() {
val message = privateMessage(id = "same-message-over-two-transports")
assertTrue(IncomingMessageAdmission.admitToAppState(message))
assertFalse(IncomingMessageAdmission.admitToAppState(message))
}
@Test
fun `public and channel messages preserve best effort admission`() {
val public = BitchatMessage(
id = "public",
sender = "alice",
content = "hello",
timestamp = Date(1L)
)
val channel = public.copy(id = "channel", channel = "#mesh")
assertTrue(IncomingMessageAdmission.admitToAppState(public))
assertTrue(IncomingMessageAdmission.admitToAppState(channel))
assertTrue(AppStateStore.publicMessages.value.contains(public))
}
private fun privateMessage(id: String) = BitchatMessage(
id = id,
sender = "alice",
content = "secret",
timestamp = Date(1L),
isPrivate = true,
senderPeerID = "peer-a"
)
}

View File

@ -0,0 +1,177 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.PrivateMessageArrivalOrder
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.util.Date
class ConversationSummaryTest {
@After
fun tearDown() {
PrivateMessageArrivalOrder.clear()
}
@Test
fun `read offline conversation remains present`() {
val message = incoming("message", "alice", 100L)
PrivateMessageArrivalOrder.record(message.id)
val conversations = buildConversationSummaries(
unreadConversationIDs = emptySet(),
privateChats = mapOf("alice-peer" to listOf(message)),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { true }
)
assertEquals(1, conversations.size)
assertEquals("alice-peer", conversations.single().conversationID)
assertEquals(0, conversations.single().unreadCount)
assertFalse(conversations.single().isConnected)
}
@Test
fun `canonical aliases yield one conversation with unique messages`() {
val first = incoming("first", "alice", 300L)
val second = incoming("second", "alice", 100L)
PrivateMessageArrivalOrder.record(first.id)
PrivateMessageArrivalOrder.record(second.id)
val conversations = buildConversationSummaries(
unreadConversationIDs = setOf("mesh-alias"),
privateChats = mapOf(
"mesh-alias" to listOf(first),
"nostr_alias" to listOf(first, second)
),
currentUserIdentifiers = setOf("me"),
canonicalize = { "contact_alice" },
isMessageRead = { false }
)
assertEquals(1, conversations.size)
assertEquals(2, conversations.single().unreadCount)
assertEquals(
setOf("mesh-alias", "nostr_alias", "contact_alice"),
conversations.single().identityAliases
)
assertEquals("alice", conversations.single().displayName)
assertEquals(DirectMessageTransport.NOSTR, conversations.single().transport)
}
@Test
fun `online conversations sort before newer offline conversations`() {
val offline = summary(
id = "offline",
isConnected = false,
unreadCount = 3,
activity = 300L
)
val onlineRead = summary(
id = "online-read",
isConnected = true,
unreadCount = 0,
activity = 100L
)
val onlineUnread = summary(
id = "online-unread",
isConnected = true,
unreadCount = 1,
activity = 50L
)
assertEquals(
listOf("online-unread", "online-read", "offline"),
sortConversationSummaries(listOf(offline, onlineRead, onlineUnread))
.map { it.conversationID }
)
}
@Test
fun `preview follows local arrival order instead of remote timestamp`() {
val first = incoming(
id = "first",
sender = "alice",
timestamp = 900L,
content = "older arrival"
)
val second = incoming(
id = "second",
sender = "alice",
timestamp = 100L,
content = " latest\nmessage "
)
PrivateMessageArrivalOrder.record(first.id)
PrivateMessageArrivalOrder.record(second.id)
val conversation = buildConversationSummaries(
unreadConversationIDs = emptySet(),
privateChats = mapOf("alice-peer" to listOf(first, second)),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { true }
).single()
assertEquals("latest message", conversation.latestMessagePreview)
assertEquals(BitchatMessageType.Message, conversation.latestMessageType)
}
@Test
fun `file preview contains filename without local path`() {
val message = incoming(
id = "file",
sender = "alice",
timestamp = 100L,
content = "/private/conversations/quarterly report.pdf",
type = BitchatMessageType.File
)
val conversation = buildConversationSummaries(
unreadConversationIDs = emptySet(),
privateChats = mapOf("alice-peer" to listOf(message)),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { true }
).single()
assertEquals("quarterly report.pdf", conversation.latestMessagePreview)
assertEquals(BitchatMessageType.File, conversation.latestMessageType)
}
private fun incoming(
id: String,
sender: String,
timestamp: Long,
content: String = "hello",
type: BitchatMessageType = BitchatMessageType.Message
) = BitchatMessage(
id = id,
sender = sender,
content = content,
type = type,
timestamp = Date(timestamp),
isPrivate = true
)
private fun summary(
id: String,
isConnected: Boolean,
unreadCount: Int,
activity: Long
) = ConversationSummary(
conversationID = id,
displayName = id,
unreadCount = unreadCount,
latestMessageAt = activity,
latestActivityOrder = activity,
latestMessageType = BitchatMessageType.Message,
latestMessagePreview = "hello",
transport = DirectMessageTransport.MESH,
nostrPubkey = null,
identityAliases = setOf(id),
isConnected = isConnected
)
}

View File

@ -77,6 +77,7 @@ val sharedSourceIncludes = listOf(
"com/bitchat/android/services/AppStateStore.kt",
"com/bitchat/android/services/ContactDirectory.kt",
"com/bitchat/android/services/ContactIdentityResolver.kt",
"com/bitchat/android/services/ConversationRepository.kt",
"com/bitchat/android/services/PrivateMessageArrivalOrder.kt",
"com/bitchat/android/services/SeenMessageStore.kt",
"com/bitchat/android/services/VerificationService.kt",

View File

@ -227,18 +227,27 @@ class WearMeshService private constructor(private val context: Context) {
}
}
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: return
AppStateStore.addPrivateMessage(peer, message)
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
}
message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message)
else -> AppStateStore.addPublicMessage(message)
private fun handleMessageReceived(
message: com.bitchat.android.model.BitchatMessage
): Boolean = try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: return false
if (!AppStateStore.addPrivateMessage(peer, message)) return false
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
true
}
} catch (_: Exception) { }
message.channel != null -> {
AppStateStore.addChannelMessage(message.channel, message)
true
}
else -> {
AppStateStore.addPublicMessage(message)
true
}
}
} catch (_: Exception) {
!message.isPrivate
}
fun startServices() {