Merge pull request #815 from permissionlesstech/codex/polish-persistent-conversations-v2

Polish persistent private conversations
This commit is contained in:
callebtc 2026-07-29 15:05:42 +02:00 committed by GitHub
commit 3410aaf5b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 4510 additions and 489 deletions

View File

@ -137,6 +137,11 @@
</intent-filter>
</receiver>
<receiver
android:name=".service.ConversationNotificationReceiver"
android:enabled="true"
android:exported="false" />
<!-- Auto-start mesh service after boot if enabled -->
<receiver
android:name=".service.BootCompletedReceiver"

View File

@ -167,6 +167,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
notifyChanged(pid)
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")

View File

@ -375,4 +375,30 @@ object FileUtils {
}
}
}
/**
* Deletes paths proven by the conversation database to have no remaining message reference.
* The same canonical-root boundary as explicit conversation deletion prevents arbitrary paths
* from being removed even if persisted metadata is malformed.
*/
fun deleteStoredMediaPaths(context: Context, paths: Collection<String>) {
val roots = listOf(context.filesDir, context.cacheDir)
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
paths.asSequence()
.mapNotNull { runCatching { File(it).canonicalFile }.getOrNull() }
.distinctBy(File::getPath)
.filter { file ->
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 unreferenced conversation media")
}
}
}
}
}

View File

@ -528,4 +528,11 @@ class SecureIdentityStateManager {
}
editor.apply()
}
/** Use for panic paths that must finish the disk mutation before identity reset continues. */
fun clearSecureValuesSynchronously(vararg keys: String): Boolean {
val editor = prefs.edit()
keys.forEach(editor::remove)
return editor.commit()
}
}

View File

