Polish persistent private conversations

This commit is contained in:
callebtc 2026-07-29 13:46:21 +02:00
parent 9ed5cb26da
commit a105d884cb
30 changed files with 3865 additions and 460 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

@ -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,84 @@
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
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,14 @@ 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()
@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 +84,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 +197,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 +271,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 +285,53 @@ object AppStateStore {
?: msg.sender.takeUnless {
it.isBlank() || it == "system" || it == _nickname.value
}
conversationRepository?.upsertMessage(
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 +359,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
@ -292,6 +445,17 @@ object AppStateStore {
if (privateConversationWritesSuspended) return
if (messageID in _readPrivateMessageIDs.value) return
_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 +463,39 @@ 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 - canonicalID
} 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)
@ -324,6 +521,8 @@ object AppStateStore {
matchingKeys.forEach(updated::remove)
_privateMessages.value = updated
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value - matchingKeys - canonicalID
if (
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
@ -335,6 +534,115 @@ 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
_readPrivateMessageIDs.value =
_readPrivateMessageIDs.value - deletion.messageIDs
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
if (
!repository.restoreConversationAndWait(
conversationID = deletion.conversationID,
aliases = deletion.aliases,
displayName = deletion.displayName,
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
)
}
}
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 = 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()
@ -365,8 +673,10 @@ object AppStateStore {
suspend fun panicClearPrivateConversations(): Boolean {
val repository = synchronized(this) {
privateConversationWritesSuspended = true
privateConversationGeneration += 1
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_unreadPrivateMessageCounts.value = emptyMap()
_selectedPrivateChatPeer.value = null
conversationRepository
}
@ -394,6 +704,8 @@ object AppStateStore {
fun clear() {
synchronized(this) {
seenMessageIds.clear()
reservedPrivateMessageIds.clear()
privateConversationGeneration += 1
seenPublicMessageKeys.clear()
PrivateMessageArrivalOrder.clear()
privateWritesSinceGlobalPrune = 0
@ -404,6 +716,7 @@ object AppStateStore {
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_unreadPrivateMessageCounts.value = emptyMap()
_channelMessages.value = emptyMap()
_nickname.value = ""
_selectedPrivateChatPeer.value = null
@ -421,12 +734,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,6 +769,12 @@ 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
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(
merged.mapValues { (_, messages) ->
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
@ -565,3 +889,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

@ -0,0 +1,164 @@
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 constructor(context: Context) : this(
SecureIdentityStateManager(context.applicationContext)
)
internal constructor(
stateManager: SecureIdentityStateManager,
testOnly: Boolean
) : this(stateManager) {
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)
}
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 =
ContactDirectory.canonicalConversationId(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,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
LaunchedEffect(selectedPrivatePeer) {
selectedPrivatePeer?.let { peerID ->
messageText = TextFieldValue(viewModel.conversationDraft(peerID))
}
}
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt
@ -356,14 +362,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

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.Job
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
@ -58,6 +59,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 +111,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 +135,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,12 +203,17 @@ 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 baseConversations = combine(
state.unreadPrivateMessages,
state.privateChats,
state.nickname,
state.connectedPeers
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
conversationPresencePeers,
com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs, unreadCounts ->
val seenStore = seenMessageStore
val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID ->
runCatching {
@ -215,7 +230,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
@ -251,7 +267,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,6 +339,7 @@ class ChatViewModel(
}
init {
observeConversationPresenceWithDisconnectGrace()
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
@ -338,7 +373,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 +393,9 @@ class ChatViewModel(
!seenMessageStore.hasBeenReadLocally(message.id)
}
}
.keys
.keys + unreadCounts
.filterValues { it > 0 }
.keys
} catch (_: Exception) {
state.getUnreadPrivateMessagesValue()
}
@ -380,6 +422,42 @@ 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)
}
}
}
}
}
fun cancelMediaSend(messageId: String) {
// Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state
mediaSendingManager.cancelMediaSend(messageId)
@ -511,6 +589,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 +616,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 +635,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 +665,7 @@ class ChatViewModel(
state.setUnreadPrivateMessages(
state.getUnreadPrivateMessagesValue() - unreadAliases
)
seenMessageStore.remove(deletedMessageIDs)
seenMessageStore.remove(deletion.messageIDs)
val selected = state.getSelectedPrivateChatPeerValue()
if (
@ -594,6 +685,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 +818,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 +843,7 @@ class ChatViewModel(
mesh.sendMessage(messageContent, mentions, channel)
}
}, this)
onAccepted(true)
return
}
@ -699,18 +872,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 +944,7 @@ class ChatViewModel(
mesh.sendMessage(content, mentions, null)
}
}
onAccepted(true)
}
}
@ -1159,6 +1348,7 @@ class ChatViewModel(
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
conversationListPreferences.clearAll()
// Clear seen message store
try {
@ -1169,7 +1359,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
@ -59,18 +76,22 @@ internal fun buildConversationSummaries(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
) ?: return@mapNotNull null
val incoming = messages.filterNot {
it.sender in currentUsers || it.sender == "system"
it.sender.lowercase() in currentUsers || it.sender == "system"
}
val latestIncoming = incoming.maxWithOrNull(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
)
val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs
val unreadCount = if (canonicalUnread) {
incoming.count { !isMessageRead(it) }.coerceAtLeast(1)
} else {
0
}
val aliases = aliasesByCanonicalID[conversationID].orEmpty()
val persistedUnreadCount = unreadCountsByCanonicalID[conversationID.lowercase()] ?: 0
val unreadCount = maxOf(
persistedUnreadCount,
if (canonicalUnread) {
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 +110,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 = latest.sender.lowercase() in currentUsers,
latestDeliveryStatus = latest.deliveryStatus,
transport = if (isNostrConversation) {
DirectMessageTransport.NOSTR
} else {
@ -109,6 +133,7 @@ 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

@ -194,6 +194,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")

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()
@ -102,7 +109,7 @@ 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",
@ -111,6 +118,7 @@ class ConversationDatabaseTest {
)
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 })
}
@ -132,7 +140,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 +196,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 +217,324 @@ 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 `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,75 @@
package com.bitchat.android.services
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.identity.SecureIdentityStateManager
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()}"
}
@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())
}
private fun preferences(): ConversationListPreferences {
val sharedPreferences =
context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
return ConversationListPreferences(
stateManager = SecureIdentityStateManager(sharedPreferences, testOnly = true),
testOnly = true
)
}
}

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,46 @@
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.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 +49,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 +75,30 @@ 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 `public and channel messages preserve best effort admission`() {
val public = BitchatMessage(
@ -57,6 +114,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 +204,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

@ -141,6 +141,47 @@ 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 `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