mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Polish persistent private conversations
This commit is contained in:
parent
9ed5cb26da
commit
a105d884cb
@ -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"
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 -> {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() }
|
||||
|
||||
@ -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
@ -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) {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -74,13 +74,46 @@
|
||||
<!-- Chat header & 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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user