@ -132,7 +132,14 @@ class NostrDirectMessageHandler(
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
val admitted = handleFavoriteControl(
favoriteControl,
conversationID,
senderNickname,
timestamp,
senderPubkey
)
if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
@ -157,13 +164,14 @@ class NostrDirectMessageHandler(
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
val admitted = withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
@ -215,13 +223,19 @@ class NostrDirectMessageHandler(
senderNostrPubkey = senderPubkey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
val admitted = withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!admitted) {
com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(
application,
listOf(savedPath)
)
}
} else {
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
@ -238,15 +252,15 @@ class NostrDirectMessageHandler(
senderNickname: String,
timestamp: Date,
senderPubkey: String
) {
try {
): Boolean {
return try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return
return false
}
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
@ -278,7 +292,7 @@ class NostrDirectMessageHandler(
)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
privateChatManager.handleIncomingPrivateMessageDurably(
message = systemMessage,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
@ -286,6 +300,7 @@ class NostrDirectMessageHandler(
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
false
}
}

View File

@ -0,0 +1,88 @@
package com.bitchat.android.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.MessageRouter
import com.bitchat.android.ui.NotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
/** Handles privacy-scoped direct reply and mark-read actions from DM notifications. */
class ConversationNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val conversationID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID)
?.let(ContactDirectory::canonicalConversationId)
?: return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
var acknowledged = false
when (intent.action) {
NotificationManager.ACTION_MARK_CONVERSATION_READ -> {
acknowledged =
AppStateStore.setPrivateConversationRead(conversationID, true)
}
NotificationManager.ACTION_REPLY_TO_CONVERSATION -> {
val reply = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence(NotificationManager.KEY_TEXT_REPLY)
?.toString()
?.trim()
?.takeIf(String::isNotEmpty)
?: return@launch
// A notification can outlive the process/service that posted it. Promote
// the mesh runtime before dispatch so Android keeps the transport alive
// after this short-lived receiver finishes.
MeshForegroundService.start(context.applicationContext)
val mesh = MeshServiceHolder.getUnifiedOrCreate(
context.applicationContext
)
val message = BitchatMessage(
id = UUID.randomUUID().toString().uppercase(),
sender = mesh.myPeerID,
content = reply,
timestamp = Date(),
isPrivate = true,
recipientNickname = intent.getStringExtra(
NotificationManager.EXTRA_SENDER_NICKNAME
),
senderPeerID = mesh.myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
val persisted = AppStateStore.addPrivateMessageDurably(
peerID = conversationID,
msg = message,
forceRead = true
)
if (persisted) {
MessageRouter.getInstance(context.applicationContext, mesh)
.sendPrivate(
content = reply,
toPeerID = conversationID,
recipientNickname = message.recipientNickname.orEmpty(),
messageID = message.id
)
acknowledged =
AppStateStore.setPrivateConversationRead(conversationID, true)
}
}
}
if (acknowledged) {
NotificationManager.acknowledgeConversation(context, conversationID)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
private val reservedPrivateMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
private var privateWritesSinceGlobalPrune = 0
@ -34,10 +35,18 @@ object AppStateStore {
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
private val _readPrivateMessageIDs = MutableStateFlow<Set<String>>(emptySet())
val readPrivateMessageIDs: StateFlow<Set<String>> = _readPrivateMessageIDs.asStateFlow()
private val _unreadPrivateMessageCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadPrivateMessageCounts: StateFlow<Map<String, Int>> =
_unreadPrivateMessageCounts.asStateFlow()
private val _privateConversationDisplayNames =
MutableStateFlow<Map<String, String>>(emptyMap())
val privateConversationDisplayNames: StateFlow<Map<String, String>> =
_privateConversationDisplayNames.asStateFlow()
@Volatile
private var conversationRepository: ConversationRepository? = null
private var privateConversationWritesSuspended = false
private var privateConversationGeneration = 0L
private val _nickname = MutableStateFlow("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
@ -79,10 +88,53 @@ object AppStateStore {
repository.reload(::restorePrivateConversations)
}
internal fun setConversationRepositoryForTest(repository: ConversationRepository?) {
conversationRepository = repository
}
suspend fun awaitConversationPersistence() {
conversationRepository?.awaitPendingWrites()
}
suspend fun loadPrivateConversationHistory(conversationID: String): Boolean {
val repository = conversationRepository ?: return false
val snapshot = repository.loadConversationAndWait(
ContactDirectory.canonicalConversationId(conversationID)
) ?: return false
restorePrivateConversations(snapshot)
return true
}
/**
* Drops an opened conversation's full payloads from memory while retaining its summary row.
* The complete bounded history remains encrypted in SQLite and is loaded again on demand.
*/
fun releasePrivateConversationHistory(conversationID: String) {
synchronized(this) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val matching = _privateMessages.value.entries.filter { (id, _) ->
ContactDirectory.canonicalConversationId(id)
.equals(canonicalID, ignoreCase = true)
}
val latest = matching
.flatMap { it.value }
.distinctBy { it.id }
.maxWithOrNull(
compareBy<BitchatMessage> {
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MIN_VALUE
}.thenBy { it.timestamp.time }
)
?: return
val compacted = _privateMessages.value.toMutableMap()
matching.forEach { compacted.remove(it.key) }
compacted[canonicalID] = listOf(latest)
_privateMessages.value = compacted
}
}
val conversationStoreState: StateFlow<ConversationStoreState>
get() = conversationRepository?.storeState ?: EMPTY_CONVERSATION_STORE_STATE
fun setTransportPeers(transportId: String, ids: List<String>) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
@ -149,8 +201,64 @@ object AppStateStore {
msg: BitchatMessage,
forceRead: Boolean = false
): Boolean = synchronized(this) {
if (privateConversationWritesSuspended) return@synchronized false
if (seenMessageIds.contains(msg.id)) return@synchronized false
addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true)
}
/**
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.
*/
suspend fun addPrivateMessageDurably(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean = false
): Boolean {
val persistence = synchronized(this) {
if (privateConversationWritesSuspended) return false
if (seenMessageIds.contains(msg.id) || !reservedPrivateMessageIds.add(msg.id)) {
return false
}
privateMessagePersistence(peerID, msg, forceRead)
}
val repository = persistence.repository
if (repository == null) {
synchronized(this) { reservedPrivateMessageIds.remove(msg.id) }
return false
}
val persisted = repository.upsertMessageAndWait(
conversationID = persistence.conversationID,
aliases = persistence.aliases,
displayName = persistence.displayName,
message = msg,
isRead = persistence.isRead
)
return synchronized(this) {
reservedPrivateMessageIds.remove(msg.id)
if (
!persisted ||
privateConversationWritesSuspended ||
persistence.generation != privateConversationGeneration ||
seenMessageIds.contains(msg.id)
) {
return@synchronized false
}
addPrivateMessageLocked(
peerID = peerID,
msg = msg,
forceRead = forceRead,
persistAsynchronously = false
)
}
}
private fun addPrivateMessageLocked(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean,
persistAsynchronously: Boolean
): Boolean {
if (privateConversationWritesSuspended) return false
if (seenMessageIds.contains(msg.id)) return false
seenMessageIds.add(msg.id)
PrivateMessageArrivalOrder.record(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
@ -167,6 +275,10 @@ object AppStateStore {
?.equals(conversationID, ignoreCase = true) == true
if (isRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
} else {
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
counts[conversationID] = (counts[conversationID] ?: 0) + 1
_unreadPrivateMessageCounts.value = counts
}
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
@ -177,15 +289,58 @@ object AppStateStore {
?: msg.sender.takeUnless {
it.isBlank() || it == "system" || it == _nickname.value
}
conversationRepository?.upsertMessage(
displayName
?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
?.let { updateConversationDisplayNameLocked(conversationID, it) }
if (persistAsynchronously) {
conversationRepository?.upsertMessage(
conversationID = conversationID,
aliases = aliases,
displayName = displayName,
message = msg,
isRead = isRead
)
}
prunePrivateMessagesLocked(conversationID)
return true
}
private fun privateMessagePersistence(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean
): PendingPrivateMessagePersistence {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val existingMessages = _privateMessages.value[conversationID].orEmpty()
val isRead = forceRead ||
msg.sender == "system" ||
msg.sender == _nickname.value ||
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(conversationID, ignoreCase = true) == true
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(conversationID) +
listOfNotNull(msg.senderPeerID)
}.getOrDefault(setOf(peerID, conversationID))
val displayName = ContactDirectory.resolve(conversationID).displayName
?: (existingMessages + msg)
.lastOrNull { candidate ->
candidate.sender.isNotBlank() &&
candidate.sender != "system" &&
candidate.sender != _nickname.value
}
?.sender
return PendingPrivateMessagePersistence(
repository = conversationRepository,
conversationID = conversationID,
aliases = aliases,
displayName = displayName,
message = msg,
isRead = isRead
isRead = isRead,
generation = privateConversationGeneration
)
prunePrivateMessagesLocked(conversationID)
true
}
fun hasSeenMessage(messageID: String): Boolean = synchronized(this) {
@ -213,7 +368,14 @@ object AppStateStore {
if (idx >= 0) {
val current = list[idx].deliveryStatus
// Do not downgrade (e.g., Read -> Delivered)
if (statusPriority(status) >= statusPriority(current)) {
val mayReplace = when {
status is DeliveryStatus.Failed ->
current !is DeliveryStatus.Delivered &&
current !is DeliveryStatus.Read
current is DeliveryStatus.Failed -> true
else -> statusPriority(status) >= statusPriority(current)
}
if (mayReplace) {
list[idx] = list[idx].copy(deliveryStatus = status)
map[peer] = list
changed = true
@ -222,8 +384,11 @@ object AppStateStore {
}
if (changed) {
_privateMessages.value = map
conversationRepository?.updateDeliveryStatus(messageID, status)
}
// Full histories are unloaded after a chat closes, so the message may only exist in
// SQLite. Always offer the update to the repository; it safely ignores unknown IDs
// and enforces the same monotonic status rules as the in-memory path.
conversationRepository?.updateDeliveryStatus(messageID, status)
}
}
@ -273,16 +438,48 @@ object AppStateStore {
} else {
map[targetConversationID] = targetList
}
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
_privateMessages.value = map
}
canonicalizePrivateConversationStateLocked()
}
}
fun canonicalizePrivateChats() {
synchronized(this) {
val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonical != _privateMessages.value) {
_privateMessages.value = canonical
canonicalizePrivateConversationStateLocked()
}
}
/**
* Applies current peer announcements to retained conversations and persists the latest name
* independently of message history. A nickname change must not require another message to
* survive process death.
*/
fun updatePrivateConversationDisplayNames(peerNicknames: Map<String, String>) {
if (peerNicknames.isEmpty()) return
synchronized(this) {
if (privateConversationWritesSuspended || _privateMessages.value.isEmpty()) return
canonicalizePrivateConversationStateLocked()
val conversationIDs = _privateMessages.value.keys
.mapTo(mutableSetOf()) { it.lowercase() }
peerNicknames.forEach { (peerID, nickname) ->
val usableName = nickname.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
} ?: return@forEach
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
if (canonicalID.lowercase() !in conversationIDs) {
return@forEach
}
updateConversationDisplayNameLocked(canonicalID, usableName)
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(canonicalID)
}.getOrDefault(setOf(peerID, canonicalID))
conversationRepository?.updateConversationIdentity(
conversationID = canonicalID,
aliases = aliases,
displayName = usableName
)
}
}
}
@ -291,7 +488,19 @@ object AppStateStore {
synchronized(this) {
if (privateConversationWritesSuspended) return
if (messageID in _readPrivateMessageIDs.value) return
canonicalizePrivateConversationStateLocked()
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID
val conversationID = _privateMessages.value.entries
.firstOrNull { (_, messages) -> messages.any { it.id == messageID } }
?.key
if (conversationID != null) {
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
val remaining = ((counts[conversationID] ?: 0) - 1).coerceAtLeast(0)
if (remaining == 0) counts.remove(conversationID) else {
counts[conversationID] = remaining
}
_unreadPrivateMessageCounts.value = counts
}
conversationRepository?.markRead(messageID)
}
}
@ -299,6 +508,42 @@ object AppStateStore {
fun isPrivateMessageRead(messageID: String): Boolean =
messageID in _readPrivateMessageIDs.value
suspend fun setPrivateConversationRead(
conversationID: String,
isRead: Boolean
): Boolean {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val repository = conversationRepository ?: return false
val result = repository.setConversationReadAndWait(canonicalID, isRead)
if (!result.success) return false
synchronized(this) {
val messageIDs = _privateMessages.value
.filterKeys { key ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
.values
.flatten()
.mapTo(linkedSetOf(), BitchatMessage::id)
if (isRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageIDs
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value.filterKeys { key ->
!ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
} else {
result.affectedMessageID?.let { latestMessageID ->
_readPrivateMessageIDs.value =
_readPrivateMessageIDs.value - latestMessageID
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value + (canonicalID to 1)
}
}
}
return true
}
fun deletePrivateConversation(peerOrConversationID: String): Set<String> {
synchronized(this) {
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
@ -323,7 +568,10 @@ object AppStateStore {
val updated = _privateMessages.value.toMutableMap()
matchingKeys.forEach(updated::remove)
_privateMessages.value = updated
removeConversationDisplayNamesLocked(canonicalID)
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value - matchingKeys - canonicalID
if (
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
@ -335,6 +583,129 @@ object AppStateStore {
}
}
internal suspend fun deletePrivateConversationAndWait(
peerOrConversationID: String
): DeletedPrivateConversation? {
loadPrivateConversationHistory(peerOrConversationID)
val deletion = synchronized(this) {
if (privateConversationWritesSuspended) return null
buildDeletedConversationLocked(peerOrConversationID)
}
val repository = conversationRepository ?: return null
if (!repository.deleteConversationAndWait(deletion.conversationID, deletion.aliases)) {
return null
}
synchronized(this) {
val updated = _privateMessages.value.toMutableMap()
updated.keys.toList().forEach { key ->
if (
ContactDirectory.canonicalConversationId(key)
.equals(deletion.conversationID, ignoreCase = true)
) {
val remaining = updated[key].orEmpty().filterNot {
it.id in deletion.messageIDs
}
if (remaining.isEmpty()) updated.remove(key) else updated[key] = remaining
}
}
_privateMessages.value = updated
removeConversationDisplayNamesLocked(deletion.conversationID)
_readPrivateMessageIDs.value =
_readPrivateMessageIDs.value - deletion.messageIDs
canonicalizePrivateConversationStateLocked()
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
val currentCount = counts[deletion.conversationID] ?: 0
val remainingUnread = (currentCount - deletion.unreadMessageCount).coerceAtLeast(0)
if (remainingUnread == 0) counts.remove(deletion.conversationID) else {
counts[deletion.conversationID] = remainingUnread
}
_unreadPrivateMessageCounts.value = counts
if (
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(deletion.conversationID, ignoreCase = true) == true
) {
_selectedPrivateChatPeer.value = null
}
}
return deletion
}
internal suspend fun restoreDeletedConversation(
deletion: DeletedPrivateConversation
): Boolean {
val repository = conversationRepository ?: return false
val restoredDisplayName =
ContactDirectory.resolve(deletion.conversationID).displayName
?: deletion.displayName
if (
!repository.restoreConversationAndWait(
conversationID = deletion.conversationID,
aliases = deletion.aliases,
displayName = restoredDisplayName,
messages = deletion.messages,
readMessageIDs = deletion.readMessageIDs
)
) {
return false
}
synchronized(this) {
seenMessageIds.removeAll(deletion.messageIDs)
deletion.messages.forEach { message ->
addPrivateMessageLocked(
peerID = deletion.conversationID,
msg = message,
forceRead = message.id in deletion.readMessageIDs,
persistAsynchronously = false
)
}
restoredDisplayName?.let {
updateConversationDisplayNameLocked(deletion.conversationID, it)
}
}
return true
}
private fun buildDeletedConversationLocked(
peerOrConversationID: String
): DeletedPrivateConversation {
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 messages = matchingKeys
.flatMap { _privateMessages.value[it].orEmpty() }
.distinctBy(BitchatMessage::id)
val messageIDs = messages.mapTo(linkedSetOf(), BitchatMessage::id)
val readIDs = _readPrivateMessageIDs.value.intersect(messageIDs)
return DeletedPrivateConversation(
conversationID = canonicalID,
aliases = aliases,
displayName = _privateConversationDisplayNames.value.entries
.firstOrNull { (key, _) ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
?.value
?: ContactDirectory.resolve(canonicalID).displayName,
messages = messages,
readMessageIDs = readIDs,
unreadMessageCount = messages.count { message ->
message.id !in readIDs &&
message.sender != "system" &&
message.sender != _nickname.value
}
)
}
fun removePrivateMessage(messageID: String) {
synchronized(this) {
val updated = _privateMessages.value.toMutableMap()
@ -354,6 +725,15 @@ object AppStateStore {
if (!changed) return
conversationRepository?.deleteMessage(messageID)
_privateMessages.value = updated
val retainedConversationIDs = updated.keys
.mapTo(mutableSetOf()) {
ContactDirectory.canonicalConversationId(it).lowercase()
}
_privateConversationDisplayNames.value =
_privateConversationDisplayNames.value.filterKeys {
ContactDirectory.canonicalConversationId(it).lowercase() in
retainedConversationIDs
}
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID
}
}
@ -365,8 +745,11 @@ object AppStateStore {
suspend fun panicClearPrivateConversations(): Boolean {
val repository = synchronized(this) {
privateConversationWritesSuspended = true
privateConversationGeneration += 1
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_unreadPrivateMessageCounts.value = emptyMap()
_privateConversationDisplayNames.value = emptyMap()
_selectedPrivateChatPeer.value = null
conversationRepository
}
@ -394,6 +777,8 @@ object AppStateStore {
fun clear() {
synchronized(this) {
seenMessageIds.clear()
reservedPrivateMessageIds.clear()
privateConversationGeneration += 1
seenPublicMessageKeys.clear()
PrivateMessageArrivalOrder.clear()
privateWritesSinceGlobalPrune = 0
@ -404,6 +789,8 @@ object AppStateStore {
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_unreadPrivateMessageCounts.value = emptyMap()
_privateConversationDisplayNames.value = emptyMap()
_channelMessages.value = emptyMap()
_nickname.value = ""
_selectedPrivateChatPeer.value = null
@ -421,12 +808,17 @@ object AppStateStore {
).joinToString("\u001F")
}
private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
internal 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)
PrivateMessageArrivalOrder.restore(
snapshot.arrivalOrder,
liveMessageIDs,
snapshot.receivedAtByMessageID,
snapshot.arrivalSequenceByMessageID
)
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
snapshot.chats.forEach { (conversationID, messages) ->
@ -451,11 +843,85 @@ object AppStateStore {
_readPrivateMessageIDs.value =
(snapshot.readMessageIDs + _readPrivateMessageIDs.value) -
snapshot.deletedMessageIDs
val unreadCounts = _unreadPrivateMessageCounts.value.toMutableMap()
snapshot.unreadCounts.forEach { (conversationID, count) ->
if (count > 0) unreadCounts[conversationID] = count
else unreadCounts.remove(conversationID)
}
_unreadPrivateMessageCounts.value = unreadCounts
_privateConversationDisplayNames.value =
snapshot.displayNames + _privateConversationDisplayNames.value
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(
merged.mapValues { (_, messages) ->
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
}
)
canonicalizePrivateConversationStateLocked()
}
}
private fun updateConversationDisplayNameLocked(
conversationID: String,
displayName: String
) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val updated = _privateConversationDisplayNames.value
.filterKeys { key ->
!ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
.toMutableMap()
updated[canonicalID] = displayName
_privateConversationDisplayNames.value = updated
}
private fun removeConversationDisplayNamesLocked(conversationID: String) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
_privateConversationDisplayNames.value =
_privateConversationDisplayNames.value.filterKeys { key ->
!ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
}
/**
* Identity mappings can become richer after a Noise handshake or favorite/Nostr update.
* Keep every process-wide projection on the same canonical key so unread/read/delete updates
* cannot leave a stale alias behind.
*/
private fun canonicalizePrivateConversationStateLocked() {
val canonicalChats = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonicalChats != _privateMessages.value) {
_privateMessages.value = canonicalChats
}
val canonicalUnreadCounts = linkedMapOf<String, Int>()
_unreadPrivateMessageCounts.value.forEach { (conversationID, count) ->
if (count <= 0) return@forEach
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
canonicalUnreadCounts[canonicalID] =
(canonicalUnreadCounts[canonicalID] ?: 0) + count
}
if (canonicalUnreadCounts != _unreadPrivateMessageCounts.value) {
_unreadPrivateMessageCounts.value = canonicalUnreadCounts
}
val canonicalDisplayNames = linkedMapOf<String, String>()
_privateConversationDisplayNames.value.forEach { (conversationID, displayName) ->
if (displayName.isBlank()) return@forEach
canonicalDisplayNames[
ContactDirectory.canonicalConversationId(conversationID)
] = displayName
}
if (canonicalDisplayNames != _privateConversationDisplayNames.value) {
_privateConversationDisplayNames.value = canonicalDisplayNames
}
_selectedPrivateChatPeer.value?.let { selected ->
val canonicalSelected = ContactDirectory.canonicalConversationId(selected)
if (canonicalSelected != selected) {
_selectedPrivateChatPeer.value = canonicalSelected
}
}
}
@ -565,3 +1031,29 @@ object AppStateStore {
it.toByteArray(Charsets.UTF_8).size
}
}
private data class PendingPrivateMessagePersistence(
val repository: ConversationRepository?,
val conversationID: String,
val aliases: Set<String>,
val displayName: String?,
val isRead: Boolean,
val generation: Long
)
internal data class DeletedPrivateConversation(
val conversationID: String,
val aliases: Set<String>,
val displayName: String?,
val messages: List<BitchatMessage>,
val readMessageIDs: Set<String>,
val unreadMessageCount: Int,
val wasPinned: Boolean = false,
val wasMuted: Boolean = false,
val draft: String? = null
) {
val messageIDs: Set<String> = messages.mapTo(linkedSetOf(), BitchatMessage::id)
}
private val EMPTY_CONVERSATION_STORE_STATE =
MutableStateFlow<ConversationStoreState>(ConversationStoreState.Ready).asStateFlow()

View File

@ -81,9 +81,19 @@ object ContactDirectory {
conversationID = conversationID,
meshPeerID = liveMeshPeerID,
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
nostrPubkey = favorite?.peerNostrPublicKey,
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
nostrPubkey = favorite?.peerNostrPublicKey
?: liveMeshPeerID?.let {
runCatching {
FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(it)
}.getOrNull()
},
// A connected peer's current announcement is authoritative. Favorite and fingerprint
// records are offline fallbacks and can legitimately contain an older nickname.
displayName = liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: favorite?.peerNickname?.takeIf {
it.isNotBlank() && !it.equals("Unknown", ignoreCase = true)
}
?: contactFingerprint?.let { cachedFingerprintNickname(it) },
isMutualFavorite = favorite?.isMutual == true
)

View File

@ -0,0 +1,194 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
/**
* Small encrypted, immediately observable preferences for conversation-list organization.
*
* Message history remains in SQLite; these compact sets and drafts belong in the app's existing
* Keystore-backed preference store. Panic clearing the identity store also removes these values.
*/
internal class ConversationListPreferences private constructor(
private val stateManager: SecureIdentityStateManager,
private val canonicalize: (String) -> String
) {
private constructor(context: Context) : this(
SecureIdentityStateManager(context.applicationContext),
ContactDirectory::canonicalConversationId
)
internal constructor(
stateManager: SecureIdentityStateManager,
testOnly: Boolean,
canonicalize: (String) -> String = ContactDirectory::canonicalConversationId
) : this(stateManager, canonicalize) {
require(testOnly) { "Injected conversation preferences are test-only" }
}
companion object {
private const val PINNED_KEY = "conversation_pinned_v1"
private const val MUTED_KEY = "conversation_muted_v1"
private const val DRAFTS_KEY = "conversation_drafts_v1"
private const val MAX_DRAFT_CHARS = 8_000
private const val MAX_DRAFTS = 50
private const val MAX_DRAFT_CHARS_TOTAL = 128_000
@Volatile
private var instance: ConversationListPreferences? = null
fun getInstance(context: Context): ConversationListPreferences =
instance ?: synchronized(this) {
instance ?: ConversationListPreferences(context.applicationContext).also {
instance = it
}
}
}
private val _pinned = MutableStateFlow(loadSet(PINNED_KEY))
val pinned: StateFlow<Set<String>> = _pinned.asStateFlow()
private val _muted = MutableStateFlow(loadSet(MUTED_KEY))
val muted: StateFlow<Set<String>> = _muted.asStateFlow()
private val _drafts = MutableStateFlow(loadDrafts())
val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
fun togglePinned(conversationID: String) {
_pinned.value = _pinned.value.toggle(normalize(conversationID))
saveSet(PINNED_KEY, _pinned.value)
}
fun toggleMuted(conversationID: String) {
_muted.value = _muted.value.toggle(normalize(conversationID))
saveSet(MUTED_KEY, _muted.value)
}
fun isMuted(conversationID: String): Boolean =
normalize(conversationID) in _muted.value
fun isPinned(conversationID: String): Boolean =
normalize(conversationID) in _pinned.value
fun draftFor(conversationID: String): String? =
_drafts.value[normalize(conversationID)]
fun setDraft(conversationID: String, text: String) {
val key = normalize(conversationID)
val updated = _drafts.value.toMutableMap()
// Reinsert edited drafts at the end so bounded eviction approximates least-recently-used.
updated.remove(key)
val bounded = text.take(MAX_DRAFT_CHARS)
if (bounded.isNotBlank()) updated[key] = bounded
val retained = boundDrafts(updated)
_drafts.value = retained
saveDrafts(retained)
}
fun removeConversation(conversationID: String) {
val key = normalize(conversationID)
_pinned.value = _pinned.value - key
_muted.value = _muted.value - key
_drafts.value = _drafts.value - key
saveSet(PINNED_KEY, _pinned.value)
saveSet(MUTED_KEY, _muted.value)
saveDrafts(_drafts.value)
}
/**
* Re-key list preferences when a transient mesh ID becomes a stable contact identity.
* Without this, pin, mute, and draft state appears to disappear after a Noise/favorite update.
*/
fun canonicalizeAliases() {
val canonicalPinned = _pinned.value.mapTo(linkedSetOf(), ::normalize)
val canonicalMuted = _muted.value.mapTo(linkedSetOf(), ::normalize)
val canonicalDrafts = linkedMapOf<String, String>()
_drafts.value.forEach { (conversationID, draft) ->
canonicalDrafts[normalize(conversationID)] = draft
}
if (canonicalPinned != _pinned.value) {
_pinned.value = canonicalPinned
saveSet(PINNED_KEY, canonicalPinned)
}
if (canonicalMuted != _muted.value) {
_muted.value = canonicalMuted
saveSet(MUTED_KEY, canonicalMuted)
}
if (canonicalDrafts != _drafts.value) {
val bounded = boundDrafts(canonicalDrafts)
_drafts.value = bounded
saveDrafts(bounded)
}
}
fun clearInMemory() {
_pinned.value = emptySet()
_muted.value = emptySet()
_drafts.value = emptyMap()
}
fun clearAll(): Boolean {
val cleared = stateManager.clearSecureValuesSynchronously(
PINNED_KEY,
MUTED_KEY,
DRAFTS_KEY
)
clearInMemory()
return cleared
}
private fun loadSet(key: String): Set<String> = runCatching {
val array = JSONArray(stateManager.getSecureValue(key) ?: return emptySet())
buildSet {
for (index in 0 until array.length()) add(normalize(array.getString(index)))
}
}.getOrDefault(emptySet())
private fun saveSet(key: String, values: Set<String>) {
stateManager.storeSecureValue(key, JSONArray(values.sorted()).toString())
}
private fun loadDrafts(): Map<String, String> = runCatching {
val json = JSONObject(stateManager.getSecureValue(DRAFTS_KEY) ?: return emptyMap())
val loaded = buildMap {
json.keys().forEach { key ->
json.optString(key).takeIf(String::isNotBlank)?.let {
put(normalize(key), it.take(MAX_DRAFT_CHARS))
}
}
}
boundDrafts(loaded)
}.getOrDefault(emptyMap())
private fun saveDrafts(values: Map<String, String>) {
stateManager.storeSecureValue(
DRAFTS_KEY,
JSONObject().apply {
values.forEach { (key, value) -> put(key, value) }
}.toString()
)
}
private fun Set<String>.toggle(value: String): Set<String> =
if (value in this) this - value else this + value
private fun normalize(value: String): String =
canonicalize(value).lowercase()
private fun boundDrafts(values: Map<String, String>): Map<String, String> {
val retained = LinkedHashMap(values)
while (
retained.size > MAX_DRAFTS ||
retained.values.sumOf(String::length) > MAX_DRAFT_CHARS_TOTAL
) {
val oldest = retained.keys.firstOrNull() ?: break
retained.remove(oldest)
}
return retained
}
}

View File

@ -0,0 +1,98 @@
package com.bitchat.android.services
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Encrypts private-conversation payloads with a key that never leaves Android Keystore.
*
* Every value is bound to its database identity through AES-GCM associated data. This prevents an
* encrypted payload copied from one message or conversation row from being accepted in another.
* Deleting the dedicated alias provides practical cryptographic erasure before SQLite pages, WAL
* records, and filesystem blocks are reclaimed.
*/
internal interface ConversationStorageCipher {
fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray
fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray
fun destroyKey()
}
internal class AndroidConversationStorageCipher(
private val keyAlias: String = DEFAULT_KEY_ALIAS
) : ConversationStorageCipher {
companion object {
internal const val DEFAULT_KEY_ALIAS = "bitchat_conversation_storage_v1"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val ENVELOPE_VERSION: Byte = 1
private const val GCM_TAG_BITS = 128
private const val IV_BYTES = 12
}
private val keyLock = Any()
@Volatile
private var cachedKey: SecretKey? = null
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
cipher.updateAAD(associatedData)
val ciphertext = cipher.doFinal(plaintext)
check(cipher.iv.size == IV_BYTES) { "Unexpected AES-GCM IV length" }
return byteArrayOf(ENVELOPE_VERSION) + cipher.iv + ciphertext
}
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray {
require(envelope.size > 1 + IV_BYTES) { "Conversation payload envelope is truncated" }
require(envelope[0] == ENVELOPE_VERSION) {
"Unsupported conversation payload envelope version"
}
val iv = envelope.copyOfRange(1, 1 + IV_BYTES)
val ciphertext = envelope.copyOfRange(1 + IV_BYTES, envelope.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(associatedData)
return cipher.doFinal(ciphertext)
}
override fun destroyKey() {
synchronized(keyLock) {
cachedKey = null
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
if (keyStore.containsAlias(keyAlias)) {
keyStore.deleteEntry(keyAlias)
}
}
}
private fun getOrCreateKey(): SecretKey =
cachedKey ?: synchronized(keyLock) {
cachedKey ?: run {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey) ?: generateKey()
}.also { cachedKey = it }
}
private fun generateKey(): SecretKey {
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
generator.init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.build()
)
return generator.generateKey()
}
}

View File

@ -1,6 +1,7 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.runBlocking
/**
* Reflects an incoming transport message into process-wide state before any downstream effects.
@ -15,7 +16,12 @@ internal object IncomingMessageAdmission {
message.isPrivate -> {
val peerID = message.senderPeerID?.takeIf(String::isNotBlank)
?: return false
AppStateStore.addPrivateMessage(peerID, message)
// Mesh transport callbacks run on their background service workers. Wait for the
// serialized SQLite transaction so a notification can never advertise a message
// that an immediate process death would lose.
runBlocking {
AppStateStore.addPrivateMessageDurably(peerID, message)
}
}
message.channel != null -> {

View File

@ -11,25 +11,45 @@ import com.bitchat.android.model.BitchatMessage
*/
internal object PrivateMessageArrivalOrder {
private val sequenceByMessageID = mutableMapOf<String, Long>()
private val receivedAtByMessageID = mutableMapOf<String, Long>()
private var nextSequence = 0L
fun record(messageID: String) {
fun record(messageID: String, receivedAt: Long = System.currentTimeMillis()) {
synchronized(this) {
if (messageID !in sequenceByMessageID) {
sequenceByMessageID[messageID] = nextSequence++
receivedAtByMessageID[messageID] = receivedAt
}
}
}
fun restore(persistedOrder: List<String>, liveMessageIDs: List<String>) {
fun restore(
persistedOrder: List<String>,
liveMessageIDs: List<String>,
persistedReceivedAt: Map<String, Long> = emptyMap(),
persistedSequences: Map<String, Long> = emptyMap()
) {
synchronized(this) {
val previousSequences = sequenceByMessageID.toMap()
val previousReceivedAt = receivedAtByMessageID.toMap()
sequenceByMessageID.clear()
nextSequence = 0L
receivedAtByMessageID.clear()
nextSequence = (persistedSequences.values.maxOrNull() ?: -1L) + 1L
(persistedOrder + liveMessageIDs).forEach { messageID ->
if (messageID !in sequenceByMessageID) {
sequenceByMessageID[messageID] = nextSequence++
val sequence = persistedSequences[messageID]
?: previousSequences[messageID]
?: nextSequence++
sequenceByMessageID[messageID] = sequence
(persistedReceivedAt[messageID] ?: previousReceivedAt[messageID])?.let {
receivedAtByMessageID[messageID] = it
}
}
}
nextSequence = maxOf(
nextSequence,
(sequenceByMessageID.values.maxOrNull() ?: -1L) + 1L
)
}
}
@ -37,6 +57,10 @@ internal object PrivateMessageArrivalOrder {
sequenceByMessageID[messageID]
}
fun receivedAtOf(messageID: String): Long? = synchronized(this) {
receivedAtByMessageID[messageID]
}
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
synchronized(this) {
if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) {
@ -49,6 +73,7 @@ internal object PrivateMessageArrivalOrder {
fun clear() {
synchronized(this) {
sequenceByMessageID.clear()
receivedAtByMessageID.clear()
nextSequence = 0L
}
}

View File

@ -98,6 +98,14 @@ fun ChatScreen(viewModel: ChatViewModel) {
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
LaunchedEffect(selectedPrivatePeer) {
messageText = TextFieldValue(
selectedPrivatePeer
?.let(viewModel::conversationDraft)
.orEmpty()
)
}
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt
@ -356,14 +364,19 @@ fun ChatScreen(viewModel: ChatViewModel) {
messageText = messageText,
onMessageTextChange = { newText: TextFieldValue ->
messageText = newText
viewModel.setConversationDraft(selectedPrivatePeer, newText.text)
viewModel.updateCommandSuggestions(newText.text)
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.text.trim())
messageText = TextFieldValue("")
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
viewModel.sendMessage(messageText.text.trim()) { accepted ->
if (accepted) {
messageText = TextFieldValue("")
viewModel.setConversationDraft(selectedPrivatePeer, "")
forceScrollToBottom = !forceScrollToBottom
}
}
}
},
onSendVoiceNote = { peer, onionOrChannel, path ->

View File

@ -5,6 +5,7 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesChangeListener
import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
@ -38,6 +41,12 @@ import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
val peerNicknames: Map<String, String>,
val persistedDisplayNames: Map<String, String>
)
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
@ -58,6 +67,7 @@ class ChatViewModel(
companion object {
private const val TAG = "ChatViewModel"
private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L
}
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
@ -109,6 +119,8 @@ class ChatViewModel(
private val seenMessageStore by lazy {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
}
private val conversationListPreferences =
com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication())
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@ -131,7 +143,13 @@ class ChatViewModel(
seenMessageStore.markReadLocally(messageID)
}
)
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val commandProcessor = CommandProcessor(
state,
messageManager,
channelManager,
privateChatManager,
viewModelScope
)
private val notificationManager = NotificationManager(
application.applicationContext,
NotificationManagerCompat.from(application.applicationContext),
@ -193,19 +211,62 @@ 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 conversations: StateFlow<List<ConversationSummary>> = combine(
internal val conversationStoreState =
com.bitchat.android.services.AppStateStore.conversationStoreState
private val conversationPresencePeers = MutableStateFlow<List<String>>(emptyList())
private val conversationPresenceRemovalJobs = mutableMapOf<String, Job>()
private val conversationDirectoryRevision = MutableStateFlow(0L)
private var favoriteRelationshipListenerRegistered = false
private val favoriteRelationshipChangeListener = object : FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) {
refreshConversationDirectoryState()
}
override fun onAllCleared() {
refreshConversationDirectoryState()
}
}
private fun refreshConversationDirectoryState() {
viewModelScope.launch {
refreshPeerFavoritedUs()
conversationListPreferences.canonicalizeAliases()
conversationDirectoryRevision.update { it + 1L }
}
}
private val conversationLiveIdentityState = combine(
conversationPresencePeers,
state.peerNicknames,
state.peerFingerprints,
conversationDirectoryRevision,
com.bitchat.android.services.AppStateStore.privateConversationDisplayNames
) { connectedPeerIDs, peerNicknames, _, _, persistedDisplayNames ->
ConversationLiveIdentityState(
connectedPeerIDs = connectedPeerIDs,
peerNicknames = peerNicknames,
persistedDisplayNames = persistedDisplayNames
.mapKeys { (conversationID, _) -> conversationID.lowercase() }
)
}
private val baseConversations = combine(
state.unreadPrivateMessages,
state.privateChats,
state.nickname,
state.connectedPeers
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
conversationLiveIdentityState,
com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts
) { unreadConversationIDs, chats, currentNickname, liveIdentity, unreadCounts ->
val seenStore = seenMessageStore
val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID ->
runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.canonicalConversationId(peerID)
}.getOrDefault(setOf(peerID))
.mapTo(mutableSetOf()) { it.lowercase() }
val connectedPeerByIdentity = buildMap {
liveIdentity.connectedPeerIDs.forEach { peerID ->
val identities = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.canonicalConversationId(peerID)
}.getOrDefault(setOf(peerID))
identities.forEach { identity ->
putIfAbsent(identity.lowercase(), peerID)
}
}
}
buildConversationSummaries(
unreadConversationIDs = unreadConversationIDs,
@ -215,7 +276,8 @@ class ChatViewModel(
isMessageRead = { message ->
com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) ||
seenStore.hasBeenReadLocally(message.id)
}
},
persistedUnreadCounts = unreadCounts
).map { summary ->
val resolution = ContactDirectory.resolve(summary.conversationID)
val resolvedNostrPubkey = summary.nostrPubkey
@ -230,19 +292,31 @@ class ChatViewModel(
?.let(ContactIdentityResolver::nostrAliasForPubkey)
?.let(::add)
}.mapTo(mutableSetOf()) { it.lowercase() }
val connectedPeerID = connectedIdentitiesByPeer.entries
.firstOrNull { (_, connectedAliases) ->
connectedAliases.any(aliases::contains)
}
?.key
val connectedPeerID = aliases
.asSequence()
.mapNotNull(connectedPeerByIdentity::get)
.firstOrNull()
val persistedDisplayName = liveIdentity.persistedDisplayNames[
summary.conversationID.lowercase()
] ?: aliases
.asSequence()
.mapNotNull(liveIdentity.persistedDisplayNames::get)
.firstOrNull()
summary.copy(
displayName = resolution.displayName
?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
?: summary.displayName,
displayName = resolveConversationDisplayName(
fallbackName = summary.displayName,
connectedPeerID = connectedPeerID,
peerNicknames = liveIdentity.peerNicknames,
resolvedContactName = resolution.displayName,
persistedDisplayName = persistedDisplayName
),
nostrPubkey = resolvedNostrPubkey,
transport = if (resolvedNostrPubkey != null) {
DirectMessageTransport.NOSTR
} else {
summary.transport
},
identityAliases = aliases,
isConnected = connectedPeerID != null,
connectedPeerID = connectedPeerID,
@ -251,7 +325,25 @@ class ChatViewModel(
.mapNotNull(GeohashConversationRegistry::get)
.firstOrNull()
)
}.let(::sortConversationSummaries)
}
}
internal val conversations: StateFlow<List<ConversationSummary>> = combine(
baseConversations,
conversationListPreferences.pinned,
conversationListPreferences.muted,
conversationListPreferences.drafts
) { summaries, pinned, muted, drafts ->
sortConversationSummaries(
summaries.map { summary ->
val key = summary.conversationID.lowercase()
summary.copy(
isPinned = key in pinned,
isMuted = key in muted,
draft = drafts[key]
)
}
)
}
.flowOn(Dispatchers.IO)
.stateIn(
@ -305,10 +397,12 @@ class ChatViewModel(
}
init {
observeConversationPresenceWithDisconnectGrace()
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
observeConversationDisplayNames()
// 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.
@ -338,7 +432,12 @@ class ChatViewModel(
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
try {
combine(
com.bitchat.android.services.AppStateStore.privateMessages,
com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts
) { byPeer, unreadCounts -> byPeer to unreadCounts }
.collect { (byPeer, unreadCounts) ->
val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) {
val canonical = ContactDirectory.canonicalizePrivateChats(byPeer)
val unread = try {
@ -353,7 +452,9 @@ class ChatViewModel(
!seenMessageStore.hasBeenReadLocally(message.id)
}
}
.keys
.keys + unreadCounts
.filterValues { it > 0 }
.keys
} catch (_: Exception) {
state.getUnreadPrivateMessagesValue()
}
@ -380,6 +481,60 @@ class ChatViewModel(
// Removed background location notes subscription. Notes now load only when sheet opens.
}
/**
* Mesh discovery can briefly drop a peer while transports hand over. Preserve its online
* treatment for a short grace window to keep conversation rows from jumping between sections.
* New connections still appear immediately.
*/
private fun observeConversationPresenceWithDisconnectGrace() {
viewModelScope.launch {
state.connectedPeers.collect { connected ->
val current = connected.toSet()
current.forEach { peerID ->
conversationPresenceRemovalJobs.remove(peerID)?.cancel()
}
val displayed = conversationPresencePeers.value.toMutableList()
connected.forEach { peerID ->
if (peerID !in displayed) displayed.add(peerID)
}
if (displayed != conversationPresencePeers.value) {
conversationPresencePeers.value = displayed
}
(displayed.toSet() - current).forEach { peerID ->
if (peerID in conversationPresenceRemovalJobs) return@forEach
conversationPresenceRemovalJobs[peerID] = launch {
delay(CONVERSATION_DISCONNECT_GRACE_MS)
if (peerID !in state.connectedPeers.value) {
conversationPresencePeers.value =
conversationPresencePeers.value - peerID
}
conversationPresenceRemovalJobs.remove(peerID)
}
}
}
}
}
private fun observeConversationDisplayNames() {
viewModelScope.launch {
combine(
state.peerNicknames,
state.connectedPeers,
state.peerFingerprints
) { peerNicknames, connectedPeers, _ ->
connectedPeers.mapNotNull { peerID ->
peerNicknames[peerID]?.let { peerID to it }
}.toMap()
}.collect { connectedNames ->
conversationListPreferences.canonicalizeAliases()
com.bitchat.android.services.AppStateStore
.updatePrivateConversationDisplayNames(connectedNames)
}
}
}
fun cancelMediaSend(messageId: String) {
// Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state
mediaSendingManager.cancelMediaSend(messageId)
@ -443,11 +598,9 @@ class ChatViewModel(
refreshPeerFavoritedUs()
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(
object : com.bitchat.android.favorites.FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs()
override fun onAllCleared() = refreshPeerFavoritedUs()
}
favoriteRelationshipChangeListener
)
favoriteRelationshipListenerRegistered = true
} catch (_: Exception) { }
// Load verified fingerprints from secure storage
@ -466,6 +619,14 @@ class ChatViewModel(
}
override fun onCleared() {
if (favoriteRelationshipListenerRegistered) {
runCatching {
FavoritesPersistenceService.shared.removeListener(
favoriteRelationshipChangeListener
)
}
favoriteRelationshipListenerRegistered = false
}
geohashViewModel.shutdownUiSubscriptions()
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null)
// Note: Mesh service lifecycle is now managed by MainActivity
@ -511,6 +672,13 @@ class ChatViewModel(
val (conversationID, success) = withContext(Dispatchers.IO) {
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
com.bitchat.android.services.AppStateStore
.loadPrivateConversationHistory(canonicalID)
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
val unreadAliases = matchingUnreadAliases(
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
canonicalConversationID = canonicalID,
@ -531,7 +699,17 @@ class ChatViewModel(
}
fun endPrivateChat() {
val conversationID = state.getSelectedPrivateChatPeerValue()
privateChatManager.endPrivateChat()
if (conversationID != null) {
com.bitchat.android.services.AppStateStore
.releasePrivateConversationHistory(conversationID)
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
}
// Notify notification manager that no private chat is active
setCurrentPrivateChatPeer(null)
// Clear mesh mention notifications since user is now back in mesh chat
@ -540,31 +718,27 @@ class ChatViewModel(
hidePrivateChatSheet()
}
fun deletePrivateConversation(peerOrConversationID: String) {
internal suspend fun deletePrivateConversation(
peerOrConversationID: String
): com.bitchat.android.services.DeletedPrivateConversation? {
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
val deletedMessages = state.getPrivateChatsValue()
.filterKeys { key ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
.values
.flatten()
val wasPinned = conversationListPreferences.isPinned(canonicalID)
val wasMuted = conversationListPreferences.isMuted(canonicalID)
val draft = conversationListPreferences.draftFor(canonicalID)
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
)
}
val deletion = withContext(Dispatchers.IO) {
com.bitchat.android.services.AppStateStore
.deletePrivateConversationAndWait(canonicalID)
}?.copy(
wasPinned = wasPinned,
wasMuted = wasMuted,
draft = draft
) ?: return null
conversationListPreferences.removeConversation(canonicalID)
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
@ -574,7 +748,7 @@ class ChatViewModel(
state.setUnreadPrivateMessages(
state.getUnreadPrivateMessagesValue() - unreadAliases
)
seenMessageStore.remove(deletedMessageIDs)
seenMessageStore.remove(deletion.messageIDs)
val selected = state.getSelectedPrivateChatPeerValue()
if (
@ -594,6 +768,81 @@ class ChatViewModel(
hidePrivateChatSheet()
}
clearNotificationsForSender(canonicalID)
notificationManager.removeConversationShortcut(canonicalID)
return deletion
}
internal suspend fun restoreDeletedConversation(
deletion: com.bitchat.android.services.DeletedPrivateConversation
): Boolean {
val restored = withContext(Dispatchers.IO) {
com.bitchat.android.services.AppStateStore
.restoreDeletedConversation(deletion)
}
if (!restored) return false
if (deletion.wasPinned != conversationListPreferences.isPinned(deletion.conversationID)) {
conversationListPreferences.togglePinned(deletion.conversationID)
}
if (deletion.wasMuted != conversationListPreferences.isMuted(deletion.conversationID)) {
conversationListPreferences.toggleMuted(deletion.conversationID)
}
deletion.draft?.let {
conversationListPreferences.setDraft(deletion.conversationID, it)
}
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
if (deletion.unreadMessageCount > 0) {
state.setUnreadPrivateMessages(
state.getUnreadPrivateMessagesValue() + deletion.conversationID
)
}
return true
}
internal suspend fun setConversationRead(
conversationID: String,
isRead: Boolean
): Boolean {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val updated = withContext(Dispatchers.IO) {
com.bitchat.android.services.AppStateStore
.setPrivateConversationRead(canonicalID, isRead)
}
if (!updated) return false
state.setUnreadPrivateMessages(
if (isRead) {
state.getUnreadPrivateMessagesValue().filterNotTo(mutableSetOf()) {
ContactDirectory.canonicalConversationId(it)
.equals(canonicalID, ignoreCase = true)
}
} else {
state.getUnreadPrivateMessagesValue() + canonicalID
}
)
return true
}
internal fun toggleConversationPinned(conversationID: String) {
conversationListPreferences.togglePinned(conversationID)
}
internal fun toggleConversationMuted(conversationID: String) {
conversationListPreferences.toggleMuted(conversationID)
}
internal fun conversationDraft(conversationID: String?): String =
conversationID
?.let(ContactDirectory::canonicalConversationId)
?.lowercase()
?.let(conversationListPreferences.drafts.value::get)
.orEmpty()
internal fun setConversationDraft(conversationID: String?, text: String) {
if (conversationID.isNullOrBlank()) return
conversationListPreferences.setDraft(conversationID, text)
}
// MARK: - Open Latest Unread Private Chat
@ -652,8 +901,14 @@ class ChatViewModel(
// MARK: - Message Sending
fun sendMessage(content: String) {
if (content.isEmpty()) return
fun sendMessage(
content: String,
onAccepted: (Boolean) -> Unit = {}
) {
if (content.isEmpty()) {
onAccepted(false)
return
}
// Check for commands
if (content.startsWith("/")) {
@ -671,6 +926,7 @@ class ChatViewModel(
mesh.sendMessage(messageContent, mentions, channel)
}
}, this)
onAccepted(true)
return
}
@ -699,18 +955,33 @@ class ChatViewModel(
}
// Send private message
val recipientNickname = nicknameForPeer(selectedPeer)
privateChatManager.sendPrivateMessage(
content,
selectedPeer,
recipientNickname,
state.getNicknameValue(),
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent)
val destination = selectedPeer
viewModelScope.launch {
val accepted = privateChatManager.sendPrivateMessageDurably(
content,
destination,
recipientNickname,
state.getNicknameValue(),
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
val router = com.bitchat.android.services.MessageRouter.getInstance(
getApplication(),
mesh
)
val route = router.sendPrivate(
messageContent,
peerID,
recipientNicknameParam,
messageId
)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(
messageId,
com.bitchat.android.model.DeliveryStatus.Sent
)
}
}
onAccepted(accepted)
}
} else {
// Check if we're in a location channel
@ -756,6 +1027,7 @@ class ChatViewModel(
mesh.sendMessage(content, mentions, null)
}
}
onAccepted(true)
}
}
@ -1159,6 +1431,7 @@ class ChatViewModel(
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
conversationListPreferences.clearAll()
// Clear seen message store
try {
@ -1169,7 +1442,7 @@ class ChatViewModel(
clearAllCryptographicData()
// Clear all notifications
notificationManager.clearAllNotifications()
notificationManager.clearAllNotifications(removeConversationShortcuts = true)
// Clear all media files
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())

View File

@ -4,6 +4,8 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Handles processing of IRC-style commands
@ -12,7 +14,8 @@ class CommandProcessor(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val privateChatManager: PrivateChatManager
private val privateChatManager: PrivateChatManager,
private val coroutineScope: CoroutineScope? = null
) {
// Available commands list
@ -90,15 +93,15 @@ class CommandProcessor(
if (parts.size > 2) {
val messageContent = parts.drop(2).joinToString(" ")
val recipientNickname = getPeerNickname(peerID, meshService)
privateChatManager.sendPrivateMessage(
sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService)
) { content, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
}
getMyPeerID(meshService),
meshService,
viewModel
)
} else {
val systemMessage = BitchatMessage(
sender = "system",
@ -303,15 +306,15 @@ class CommandProcessor(
// Send as regular message
if (state.getSelectedPrivateChatPeerValue() != null) {
val peerID = state.getSelectedPrivateChatPeerValue()!!
privateChatManager.sendPrivateMessage(
sendPrivateMessage(
actionMessage,
peerID,
getPeerNickname(peerID, meshService),
state.getNicknameValue(),
myPeerID
) { content, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
}
myPeerID,
meshService,
viewModel
)
} else if (isInLocationChannel) {
// Let the transport layer add the echo; just send it out
onSendMessage(actionMessage, emptyList(), null)
@ -527,7 +530,51 @@ class CommandProcessor(
private fun getMyPeerID(meshService: MeshService): String {
return meshService.myPeerID
}
private fun sendPrivateMessage(
content: String,
peerID: String,
recipientNickname: String?,
senderNickname: String?,
myPeerID: String,
meshService: MeshService,
viewModel: ChatViewModel?
) {
val send: (String, String, String, String) -> Unit =
{ messageContent, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(
meshService,
messageContent,
peerIdParam,
recipientNicknameParam,
messageId,
viewModel
)
}
val scope = coroutineScope
if (scope == null) {
privateChatManager.sendPrivateMessage(
content,
peerID,
recipientNickname,
senderNickname,
myPeerID,
send
)
} else {
scope.launch {
privateChatManager.sendPrivateMessageDurably(
content,
peerID,
recipientNickname,
senderNickname,
myPeerID,
send
)
}
}
}
private fun sendPrivateMessageVia(
meshService: MeshService,
content: String,

View File

@ -2,6 +2,7 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.PrivateMessageArrivalOrder
/**
@ -15,12 +16,17 @@ internal data class ConversationSummary(
val latestActivityOrder: Long,
val latestMessageType: BitchatMessageType,
val latestMessagePreview: String,
val latestMessageIsOutgoing: Boolean = false,
val latestDeliveryStatus: DeliveryStatus? = null,
val transport: DirectMessageTransport,
val nostrPubkey: String?,
val identityAliases: Set<String>,
val isConnected: Boolean = false,
val connectedPeerID: String? = null,
val sourceGeohash: String? = null
val sourceGeohash: String? = null,
val isPinned: Boolean = false,
val isMuted: Boolean = false,
val draft: String? = null
)
internal fun buildConversationSummaries(
@ -28,27 +34,38 @@ internal fun buildConversationSummaries(
privateChats: Map<String, List<BitchatMessage>>,
currentUserIdentifiers: Set<String>,
canonicalize: (String) -> String,
isMessageRead: (BitchatMessage) -> Boolean
isMessageRead: (BitchatMessage) -> Boolean,
persistedUnreadCounts: Map<String, Int> = emptyMap()
): List<ConversationSummary> {
if (privateChats.isEmpty()) return emptyList()
val currentUsers = currentUserIdentifiers.filterTo(mutableSetOf()) { it.isNotBlank() }
val currentUsers = currentUserIdentifiers
.filter(String::isNotBlank)
.mapTo(mutableSetOf()) { it.lowercase() }
val unreadCanonicalIDs = unreadConversationIDs
.mapTo(mutableSetOf()) { canonicalize(it).lowercase() }
val unreadCountsByCanonicalID = persistedUnreadCounts.entries
.groupingBy { canonicalize(it.key).lowercase() }
.fold(0) { total, entry -> total + entry.value }
val aliasesByCanonicalID = linkedMapOf<String, MutableSet<String>>()
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
val displayCanonicalIDByNormalized = linkedMapOf<String, String>()
privateChats.forEach { (sourceID, messages) ->
val canonicalID = canonicalize(sourceID)
val normalizedID = canonicalID.lowercase()
displayCanonicalIDByNormalized.putIfAbsent(normalizedID, canonicalID)
aliasesByCanonicalID
.getOrPut(canonicalID) { linkedSetOf() }
.getOrPut(normalizedID) { linkedSetOf() }
.add(sourceID)
messagesByCanonicalID
.getOrPut(canonicalID) { mutableListOf() }
.getOrPut(normalizedID) { mutableListOf() }
.addAll(messages)
}
return messagesByCanonicalID.mapNotNull { (conversationID, sourceMessages) ->
return messagesByCanonicalID.mapNotNull { (normalizedConversationID, sourceMessages) ->
val conversationID =
displayCanonicalIDByNormalized.getValue(normalizedConversationID)
val messages = sourceMessages.distinctBy { it.id }
if (messages.isEmpty()) return@mapNotNull null
@ -58,19 +75,27 @@ internal fun buildConversationSummaries(
val latest = messages.maxWithOrNull(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
) ?: return@mapNotNull null
fun isOutgoing(message: BitchatMessage): Boolean =
message.sender.lowercase() in currentUsers ||
message.senderPeerID?.lowercase() in currentUsers
val incoming = messages.filterNot {
it.sender in currentUsers || it.sender == "system"
isOutgoing(it) || 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 persistedUnreadCount = unreadCountsByCanonicalID[conversationID.lowercase()] ?: 0
val unreadCount = maxOf(
persistedUnreadCount,
if (canonicalUnread && incoming.isNotEmpty()) {
incoming.count { !isMessageRead(it) }.coerceAtLeast(1)
} else {
0
}
)
val aliases = aliasesByCanonicalID[normalizedConversationID].orEmpty()
val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey
val isNostrConversation = nostrPubkey != null ||
aliases.any(::isNostrConversationKey) ||
@ -89,10 +114,13 @@ internal fun buildConversationSummaries(
conversationID = conversationID,
displayName = displayName,
unreadCount = unreadCount,
latestMessageAt = latest.timestamp.time,
latestMessageAt =
PrivateMessageArrivalOrder.receivedAtOf(latest.id) ?: latest.timestamp.time,
latestActivityOrder = activityOrder(latest),
latestMessageType = latest.type,
latestMessagePreview = latest.conversationPreview(),
latestMessageIsOutgoing = isOutgoing(latest),
latestDeliveryStatus = latest.deliveryStatus,
transport = if (isNostrConversation) {
DirectMessageTransport.NOSTR
} else {
@ -105,10 +133,36 @@ internal fun buildConversationSummaries(
}
}
internal fun resolveConversationDisplayName(
fallbackName: String,
connectedPeerID: String?,
peerNicknames: Map<String, String>,
resolvedContactName: String?,
persistedDisplayName: String?
): String {
fun String?.usableName(): String? = this?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
val liveName = connectedPeerID?.let { peerID ->
peerNicknames[peerID]
?: peerNicknames.entries
.firstOrNull { (candidateID, _) ->
candidateID.equals(peerID, ignoreCase = true)
}
?.value
}
return liveName.usableName()
?: resolvedContactName.usableName()
?: persistedDisplayName.usableName()
?: fallbackName
}
internal fun sortConversationSummaries(
conversations: List<ConversationSummary>
): List<ConversationSummary> = conversations.sortedWith(
compareByDescending<ConversationSummary> { it.isConnected }
.thenByDescending { it.isPinned }
.thenByDescending { it.unreadCount > 0 }
.thenByDescending { it.latestActivityOrder }
.thenBy { it.displayName.lowercase() }

View File

@ -441,7 +441,7 @@ class MediaSendingManager(
}
}
private fun handlePrivatePreparation(
private suspend fun handlePrivatePreparation(
preparation: PrivateMediaPreparation,
pending: PendingAutomaticPrivateMedia
) {
@ -599,7 +599,7 @@ class MediaSendingManager(
}
}
private fun commitPreparedPrivateFile(
private suspend fun commitPreparedPrivateFile(
preparation: PrivateMediaPreparation.Ready,
conversationID: String,
recipientMeshPeerID: String,
@ -630,7 +630,14 @@ class MediaSendingManager(
// Preparation already built and admitted the exact final packet. Map
// progress before commit so the first asynchronous event cannot race us.
messageManager.addPrivateMessage(conversationID, msg)
if (!messageManager.addPrivateMessageDurably(conversationID, msg, forceRead = true)) {
Log.e(TAG, "Prepared private-media message could not be persisted; send aborted")
addPrivateMediaSystemMessage(
conversationID,
"Private media was not sent because the conversation could not be saved."
)
return
}
synchronized(transferMessageMap) {
transferMessageMap[transferId] = msg.id
messageTransferMap[msg.id] = transferId
@ -641,12 +648,17 @@ class MediaSendingManager(
)
if (!preparation.transfer.commit()) {
messageManager.removeMessageById(msg.id)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(msg.id)
}
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back")
messageManager.updateMessageDeliveryStatus(
msg.id,
com.bitchat.android.model.DeliveryStatus.Failed(
"Prepared transfer could not be committed"
)
)
Log.w(TAG, "Prepared private-media commit failed; local echo marked failed")
addPrivateMediaSystemMessage(
conversationID,
"Private media was not sent because the prepared transfer could not be committed."

File diff suppressed because it is too large Load Diff

View File

@ -111,6 +111,40 @@ class MessageManager(private val state: ChatState) {
false
}
if (!accepted) return
publishAcceptedPrivateMessage(conversationID, message, forceRead = false)
}
/**
* Adds a private message only after its database transaction has completed.
*
* Outgoing sends and non-mesh transports use this path so the local echo is never shown or
* transmitted unless it can survive an immediate process death.
*/
suspend fun addPrivateMessageDurably(
peerID: String,
message: BitchatMessage,
forceRead: Boolean = false
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val accepted = try {
com.bitchat.android.services.AppStateStore.addPrivateMessageDurably(
peerID = conversationID,
msg = message,
forceRead = forceRead
)
} catch (_: Exception) {
false
}
if (!accepted) return false
publishAcceptedPrivateMessage(conversationID, message, forceRead)
return true
}
private fun publishAcceptedPrivateMessage(
conversationID: String,
message: BitchatMessage,
forceRead: Boolean
) {
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
@ -125,6 +159,7 @@ class MessageManager(private val state: ChatState) {
val selectedConversationID = state.getSelectedPrivateChatPeerValue()
?.let(ContactDirectory::canonicalConversationId)
if (
!forceRead &&
selectedConversationID != conversationID &&
message.sender != state.getNicknameValue()
) {
@ -147,14 +182,7 @@ class MessageManager(private val state: ChatState) {
false
}
if (!accepted) return
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
}
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
publishAcceptedPrivateMessage(conversationID, message, forceRead = true)
}
fun clearPrivateMessages(peerID: String) {
@ -259,12 +287,19 @@ class MessageManager(private val state: ChatState) {
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
is DeliveryStatus.Failed -> 0
}
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
return if (statusPriority(new) >= statusPriority(old)) new else old
// A send failure may replace an in-flight state, but never a confirmed delivery/read.
return when {
new is DeliveryStatus.Failed &&
old !is DeliveryStatus.Delivered &&
old !is DeliveryStatus.Read -> new
old is DeliveryStatus.Failed -> new
statusPriority(new) >= statusPriority(old) -> new
else -> old
}
}
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {

View File

@ -1,19 +1,31 @@
package com.bitchat.android.ui
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager as AndroidNotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.content.LocusIdCompat
import androidx.core.graphics.drawable.IconCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.service.ConversationNotificationReceiver
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ConversationListPreferences
import com.bitchat.android.util.NotificationIntervalManager
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
/**
@ -43,6 +55,7 @@ class NotificationManager(
private const val SUMMARY_NOTIFICATION_ID = 999
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
private const val ACTIVE_PEERS_NOTIFICATION_ID = 997
private const val MAX_MESSAGES_IN_NOTIFICATION = 25
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS
// Intent extras for notification handling
@ -51,9 +64,33 @@ class NotificationManager(
const val EXTRA_PEER_ID = "peer_id"
const val EXTRA_SENDER_NICKNAME = "sender_nickname"
const val EXTRA_GEOHASH = "geohash"
const val ACTION_REPLY_TO_CONVERSATION =
"com.bitchat.android.action.REPLY_TO_CONVERSATION"
const val ACTION_MARK_CONVERSATION_READ =
"com.bitchat.android.action.MARK_CONVERSATION_READ"
const val KEY_TEXT_REPLY = "conversation_reply_text"
private val liveManagers: MutableSet<NotificationManager> =
Collections.newSetFromMap(WeakHashMap<NotificationManager, Boolean>())
/**
* Synchronizes notification action receivers with every manager instance in this process.
* Without this, an old in-memory MessagingStyle history could reappear on the next DM.
*/
fun acknowledgeConversation(context: Context, conversationID: String) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val managers = synchronized(liveManagers) { liveManagers.toList() }
managers.forEach { it.clearNotificationsForSender(canonicalID) }
if (managers.isEmpty()) {
NotificationManagerCompat.from(context).cancel(canonicalID.hashCode())
}
}
}
private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val systemNotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as AndroidNotificationManager
private val conversationPreferences =
ConversationListPreferences.getInstance(context.applicationContext)
// Track pending notifications per sender to enable grouping
private val pendingNotifications = ConcurrentHashMap<String, MutableList<PendingNotification>>()
@ -88,15 +125,17 @@ class NotificationManager(
)
init {
synchronized(liveManagers) { liveManagers.add(this) }
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// DM notifications channel
val dmName = "Direct Messages"
val dmDescriptionText = "Notifications for private messages from other users"
val dmImportance = NotificationManager.IMPORTANCE_HIGH
val dmName = context.getString(R.string.notification_channel_direct_messages)
val dmDescriptionText =
context.getString(R.string.notification_channel_direct_messages_description)
val dmImportance = AndroidNotificationManager.IMPORTANCE_HIGH
val dmChannel = NotificationChannel(CHANNEL_ID, dmName, dmImportance).apply {
description = dmDescriptionText
enableVibration(true)
@ -105,9 +144,10 @@ class NotificationManager(
systemNotificationManager.createNotificationChannel(dmChannel)
// Geohash notifications channel
val geohashName = "Geohash Chats"
val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels"
val geohashImportance = NotificationManager.IMPORTANCE_HIGH
val geohashName = context.getString(R.string.notification_channel_geohash)
val geohashDescriptionText =
context.getString(R.string.notification_channel_geohash_description)
val geohashImportance = AndroidNotificationManager.IMPORTANCE_HIGH
val geohashChannel = NotificationChannel(GEOHASH_CHANNEL_ID, geohashName, geohashImportance).apply {
description = geohashDescriptionText
enableVibration(true)
@ -146,6 +186,10 @@ class NotificationManager(
*/
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
if (conversationPreferences.isMuted(conversationID)) {
Log.d(TAG, "Skipping muted conversation notification")
return
}
// Only show notifications if app is in background OR user is not viewing this specific chat
val shouldNotify = isAppInBackground ||
(!isAppInBackground && currentPrivateChatPeer != conversationID)
@ -219,6 +263,12 @@ class NotificationManager(
.setName(latestNotification.senderNickname)
.setKey(senderPeerID)
.build()
val shortcutID = conversationShortcutID(senderPeerID)
publishConversationShortcut(
shortcutID = shortcutID,
person = person,
contentIntent = intent
)
// Build notification content
val contentText = if (messageCount == 1) {
@ -242,47 +292,110 @@ class NotificationManager(
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.addPerson(person)
.setShortcutId(shortcutID)
.setLocusId(LocusIdCompat(shortcutID))
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setShowWhen(true)
.setWhen(latestNotification.timestamp)
val markReadIntent = Intent(context, ConversationNotificationReceiver::class.java).apply {
action = ACTION_MARK_CONVERSATION_READ
putExtra(EXTRA_PEER_ID, senderPeerID)
}
val markReadPendingIntent = PendingIntent.getBroadcast(
context,
NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 1,
markReadIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val replyIntent = Intent(context, ConversationNotificationReceiver::class.java).apply {
action = ACTION_REPLY_TO_CONVERSATION
putExtra(EXTRA_PEER_ID, senderPeerID)
putExtra(EXTRA_SENDER_NICKNAME, latestNotification.senderNickname)
}
val replyPendingIntent = PendingIntent.getBroadcast(
context,
NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 2,
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply))
.build()
builder
.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_notification,
context.getString(R.string.notification_mark_read),
markReadPendingIntent
).build()
)
.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_notification,
context.getString(R.string.notification_reply),
replyPendingIntent
)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build()
)
.setPublicVersion(
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_private_message))
.setContentText(context.getString(R.string.notification_content_hidden))
.build()
)
// Add to notification group if we have multiple senders
if (pendingNotifications.size > 1) {
builder.setGroup(GROUP_KEY_DM)
}
// Add style for multiple messages
if (messageCount > 1) {
val style = NotificationCompat.InboxStyle()
.setBigContentTitle(contentTitle)
// Show last few messages in expanded view
notifications.takeLast(5).forEach { notif ->
style.addLine(notif.messageContent)
}
if (messageCount > 5) {
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(
R.plurals.notification_and_more, extra, extra
))
}
builder.setStyle(style)
} else {
// Single message - use BigTextStyle for long messages
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(latestNotification.messageContent)
val self = Person.Builder()
.setName(context.getString(R.string.you))
.setKey("bitchat-self")
.build()
val messagingStyle = NotificationCompat.MessagingStyle(self)
.setGroupConversation(false)
notifications.takeLast(MAX_MESSAGES_IN_NOTIFICATION).forEach { notification ->
messagingStyle.addMessage(
notification.messageContent,
notification.timestamp,
person
)
}
builder.setStyle(messagingStyle)
// Use sender peer ID hash as notification ID to group messages from same sender
val notificationId = senderPeerID.hashCode()
notificationManager.notify(notificationId, builder.build())
notifySafely(notificationId, builder.build())
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
}
private fun conversationShortcutID(conversationID: String): String =
"dm_" + java.util.UUID.nameUUIDFromBytes(
conversationID.lowercase().toByteArray(Charsets.UTF_8)
).toString()
private fun publishConversationShortcut(
shortcutID: String,
person: Person,
contentIntent: Intent
) {
val shortcut = ShortcutInfoCompat.Builder(context, shortcutID)
.setShortLabel(person.name?.toString()?.take(40).orEmpty())
.setLongLived(true)
.setPerson(person)
.setLocusId(LocusIdCompat(shortcutID))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_notification))
.setIntent(Intent(contentIntent).apply { action = Intent.ACTION_VIEW })
.build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@ -311,7 +424,10 @@ class NotificationManager(
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
notifySafely(
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
builder.build()
)
}
private fun showNotificationForActivePeers(peersSize: Int) {
@ -346,7 +462,7 @@ class NotificationManager(
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build())
notifySafely(ACTIVE_PEERS_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID")
}
private fun showSummaryNotification() {
@ -398,7 +514,7 @@ class NotificationManager(
builder.setStyle(style)
notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build())
notifySafely(SUMMARY_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed summary notification for $senderCount senders")
}
@ -431,6 +547,15 @@ class NotificationManager(
Log.d(TAG, "Cleared notifications for conversation: $conversationID")
}
fun removeConversationShortcut(conversationID: String) {
val shortcutIDs = listOf(conversationShortcutID(conversationID))
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIDs)
ShortcutManagerCompat.removeLongLivedShortcuts(
context,
shortcutIDs
)
}
/**
* Show a notification for a geohash message with mention or first message
*/
@ -559,7 +684,7 @@ class NotificationManager(
// Use geohash hash as notification ID to group messages from same geohash
val notificationId = 3000 + geohash.hashCode()
notificationManager.notify(notificationId, builder.build())
notifySafely(notificationId, builder.build())
Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId")
}
@ -626,7 +751,7 @@ class NotificationManager(
builder.setStyle(style)
notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build())
notifySafely(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations")
}
@ -767,7 +892,7 @@ class NotificationManager(
// Use a special notification ID for mesh mentions
val notificationId = 4000 // Different from DM and geohash IDs
notificationManager.notify(notificationId, builder.build())
notifySafely(notificationId, builder.build())
Log.d(TAG, "Displayed mesh mention notification: $contentTitle")
}
@ -799,13 +924,37 @@ class NotificationManager(
/**
* Clear all pending notifications
*/
fun clearAllNotifications() {
fun clearAllNotifications(removeConversationShortcuts: Boolean = false) {
pendingNotifications.clear()
notificationManager.cancelAll()
pendingGeohashNotifications.clear()
if (removeConversationShortcuts) {
val shortcutIDs = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
if (shortcutIDs.isNotEmpty()) {
ShortcutManagerCompat.removeLongLivedShortcuts(context, shortcutIDs)
}
}
Log.d(TAG, "Cleared all notifications")
}
private fun notifySafely(notificationID: Int, notification: android.app.Notification) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
try {
notificationManager.notify(notificationID, notification)
} catch (error: SecurityException) {
Log.w(TAG, "Notification permission was revoked: ${error.message}")
}
}
/**
* Get pending notification count for UI badging
*/

View File

@ -134,6 +134,50 @@ class PrivateChatManager(
return true
}
/**
* Persists the local echo before handing the payload to a transport.
*
* A failed database write deliberately aborts the send: otherwise the remote peer could
* receive a message that disappears from the sender's conversation after process death.
*/
suspend fun sendPrivateMessageDurably(
content: String,
peerID: String,
recipientNickname: String?,
senderNickname: String?,
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot send message to $recipientNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
val message = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
if (!messageManager.addPrivateMessageDurably(conversationID, message, forceRead = true)) {
return false
}
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
return true
}
// MARK: - Peer Management
fun isPeerBlocked(peerID: String): Boolean {
@ -375,6 +419,51 @@ class PrivateChatManager(
}
}
/**
* Durable admission for transports that do not pass through the mesh admission pipeline.
*
* Nostr acknowledgements are emitted by the caller only after this returns true, which lets a
* failed write be retried rather than silently acknowledging a message that was never saved.
*/
suspend fun handleIncomingPrivateMessageDurably(
message: BitchatMessage,
suppressUnread: Boolean,
origin: PrivateMessageOrigin
): Boolean {
val senderPeerID = message.senderPeerID
val conversationID = senderPeerID
?.let(ContactDirectory::canonicalConversationId)
?: state.getSelectedPrivateChatPeerValue()
?: return false
if (senderPeerID != null && isPeerBlocked(senderPeerID)) return false
messageManager.initializePrivateChat(conversationID)
val shouldPersistHere = origin == PrivateMessageOrigin.NOSTR || senderPeerID == null
if (shouldPersistHere) {
val accepted = messageManager.addPrivateMessageDurably(
peerID = conversationID,
message = message,
forceRead = suppressUnread || !trackUnreadMessages
)
if (!accepted) return false
}
if (
senderPeerID != null &&
trackUnreadMessages &&
!suppressUnread &&
state.getSelectedPrivateChatPeerValue() != conversationID
) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
}
return true
}
/**
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat

View File

@ -74,13 +74,46 @@
<!-- 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="conversations">Conversations</string>
<string name="offline_not_in_mesh">Offline · not in mesh</string>
<string name="offline_reachable_via_nostr">Offline from mesh · Nostr available</string>
<string name="delete">Delete</string>
<string name="delete_conversation_action">Delete conversation</string>
<string name="delete_conversation_title">Delete conversation?</string>
<string name="delete_conversation_message">Delete your conversation with %1$s from this device?</string>
<string name="search_conversations">Search conversations</string>
<string name="clear">Clear</string>
<string name="loading_conversations">Loading private conversations…</string>
<string name="conversation_storage_error">Private conversations could not be loaded</string>
<string name="no_conversations_yet">Private conversations will appear here</string>
<string name="no_conversation_results">No matching conversations</string>
<string name="online_conversations">ONLINE</string>
<string name="offline_conversations">OFFLINE</string>
<string name="mark_read">Mark as read</string>
<string name="mark_unread">Mark as unread</string>
<string name="pin_conversation">Pin conversation</string>
<string name="unpin_conversation">Unpin conversation</string>
<string name="mute_conversation">Mute conversation</string>
<string name="unmute_conversation">Unmute conversation</string>
<string name="conversation_actions">Conversation actions</string>
<string name="conversation_unread_count">%1$d unread messages</string>
<string name="conversation_read">Read</string>
<string name="conversation_draft_preview">Draft: %1$s</string>
<string name="conversation_you_preview">You: %1$s</string>
<string name="conversation_preview_timestamp">· %1$s</string>
<string name="conversation_delete_failed">Conversation could not be deleted</string>
<string name="conversation_deleted">Deleted conversation with %1$s</string>
<string name="undo">Undo</string>
<string name="conversation_restore_failed">Conversation could not be restored</string>
<string name="notification_reply">Reply</string>
<string name="notification_mark_read">Mark read</string>
<string name="notification_private_message">New private message</string>
<string name="notification_content_hidden">Open bitchat to view it</string>
<string name="notification_channel_direct_messages">Direct Messages</string>
<string name="notification_channel_direct_messages_description">Notifications for private messages from other users</string>
<string name="notification_channel_geohash">Geohash Chats</string>
<string name="notification_channel_geohash_description">Notifications for mentions and messages in geohash location channels</string>
<string name="you">You</string>
<string name="cd_location_notes">Location notes</string>
<string name="cd_teleported">Teleported</string>
<string name="cd_tor_status">Tor status</string>

View File

@ -2,6 +2,8 @@ package com.bitchat.android.nostr
import android.os.Build
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ConversationRepository
import com.bitchat.android.services.InMemoryConversationStorageCipher
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
@ -30,6 +32,7 @@ import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
@ -37,17 +40,31 @@ import org.robolectric.annotation.Config
class NostrDirectMessageHandlerTest {
private val gson = Gson()
private lateinit var scope: CoroutineScope
private lateinit var conversationRepository: ConversationRepository
private lateinit var conversationDatabaseName: String
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
AppStateStore.clear()
conversationDatabaseName = "nostr-dm-${UUID.randomUUID()}.db"
conversationRepository = ConversationRepository(
context = RuntimeEnvironment.getApplication(),
dispatcher = Dispatchers.Unconfined,
databaseName = conversationDatabaseName,
storageCipher = InMemoryConversationStorageCipher()
)
AppStateStore.setConversationRepositoryForTest(conversationRepository)
}
@After
fun tearDown() {
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(null)
conversationRepository.closeForTest()
RuntimeEnvironment.getApplication()
.deleteDatabase(conversationDatabaseName)
scope.cancel()
Dispatchers.resetMain()
}

View File

@ -135,6 +135,60 @@ class AppStateStoreTest {
assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID])
}
@Test
fun `live nickname updates retained conversation metadata`() {
val message = BitchatMessage(
id = "rename-message",
sender = "Alice Old",
content = "hello",
timestamp = Date(1),
isPrivate = true
)
AppStateStore.addPrivateMessage("peer-a", message)
AppStateStore.updatePrivateConversationDisplayNames(
mapOf("peer-a" to "Alice New")
)
assertEquals(
"Alice New",
AppStateStore.privateConversationDisplayNames.value.getValue("peer-a")
)
}
@Test
fun `restored unread and nickname state canonicalize with message aliases`() {
val noiseKeyHex = "02".repeat(32)
val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(
ByteArray(32) { 2 }
)
val message = BitchatMessage(
id = "restored-alias-message",
sender = "Alice",
content = "hello",
timestamp = Date(1),
isPrivate = true
)
AppStateStore.restorePrivateConversations(
PersistedConversationSnapshot(
chats = mapOf(noiseKeyHex to listOf(message)),
readMessageIDs = emptySet(),
arrivalOrder = listOf(message.id),
deletedMessageIDs = emptySet(),
displayNames = mapOf(noiseKeyHex to "Alice Renamed"),
unreadCounts = mapOf(noiseKeyHex to 1)
)
)
assertEquals(setOf(contactID), AppStateStore.privateMessages.value.keys)
assertEquals(mapOf(contactID to 1), AppStateStore.unreadPrivateMessageCounts.value)
assertEquals(
mapOf(contactID to "Alice Renamed"),
AppStateStore.privateConversationDisplayNames.value
)
}
@Test
fun `canonicalized private chat history keeps arrival order when peer clocks differ`() {
val noiseKeyHex = "01".repeat(32)
@ -194,6 +248,68 @@ class AppStateStoreTest {
assertTrue(status is DeliveryStatus.Read)
}
@Test
fun `in flight message can become failed without overwriting confirmed delivery`() {
val sending = BitchatMessage(
id = "send-failure",
sender = "me",
content = "hello",
timestamp = Date(1),
isPrivate = true,
deliveryStatus = DeliveryStatus.Sending
)
AppStateStore.addPrivateMessage("peer-a", sending)
AppStateStore.updatePrivateMessageStatus(
sending.id,
DeliveryStatus.Failed("network unavailable")
)
assertTrue(
AppStateStore.privateMessages.value
.getValue("peer-a")
.single()
.deliveryStatus is DeliveryStatus.Failed
)
AppStateStore.updatePrivateMessageStatus(
sending.id,
DeliveryStatus.Delivered("alice", Date(2))
)
AppStateStore.updatePrivateMessageStatus(
sending.id,
DeliveryStatus.Failed("late timeout")
)
assertTrue(
AppStateStore.privateMessages.value
.getValue("peer-a")
.single()
.deliveryStatus is DeliveryStatus.Delivered
)
}
@Test
fun `closing a conversation releases full payload history but keeps its summary`() {
repeat(3) { index ->
AppStateStore.addPrivateMessage(
"peer-a",
BitchatMessage(
id = "history-$index",
sender = "alice",
content = "message $index",
timestamp = Date(index.toLong()),
isPrivate = true
)
)
}
AppStateStore.releasePrivateConversationHistory("peer-a")
assertEquals(
listOf("history-2"),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
)
}
@Test
fun `long lived process applies the same bounded private history policy`() {
AppStateStore.setNickname("me")
@ -256,8 +372,12 @@ class AppStateStoreTest {
val afterPanic = beforePanic.copy(id = "after-panic")
assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic))
AppStateStore.updatePrivateConversationDisplayNames(
mapOf("peer-a" to "Alice")
)
assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() })
assertTrue(AppStateStore.privateMessages.value.isEmpty())
assertTrue(AppStateStore.privateConversationDisplayNames.value.isEmpty())
assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic))
AppStateStore.resumePrivateConversationsAfterPanic()

View File

@ -1,5 +1,6 @@
package com.bitchat.android.services
import android.content.ContentValues
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.model.BitchatMessage
@ -9,6 +10,9 @@ import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -16,18 +20,21 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Date
import java.util.UUID
import java.io.File
@RunWith(RobolectricTestRunner::class)
class ConversationDatabaseTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var database: ConversationDatabase
private lateinit var storageCipher: InMemoryConversationStorageCipher
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "conversation-test-${UUID.randomUUID()}.db"
database = ConversationDatabase(context, databaseName)
storageCipher = InMemoryConversationStorageCipher()
database = ConversationDatabase(context, databaseName, storageCipher = storageCipher)
}
@After
@ -71,7 +78,7 @@ class ConversationDatabaseTest {
)
database.close()
database = ConversationDatabase(context, databaseName)
database = ConversationDatabase(context, databaseName, storageCipher = storageCipher)
val restored = database.loadSnapshot()
val restoredMessage = restored.chats.getValue("contact_alice").single()
@ -89,6 +96,7 @@ class ConversationDatabaseTest {
)
assertEquals(setOf(message.id), restored.readMessageIDs)
assertEquals(listOf(message.id), restored.arrivalOrder)
assertEquals("alice", restored.displayNames.getValue("contact_alice"))
}
@Test
@ -102,17 +110,19 @@ class ConversationDatabaseTest {
targetConversationID = "contact_alice",
aliases = setOf("mesh-alias", "nostr_alias", "contact_alice")
)
database.upsertMessage(
val duplicate = database.upsertMessage(
"contact_alice",
setOf("mesh-alias", "nostr_alias"),
"alice",
null,
first,
false
)
val restored = database.loadSnapshot()
assertFalse(duplicate.inserted)
assertEquals(setOf("contact_alice"), restored.chats.keys)
assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id })
assertEquals("alice", restored.displayNames.getValue("contact_alice"))
}
@Test
@ -132,7 +142,7 @@ class ConversationDatabaseTest {
assertTrue(database.loadSnapshot().chats.isEmpty())
database.close()
database = ConversationDatabase(context, databaseName)
database = ConversationDatabase(context, databaseName, storageCipher = storageCipher)
database.upsertMessage(
"contact_alice",
setOf("mesh-alias", "contact_alice"),
@ -188,7 +198,8 @@ class ConversationDatabaseTest {
databaseName = databaseName,
maxMessagesPerConversation = 10,
maxMessagesTotal = 3,
maxPayloadBytes = Long.MAX_VALUE
maxPayloadBytes = Long.MAX_VALUE,
storageCipher = storageCipher
)
repeat(4) { index ->
database.upsertMessage(
@ -208,6 +219,349 @@ class ConversationDatabaseTest {
assertTrue(snapshot.deletedMessageIDs.contains("single-0"))
}
@Test
fun `initial snapshot decrypts only latest row while preserving exact unread count`() {
repeat(3) { index ->
database.upsertMessage(
conversationID = "contact_alice",
aliases = setOf("contact_alice"),
displayName = "alice",
message = message("summary-$index", "alice", index.toLong()),
isRead = index == 2
)
}
val initial = database.loadInitialSnapshot()
assertEquals(listOf("summary-2"), initial.chats.getValue("contact_alice").map { it.id })
assertEquals(2, initial.unreadCounts.getValue("contact_alice"))
assertEquals(setOf("summary-2"), initial.readMessageIDs)
assertEquals(3, database.loadConversation("contact_alice").arrivalOrder.size)
}
@Test
fun `nickname metadata updates without requiring a new message`() {
database.upsertMessage(
conversationID = "mesh-alias",
aliases = setOf("mesh-alias"),
displayName = "Alice Old",
message = message("rename-message", "Alice Old", 1L),
isRead = true
)
database.updateConversationIdentity(
conversationID = "contact_alice",
aliases = setOf("mesh-alias", "contact_alice"),
displayName = "Alice New"
)
database.close()
database = ConversationDatabase(context, databaseName, storageCipher = storageCipher)
val restored = database.loadInitialSnapshot()
assertEquals(setOf("contact_alice"), restored.chats.keys)
assertEquals("Alice New", restored.displayNames.getValue("contact_alice"))
assertEquals("Alice Old", restored.chats.getValue("contact_alice").single().sender)
}
@Test
fun `sensitive message and display fields are encrypted and legacy columns are scrubbed`() {
val secret = "unique-secret-${UUID.randomUUID()}"
database.upsertMessage(
conversationID = "contact_alice",
aliases = setOf("contact_alice"),
displayName = "display-$secret",
message = BitchatMessage(
id = "encrypted-row",
sender = "sender-$secret",
content = "content-$secret",
timestamp = Date(42L),
isPrivate = true,
recipientNickname = "recipient-$secret"
),
isRead = false
)
database.readableDatabase.query(
"private_messages",
arrayOf("sender", "content", "recipient_nickname", "payload_ciphertext"),
"message_id = ?",
arrayOf("encrypted-row"),
null,
null,
null
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("", cursor.getString(0))
assertEquals("", cursor.getString(1))
assertNull(cursor.getString(2))
val envelope = cursor.getBlob(3)
assertFalse(envelope.toString(Charsets.ISO_8859_1).contains(secret))
val plaintext = storageCipher.decrypt(
envelope,
"message:encrypted-row".toByteArray()
).toString(Charsets.UTF_8)
assertTrue(plaintext.contains(secret))
}
database.readableDatabase.query(
"conversations",
arrayOf("display_name", "display_name_ciphertext"),
"conversation_id = ?",
arrayOf("contact_alice"),
null,
null,
null
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertNull(cursor.getString(0))
assertNotNull(cursor.getBlob(1))
}
}
@Test
fun `media byte retention reports only orphaned app-owned attachments`() {
database.close()
context.deleteDatabase(databaseName)
database = ConversationDatabase(
context = context,
databaseName = databaseName,
maxMessagesPerConversation = 10,
maxMessagesTotal = 10,
maxPayloadBytes = Long.MAX_VALUE,
maxMediaBytes = 4,
storageCipher = storageCipher
)
val first = File(context.filesDir, "conversation-first-${UUID.randomUUID()}.bin")
.apply { writeBytes(byteArrayOf(1, 2, 3, 4)) }
val second = File(context.filesDir, "conversation-second-${UUID.randomUUID()}.bin")
.apply { writeBytes(byteArrayOf(5, 6, 7, 8)) }
try {
database.upsertMessage(
"contact_alice",
setOf("contact_alice"),
"alice",
message("media-1", "alice", 1L).copy(
type = BitchatMessageType.File,
content = first.absolutePath
),
true
)
database.upsertMessage(
"contact_alice",
setOf("contact_alice"),
"alice",
message("media-2", "alice", 2L).copy(
type = BitchatMessageType.File,
content = second.absolutePath
),
true
)
val orphaned = database.pruneToRetentionLimits()
assertEquals(setOf(first.canonicalPath), orphaned)
assertEquals(listOf("media-2"), database.loadSnapshot().arrivalOrder)
} finally {
first.delete()
second.delete()
}
}
@Test
fun `shared attachment path is counted once and retained while referenced`() {
database.close()
context.deleteDatabase(databaseName)
database = ConversationDatabase(
context = context,
databaseName = databaseName,
maxMessagesPerConversation = 10,
maxMessagesTotal = 10,
maxPayloadBytes = Long.MAX_VALUE,
maxMediaBytes = 4,
storageCipher = storageCipher
)
val shared = File(context.filesDir, "conversation-shared-${UUID.randomUUID()}.bin")
.apply { writeBytes(byteArrayOf(1, 2, 3, 4)) }
try {
repeat(2) { index ->
database.upsertMessage(
"contact_alice",
setOf("contact_alice"),
"alice",
message("shared-$index", "alice", index.toLong()).copy(
type = BitchatMessageType.File,
content = shared.absolutePath
),
true
)
}
assertTrue(database.pruneToRetentionLimits().isEmpty())
assertEquals(2, database.loadSnapshot().arrivalOrder.size)
} finally {
shared.delete()
}
}
@Test
fun `panic clear rotates the storage key before erasing rows`() {
database.upsertMessage(
"contact_alice",
setOf("contact_alice"),
"alice",
message("before-panic", "alice", 1L),
true
)
val envelope = database.readableDatabase.query(
"private_messages",
arrayOf("payload_ciphertext"),
"message_id = ?",
arrayOf("before-panic"),
null,
null,
null
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getBlob(0)
}
database.clearAll()
assertTrue(database.loadSnapshot().chats.isEmpty())
assertThrows(Exception::class.java) {
storageCipher.decrypt(envelope, "message:before-panic".toByteArray())
}
}
@Test
fun `version one plaintext database migrates without losing history`() {
database.close()
context.deleteDatabase(databaseName)
createVersionOneDatabase(
conversationID = "legacy-contact",
messageID = "legacy-message",
sender = "legacy-alice",
content = "legacy-content"
)
database = ConversationDatabase(context, databaseName, storageCipher = storageCipher)
val restored = database.loadSnapshot()
assertEquals("legacy-content", restored.chats.getValue("legacy-contact").single().content)
database.readableDatabase.query(
"private_messages",
arrayOf("sender", "content", "payload_ciphertext", "received_at"),
"message_id = ?",
arrayOf("legacy-message"),
null,
null,
null
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("", cursor.getString(0))
assertEquals("", cursor.getString(1))
assertNotNull(cursor.getBlob(2))
assertEquals(123L, cursor.getLong(3))
}
}
private fun createVersionOneDatabase(
conversationID: String,
messageID: String,
sender: String,
content: String
) {
context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { db ->
db.execSQL(
"""
CREATE TABLE conversations (
conversation_id TEXT COLLATE NOCASE PRIMARY KEY NOT NULL,
display_name TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE conversation_aliases (
alias TEXT COLLATE NOCASE PRIMARY KEY NOT NULL,
conversation_id TEXT COLLATE NOCASE NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE private_messages (
arrival_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE NOT NULL,
conversation_id TEXT COLLATE NOCASE NOT NULL,
sender TEXT NOT NULL,
content TEXT NOT NULL,
message_type INTEGER NOT NULL,
sent_at INTEGER NOT NULL,
is_relay INTEGER NOT NULL,
original_sender TEXT,
is_private INTEGER NOT NULL,
recipient_nickname TEXT,
sender_peer_id TEXT,
mentions_json TEXT,
channel_name TEXT,
encrypted_content BLOB,
is_encrypted INTEGER NOT NULL,
delivery_type INTEGER NOT NULL,
delivery_text TEXT,
delivery_at INTEGER,
delivery_reached INTEGER,
delivery_total INTEGER,
sender_nostr_pubkey TEXT,
is_read INTEGER NOT NULL DEFAULT 0
)
""".trimIndent()
)
db.execSQL(
"CREATE TABLE deleted_private_messages (" +
"message_id TEXT PRIMARY KEY NOT NULL, deleted_at INTEGER NOT NULL)"
)
db.insertOrThrow(
"conversations",
null,
ContentValues().apply {
put("conversation_id", conversationID)
put("display_name", sender)
put("created_at", 1L)
put("updated_at", 1L)
}
)
db.insertOrThrow(
"conversation_aliases",
null,
ContentValues().apply {
put("alias", conversationID)
put("conversation_id", conversationID)
}
)
db.insertOrThrow(
"private_messages",
null,
ContentValues().apply {
put("message_id", messageID)
put("conversation_id", conversationID)
put("sender", sender)
put("content", content)
put("message_type", BitchatMessageType.Message.ordinal)
put("sent_at", 123L)
put("is_relay", 0)
put("is_private", 1)
put("is_encrypted", 0)
put("delivery_type", 2)
put("is_read", 0)
}
)
db.version = 1
}
}
private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage(
id = id,
sender = sender,

View File

@ -0,0 +1,110 @@
package com.bitchat.android.services
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.identity.SecureIdentityStateManager
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
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.UUID
@RunWith(RobolectricTestRunner::class)
class ConversationListPreferencesTest {
private lateinit var context: Context
private lateinit var preferencesName: String
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
preferencesName = "conversation-list-${UUID.randomUUID()}"
ContactDirectory.initialize(context) { null }
}
@After
fun tearDown() {
ContactDirectory.initialize(context) { null }
}
@Test
fun `pin mute and draft persist while conversation removal clears all three`() {
val first = preferences()
first.togglePinned("CONTACT_ALICE")
first.toggleMuted("contact_alice")
first.setDraft("contact_alice", "unfinished reply")
val restored = preferences()
assertTrue(restored.isPinned("contact_alice"))
assertTrue(restored.isMuted("CONTACT_ALICE"))
assertTrue(restored.draftFor("contact_alice") == "unfinished reply")
restored.removeConversation("contact_alice")
val afterDelete = preferences()
assertFalse(afterDelete.isPinned("contact_alice"))
assertFalse(afterDelete.isMuted("contact_alice"))
assertNull(afterDelete.draftFor("contact_alice"))
}
@Test
fun `draft persistence is globally bounded and panic clear is durable`() {
val preferences = preferences()
repeat(60) { index ->
preferences.setDraft("peer-$index", "x".repeat(3_000))
}
assertTrue(preferences.drafts.value.size <= 50)
assertTrue(preferences.drafts.value.values.sumOf(String::length) <= 128_000)
assertNull(preferences.draftFor("peer-0"))
assertTrue(preferences.draftFor("peer-59")?.isNotEmpty() == true)
preferences.togglePinned("peer-59")
preferences.toggleMuted("peer-59")
preferences.clearAll()
val afterPanic = preferences()
assertTrue(afterPanic.pinned.value.isEmpty())
assertTrue(afterPanic.muted.value.isEmpty())
assertTrue(afterPanic.drafts.value.isEmpty())
}
@Test
fun `identity canonicalization preserves pin mute and draft state`() {
val peerID = "1122334455667788"
val contactID = "contact_alice"
val beforeIdentityResolution = preferences(canonicalize = { it })
beforeIdentityResolution.togglePinned(peerID)
beforeIdentityResolution.toggleMuted(peerID)
beforeIdentityResolution.setDraft(peerID, "unfinished reply")
val afterIdentityResolution = preferences(
canonicalize = { value ->
if (value.equals(peerID, ignoreCase = true)) contactID else value
}
)
afterIdentityResolution.canonicalizeAliases()
assertEquals(setOf(contactID), afterIdentityResolution.pinned.value)
assertEquals(setOf(contactID), afterIdentityResolution.muted.value)
assertEquals(
mapOf(contactID to "unfinished reply"),
afterIdentityResolution.drafts.value
)
}
private fun preferences(
canonicalize: (String) -> String = ContactDirectory::canonicalConversationId
): ConversationListPreferences {
val sharedPreferences =
context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
return ConversationListPreferences(
stateManager = SecureIdentityStateManager(sharedPreferences, testOnly = true),
testOnly = true,
canonicalize = canonicalize
)
}
}

View File

@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicReference
class ConversationRepositoryTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: ConversationRepository
private val executor = Executors.newSingleThreadExecutor()
private val dispatcher = executor.asCoroutineDispatcher()
@ -32,16 +33,18 @@ class ConversationRepositoryTest {
@After
fun tearDown() {
if (::repository.isInitialized) repository.closeForTest()
dispatcher.close()
context.deleteDatabase(databaseName)
}
@Test
fun `reload restores persisted history after initial process restore`() {
val repository = ConversationRepository(
repository = ConversationRepository(
context = context,
dispatcher = dispatcher,
databaseName = databaseName
databaseName = databaseName,
storageCipher = InMemoryConversationStorageCipher()
)
val message = BitchatMessage(
id = "persisted-message",
@ -78,10 +81,11 @@ class ConversationRepositoryTest {
@Test
fun `panic clear drains queued writes and leaves database empty`() {
val repository = ConversationRepository(
repository = ConversationRepository(
context = context,
dispatcher = dispatcher,
databaseName = databaseName
databaseName = databaseName,
storageCipher = InMemoryConversationStorageCipher()
)
repository.upsertMessage(
conversationID = "peer-alice",

View File

@ -0,0 +1,39 @@
package com.bitchat.android.services
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
internal class InMemoryConversationStorageCipher : ConversationStorageCipher {
private var key: SecretKey = generateKey()
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
cipher.updateAAD(associatedData)
return byteArrayOf(1) + cipher.iv + cipher.doFinal(plaintext)
}
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray {
require(envelope.size > 13 && envelope[0] == 1.toByte())
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
key,
GCMParameterSpec(128, envelope.copyOfRange(1, 13))
)
cipher.updateAAD(associatedData)
return cipher.doFinal(envelope.copyOfRange(13, envelope.size))
}
override fun destroyKey() {
key = generateKey()
}
private fun generateKey(): SecretKey =
KeyGenerator.getInstance("AES").apply {
init(256, SecureRandom())
}.generateKey()
}

View File

@ -1,17 +1,47 @@
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.DeliveryStatus
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MessageManager
import com.bitchat.android.ui.NoiseSessionDelegate
import com.bitchat.android.ui.PrivateChatManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
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 org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Date
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class IncomingMessageAdmissionTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: ConversationRepository
private val repositoriesToClose = mutableListOf<ConversationRepository>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "admission-test-${UUID.randomUUID()}.db"
repository = ConversationRepository(
context = context,
dispatcher = Dispatchers.Unconfined,
databaseName = databaseName,
storageCipher = InMemoryConversationStorageCipher()
)
repositoriesToClose += repository
AppStateStore.setConversationRepositoryForTest(repository)
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@ -20,6 +50,10 @@ class IncomingMessageAdmissionTest {
fun tearDown() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(null)
repositoriesToClose.forEach(ConversationRepository::closeForTest)
context.deleteDatabase(databaseName)
context.deleteDatabase("failing-$databaseName")
}
@Test
@ -42,6 +76,64 @@ class IncomingMessageAdmissionTest {
assertFalse(IncomingMessageAdmission.admitToAppState(message))
}
@Test
fun `older retained replay is rejected after summary-only restart`() {
val older = privateMessage(id = "older-retained")
val latest = privateMessage(id = "latest-summary").copy(timestamp = Date(2L))
assertTrue(IncomingMessageAdmission.admitToAppState(older))
assertTrue(IncomingMessageAdmission.admitToAppState(latest))
AppStateStore.clear()
repository.reload { snapshot ->
AppStateStore.restorePrivateConversations(snapshot)
}
runBlocking { repository.awaitPendingWrites() }
assertEquals(
listOf(latest.id),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
)
assertFalse(IncomingMessageAdmission.admitToAppState(older))
assertEquals(
listOf(latest.id),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
)
}
@Test
fun `delivery receipt persists after older message is unloaded from memory`() {
val older = privateMessage(id = "older-outgoing").copy(
sender = "me",
senderPeerID = "self",
recipientNickname = "alice",
deliveryStatus = DeliveryStatus.Sent
)
val latest = privateMessage(id = "latest-summary").copy(timestamp = Date(2L))
assertTrue(AppStateStore.addPrivateMessage("peer-a", older, forceRead = true))
assertTrue(IncomingMessageAdmission.admitToAppState(latest))
runBlocking { repository.awaitPendingWrites() }
AppStateStore.releasePrivateConversationHistory("peer-a")
assertEquals(
listOf(latest.id),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
)
val delivered = DeliveryStatus.Delivered(to = "alice", at = Date(3L))
AppStateStore.updatePrivateMessageStatus(older.id, delivered)
runBlocking { repository.awaitPendingWrites() }
val snapshot = runBlocking {
repository.loadConversationAndWait("peer-a")
}
assertEquals(
delivered,
snapshot?.chats?.getValue("peer-a")
?.single { it.id == older.id }
?.deliveryStatus
)
}
@Test
fun `public and channel messages preserve best effort admission`() {
val public = BitchatMessage(
@ -57,6 +149,88 @@ class IncomingMessageAdmissionTest {
assertTrue(AppStateStore.publicMessages.value.contains(public))
}
@Test
fun `outgoing callback runs only after local echo survives process state clear`() {
val state = ChatState(TestScope())
state.setNickname("me")
val manager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = DataManager(context),
noiseSessionDelegate = testNoiseDelegate()
)
var callbackInvoked = false
val sent = runBlocking {
manager.sendPrivateMessageDurably(
content = "durable outgoing",
peerID = "peer-a",
recipientNickname = "alice",
senderNickname = "me",
myPeerID = "self"
) { _, _, _, _ ->
callbackInvoked = true
}
}
assertTrue(sent)
assertTrue(callbackInvoked)
AppStateStore.clear()
repository.reload { snapshot ->
assertEquals(
"durable outgoing",
snapshot.chats.getValue("peer-a").single().content
)
}
runBlocking { repository.awaitPendingWrites() }
}
@Test
fun `outgoing callback is suppressed when durable storage fails`() {
val failingRepository = ConversationRepository(
context = context,
dispatcher = Dispatchers.Unconfined,
databaseName = "failing-$databaseName",
storageCipher = object : ConversationStorageCipher {
override fun encrypt(
plaintext: ByteArray,
associatedData: ByteArray
): ByteArray = error("storage unavailable")
override fun decrypt(
envelope: ByteArray,
associatedData: ByteArray
): ByteArray = error("storage unavailable")
override fun destroyKey() = Unit
}
)
repositoriesToClose += failingRepository
AppStateStore.setConversationRepositoryForTest(failingRepository)
val state = ChatState(TestScope())
val manager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = DataManager(context),
noiseSessionDelegate = testNoiseDelegate()
)
var callbackInvoked = false
val sent = runBlocking {
manager.sendPrivateMessageDurably(
"lost",
"peer-a",
"alice",
"me",
"self"
) { _, _, _, _ -> callbackInvoked = true }
}
assertFalse(sent)
assertFalse(callbackInvoked)
assertTrue(state.getPrivateChatsValue().isEmpty())
}
private fun privateMessage(id: String) = BitchatMessage(
id = id,
sender = "alice",
@ -65,4 +239,10 @@ class IncomingMessageAdmissionTest {
isPrivate = true,
senderPeerID = "peer-a"
)
private fun testNoiseDelegate() = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String) = false
override fun initiateHandshake(peerID: String) = Unit
override fun getMyPeerID() = "self"
}
}

View File

@ -6,6 +6,7 @@ import com.bitchat.android.services.PrivateMessageArrivalOrder
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Date
@ -141,6 +142,97 @@ class ConversationSummaryTest {
assertEquals(BitchatMessageType.File, conversation.latestMessageType)
}
@Test
fun `case variants cannot create duplicate conversations or misclassify outgoing messages`() {
val outgoing = incoming(
id = "outgoing",
sender = "Me",
timestamp = 200L,
content = "sent by me"
)
val conversations = buildConversationSummaries(
unreadConversationIDs = emptySet(),
privateChats = mapOf(
"CONTACT_ALICE" to listOf(outgoing),
"contact_alice" to listOf(outgoing)
),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { true }
)
assertEquals(1, conversations.size)
assertEquals(setOf("contact_alice"), conversations.single().identityAliases)
assertEquals(true, conversations.single().latestMessageIsOutgoing)
}
@Test
fun `outgoing messages remain outgoing after local nickname changes`() {
val outgoing = incoming(
id = "outgoing-before-rename",
sender = "old nickname",
timestamp = 200L,
content = "sent before rename"
).copy(senderPeerID = "my-stable-peer-id")
val conversation = buildConversationSummaries(
unreadConversationIDs = setOf("contact_alice"),
privateChats = mapOf("contact_alice" to listOf(outgoing)),
currentUserIdentifiers = setOf("new nickname", "my-stable-peer-id"),
canonicalize = { it },
isMessageRead = { false },
persistedUnreadCounts = emptyMap()
).single()
assertTrue(conversation.latestMessageIsOutgoing)
assertEquals(0, conversation.unreadCount)
}
@Test
fun `connected nickname overrides cached conversation names`() {
assertEquals(
"Alice Renamed",
resolveConversationDisplayName(
fallbackName = "Alice From Message",
connectedPeerID = "peer-a",
peerNicknames = mapOf("peer-a" to "Alice Renamed"),
resolvedContactName = "Alice Favorite Snapshot",
persistedDisplayName = "Alice Persisted"
)
)
}
@Test
fun `persisted nickname is used when no live or contact name remains`() {
assertEquals(
"Alice Persisted",
resolveConversationDisplayName(
fallbackName = "Alice From Message",
connectedPeerID = null,
peerNicknames = emptyMap(),
resolvedContactName = null,
persistedDisplayName = "Alice Persisted"
)
)
}
@Test
fun `summary-only startup uses persisted unread count`() {
val latest = incoming("latest", "alice", 300L)
val conversation = buildConversationSummaries(
unreadConversationIDs = setOf("contact_alice"),
privateChats = mapOf("contact_alice" to listOf(latest)),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { false },
persistedUnreadCounts = mapOf("contact_alice" to 47)
).single()
assertEquals(47, conversation.unreadCount)
}
private fun incoming(
id: String,
sender: String,

View File

@ -6,7 +6,11 @@ import com.bitchat.android.mesh.PreparedPrivateMediaTransfer
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.PrivateMediaWireMode
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.ConversationRepository
import com.bitchat.android.services.InMemoryConversationStorageCipher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -32,6 +36,7 @@ import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class MediaSendingManagerMigrationTest {
@ -40,11 +45,22 @@ class MediaSendingManagerMigrationTest {
private lateinit var mesh: MeshService
private lateinit var manager: MediaSendingManager
private lateinit var file: File
private lateinit var conversationRepository: ConversationRepository
private lateinit var conversationDatabaseName: String
@Before
fun setup() {
state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined))
state.setNickname("me")
conversationDatabaseName = "media-send-${UUID.randomUUID()}.db"
conversationRepository = ConversationRepository(
context = org.robolectric.RuntimeEnvironment.getApplication(),
dispatcher = Dispatchers.Unconfined,
databaseName = conversationDatabaseName,
storageCipher = InMemoryConversationStorageCipher()
)
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(conversationRepository)
mesh = mock()
whenever(mesh.myPeerID).thenReturn("0011223344556677")
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer"))
@ -63,6 +79,11 @@ class MediaSendingManagerMigrationTest {
@After
fun tearDown() {
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(null)
conversationRepository.closeForTest()
org.robolectric.RuntimeEnvironment.getApplication()
.deleteDatabase(conversationDatabaseName)
file.delete()
}
@ -362,7 +383,7 @@ class MediaSendingManagerMigrationTest {
}
@Test
fun `failed prepared commit rolls back the local file echo`() {
fun `failed prepared commit keeps a retryable failed local file echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
@ -378,9 +399,13 @@ class MediaSendingManagerMigrationTest {
manager.sendImageNote(peerID, null, file.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("could not be committed"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
assertEquals(2, messages.size)
val fileEcho = messages.single { it.type == BitchatMessageType.Image }
assertTrue(fileEcho.deliveryStatus is DeliveryStatus.Failed)
assertTrue(
messages.single { it.sender == "system" }
.content.contains("could not be committed")
)
}
@Test

View File

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