mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge pull request #806 from permissionlesstech/codex/persistent-conversations
Persist private conversations in People
This commit is contained in:
commit
1dae188cdf
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@ -17,6 +17,13 @@
|
||||
-keep class com.bitchat.android.nostr.** { *; }
|
||||
-keep class com.bitchat.android.identity.** { *; }
|
||||
|
||||
# Room loads generated database implementations by name and invokes their no-argument
|
||||
# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's
|
||||
# constructor, causing AndroidX Startup to crash before Application.onCreate.
|
||||
-keepclassmembers class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# Keep Tor implementation (always included)
|
||||
-keep class com.bitchat.android.net.RealTorProvider { *; }
|
||||
|
||||
|
||||
@ -33,6 +33,13 @@ class BitchatApplication : Application() {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Restore private conversations before background transports can deliver new messages.
|
||||
// AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace
|
||||
// newer transport state with an older database snapshot.
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Warm up Nostr identity to ensure npub is available for favorite notifications
|
||||
try {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)
|
||||
|
||||
@ -5,6 +5,8 @@ import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
@ -323,4 +325,54 @@ object FileUtils {
|
||||
Log.e(TAG, "Failed to clear media files", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete app-owned media referenced only by the conversation being removed.
|
||||
*
|
||||
* Canonical-path checks prevent message content from turning this into an arbitrary-file
|
||||
* deletion primitive. Shared paths remain intact while any retained message still references
|
||||
* them.
|
||||
*/
|
||||
fun deleteConversationMedia(
|
||||
context: Context,
|
||||
deletedMessages: Collection<BitchatMessage>,
|
||||
retainedMessages: Collection<BitchatMessage>
|
||||
) {
|
||||
val mediaTypes = setOf(
|
||||
BitchatMessageType.Audio,
|
||||
BitchatMessageType.Image,
|
||||
BitchatMessageType.File
|
||||
)
|
||||
val roots = listOf(context.filesDir, context.cacheDir)
|
||||
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
|
||||
val retainedPaths = retainedMessages
|
||||
.asSequence()
|
||||
.filter { it.type in mediaTypes }
|
||||
.mapNotNull { message ->
|
||||
runCatching { File(message.content.trim()).canonicalPath }.getOrNull()
|
||||
}
|
||||
.toSet()
|
||||
|
||||
deletedMessages
|
||||
.asSequence()
|
||||
.filter { it.type in mediaTypes }
|
||||
.mapNotNull { message ->
|
||||
runCatching { File(message.content.trim()).canonicalFile }.getOrNull()
|
||||
}
|
||||
.distinctBy(File::getPath)
|
||||
.filter { file ->
|
||||
file.path !in retainedPaths &&
|
||||
roots.any { root ->
|
||||
file.path == root.path ||
|
||||
file.path.startsWith(root.path + File.separator)
|
||||
}
|
||||
}
|
||||
.forEach { file ->
|
||||
runCatching {
|
||||
if (file.isFile && !file.delete()) {
|
||||
Log.w(TAG, "Unable to delete conversation media")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -480,21 +480,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Callbacks
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
// Always reflect into process-wide store so UI can hydrate after recreation
|
||||
try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peer = message.senderPeerID ?: ""
|
||||
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
|
||||
}
|
||||
message.channel != null -> {
|
||||
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
|
||||
}
|
||||
else -> {
|
||||
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// Private-message admission is authoritative. In particular, do not forward a
|
||||
// callback or notify after panic mode rejected the message while wiping state.
|
||||
if (
|
||||
!com.bitchat.android.services.IncomingMessageAdmission
|
||||
.admitToAppState(message)
|
||||
) return
|
||||
|
||||
// And forward to UI delegate if attached
|
||||
delegate?.didReceiveMessage(message)
|
||||
|
||||
|
||||
@ -41,7 +41,11 @@ class MeshCore(
|
||||
private val hooks: Hooks = Hooks()
|
||||
) {
|
||||
data class Hooks(
|
||||
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
|
||||
/**
|
||||
* Reflects a decoded message into transport-owned state before delegate dispatch.
|
||||
* Return false to suppress all downstream effects for a rejected message.
|
||||
*/
|
||||
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
|
||||
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
|
||||
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
|
||||
val onReadReceiptSent: ((String) -> Unit)? = null,
|
||||
@ -392,7 +396,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
hooks.onMessageReceived?.invoke(message)
|
||||
if (hooks.onMessageReceived?.invoke(message) == false) return
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,12 @@ object AppShutdownCoordinator {
|
||||
val torStop = async {
|
||||
try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
|
||||
}
|
||||
val conversationFlush = async {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore
|
||||
.awaitConversationPersistence()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Clear AppState in-memory store
|
||||
try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
|
||||
@ -75,6 +81,7 @@ object AppShutdownCoordinator {
|
||||
// Wait up to 5 seconds for shutdown tasks
|
||||
withTimeoutOrNull(5000) {
|
||||
try { torStop.await() } catch (_: Exception) { }
|
||||
try { conversationFlush.await() } catch (_: Exception) { }
|
||||
delay(100)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -15,6 +16,7 @@ object AppStateStore {
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
private var privateWritesSinceGlobalPrune = 0
|
||||
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
|
||||
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
private val _directPeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
@ -30,6 +32,12 @@ object AppStateStore {
|
||||
// Private messages by peerID
|
||||
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
|
||||
private val _readPrivateMessageIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val readPrivateMessageIDs: StateFlow<Set<String>> = _readPrivateMessageIDs.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var conversationRepository: ConversationRepository? = null
|
||||
private var privateConversationWritesSuspended = false
|
||||
|
||||
private val _nickname = MutableStateFlow("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
@ -55,6 +63,26 @@ object AppStateStore {
|
||||
_selectedPrivateChatPeer.value = peerID
|
||||
}
|
||||
|
||||
fun initializeConversationPersistence(context: Context) {
|
||||
val repository = ConversationRepository.getInstance(context.applicationContext)
|
||||
conversationRepository = repository
|
||||
repository.initialize(::restorePrivateConversations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores database state again for a newly created UI, even if Android reused this process
|
||||
* after a controlled shutdown cleared the process-wide state.
|
||||
*/
|
||||
fun reloadConversationPersistence(context: Context) {
|
||||
val repository = ConversationRepository.getInstance(context.applicationContext)
|
||||
conversationRepository = repository
|
||||
repository.reload(::restorePrivateConversations)
|
||||
}
|
||||
|
||||
suspend fun awaitConversationPersistence() {
|
||||
conversationRepository?.awaitPendingWrites()
|
||||
}
|
||||
|
||||
fun setTransportPeers(transportId: String, ids: List<String>) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport[transportId] = ids.toSet()
|
||||
@ -116,17 +144,52 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
PrivateMessageArrivalOrder.record(msg.id)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val list = (map[conversationID] ?: emptyList()) + msg
|
||||
map[conversationID] = list
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
fun addPrivateMessage(
|
||||
peerID: String,
|
||||
msg: BitchatMessage,
|
||||
forceRead: Boolean = false
|
||||
): Boolean = synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return@synchronized false
|
||||
if (seenMessageIds.contains(msg.id)) return@synchronized false
|
||||
seenMessageIds.add(msg.id)
|
||||
PrivateMessageArrivalOrder.record(msg.id)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val list = (map[conversationID] ?: emptyList()) + msg
|
||||
map[conversationID] = list
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
|
||||
val isRead = forceRead ||
|
||||
msg.sender == "system" ||
|
||||
msg.sender == _nickname.value ||
|
||||
_selectedPrivateChatPeer.value
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?.equals(conversationID, ignoreCase = true) == true
|
||||
if (isRead) {
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
|
||||
}
|
||||
val aliases = runCatching {
|
||||
ContactDirectory.aliasesForConversation(peerID) +
|
||||
ContactDirectory.aliasesForConversation(conversationID) +
|
||||
listOfNotNull(msg.senderPeerID)
|
||||
}.getOrDefault(setOf(peerID, conversationID))
|
||||
val displayName = ContactDirectory.resolve(conversationID).displayName
|
||||
?: msg.sender.takeUnless {
|
||||
it.isBlank() || it == "system" || it == _nickname.value
|
||||
}
|
||||
conversationRepository?.upsertMessage(
|
||||
conversationID = conversationID,
|
||||
aliases = aliases,
|
||||
displayName = displayName,
|
||||
message = msg,
|
||||
isRead = isRead
|
||||
)
|
||||
prunePrivateMessagesLocked(conversationID)
|
||||
true
|
||||
}
|
||||
|
||||
fun hasSeenMessage(messageID: String): Boolean = synchronized(this) {
|
||||
messageID in seenMessageIds
|
||||
}
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
@ -141,6 +204,7 @@ object AppStateStore {
|
||||
|
||||
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
var changed = false
|
||||
map.keys.toList().forEach { peer ->
|
||||
@ -158,6 +222,7 @@ object AppStateStore {
|
||||
}
|
||||
if (changed) {
|
||||
_privateMessages.value = map
|
||||
conversationRepository?.updateDeliveryStatus(messageID, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -165,7 +230,16 @@ object AppStateStore {
|
||||
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
|
||||
if (keysToMerge.isEmpty()) return
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
|
||||
val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID)
|
||||
.flatMap { key ->
|
||||
runCatching {
|
||||
ContactDirectory.aliasesForConversation(key).toList()
|
||||
}.getOrDefault(listOf(key))
|
||||
}
|
||||
.toSet()
|
||||
conversationRepository?.mergeAliases(targetConversationID, persistenceAliases)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
|
||||
val targetIds = targetList.map { it.id }.toMutableSet()
|
||||
@ -213,6 +287,98 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun markPrivateMessageRead(messageID: String) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
if (messageID in _readPrivateMessageIDs.value) return
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID
|
||||
conversationRepository?.markRead(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
fun isPrivateMessageRead(messageID: String): Boolean =
|
||||
messageID in _readPrivateMessageIDs.value
|
||||
|
||||
fun deletePrivateConversation(peerOrConversationID: String): Set<String> {
|
||||
synchronized(this) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
|
||||
val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
val aliases = (matchingKeys + peerOrConversationID + canonicalID)
|
||||
.flatMap { key ->
|
||||
runCatching {
|
||||
ContactDirectory.aliasesForConversation(key).toList()
|
||||
}.getOrDefault(listOf(key))
|
||||
}
|
||||
.toSet()
|
||||
val messageIDs = matchingKeys
|
||||
.flatMapTo(linkedSetOf()) { _privateMessages.value[it].orEmpty().map { it.id } }
|
||||
|
||||
// Queue the database deletion while holding the same lock used by addPrivateMessage.
|
||||
// A genuinely new arrival is therefore queued after the delete and starts a fresh chat.
|
||||
conversationRepository?.deleteConversation(canonicalID, aliases)
|
||||
|
||||
val updated = _privateMessages.value.toMutableMap()
|
||||
matchingKeys.forEach(updated::remove)
|
||||
_privateMessages.value = updated
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs
|
||||
if (
|
||||
_selectedPrivateChatPeer.value
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?.equals(canonicalID, ignoreCase = true) == true
|
||||
) {
|
||||
_selectedPrivateChatPeer.value = null
|
||||
}
|
||||
return messageIDs
|
||||
}
|
||||
}
|
||||
|
||||
fun removePrivateMessage(messageID: String) {
|
||||
synchronized(this) {
|
||||
val updated = _privateMessages.value.toMutableMap()
|
||||
var changed = false
|
||||
updated.keys.toList().forEach { conversationID ->
|
||||
val messages = updated[conversationID].orEmpty()
|
||||
if (messages.any { it.id == messageID }) {
|
||||
val remaining = messages.filterNot { it.id == messageID }
|
||||
if (remaining.isEmpty()) {
|
||||
updated.remove(conversationID)
|
||||
} else {
|
||||
updated[conversationID] = remaining
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) return
|
||||
conversationRepository?.deleteMessage(messageID)
|
||||
_privateMessages.value = updated
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically hides all conversations, rejects in-flight transport deliveries, then waits for
|
||||
* every earlier database write and the panic wipe itself to finish.
|
||||
*/
|
||||
suspend fun panicClearPrivateConversations(): Boolean {
|
||||
val repository = synchronized(this) {
|
||||
privateConversationWritesSuspended = true
|
||||
_privateMessages.value = emptyMap()
|
||||
_readPrivateMessageIDs.value = emptySet()
|
||||
_selectedPrivateChatPeer.value = null
|
||||
conversationRepository
|
||||
}
|
||||
return repository?.clearAllAndWait() ?: true
|
||||
}
|
||||
|
||||
fun resumePrivateConversationsAfterPanic() {
|
||||
synchronized(this) {
|
||||
privateConversationWritesSuspended = false
|
||||
}
|
||||
}
|
||||
|
||||
fun addChannelMessage(channel: String, msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
@ -230,12 +396,14 @@ object AppStateStore {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
PrivateMessageArrivalOrder.clear()
|
||||
privateWritesSinceGlobalPrune = 0
|
||||
peerIdsByTransport.clear()
|
||||
directPeerIdsByTransport.clear()
|
||||
_peers.value = emptyList()
|
||||
_directPeers.value = emptySet()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
_readPrivateMessageIDs.value = emptySet()
|
||||
_channelMessages.value = emptyMap()
|
||||
_nickname.value = ""
|
||||
_selectedPrivateChatPeer.value = null
|
||||
@ -252,4 +420,148 @@ object AppStateStore {
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
|
||||
private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val liveChats = _privateMessages.value
|
||||
val liveMessageIDs = liveChats.values.flatten().map { it.id }
|
||||
PrivateMessageArrivalOrder.restore(snapshot.arrivalOrder, liveMessageIDs)
|
||||
|
||||
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
|
||||
snapshot.chats.forEach { (conversationID, messages) ->
|
||||
merged.getOrPut(conversationID) { mutableListOf() }.addAll(messages)
|
||||
}
|
||||
liveChats.forEach { (conversationID, messages) ->
|
||||
val target = merged.getOrPut(conversationID) { mutableListOf() }
|
||||
messages
|
||||
.filterNot { it.id in snapshot.deletedMessageIDs }
|
||||
.forEach { live ->
|
||||
val existingIndex = target.indexOfFirst { it.id == live.id }
|
||||
if (existingIndex >= 0) {
|
||||
target[existingIndex] = live
|
||||
} else {
|
||||
target.add(live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged.values.flatten().forEach { seenMessageIds.add(it.id) }
|
||||
seenMessageIds.addAll(snapshot.deletedMessageIDs)
|
||||
_readPrivateMessageIDs.value =
|
||||
(snapshot.readMessageIDs + _readPrivateMessageIDs.value) -
|
||||
snapshot.deletedMessageIDs
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(
|
||||
merged.mapValues { (_, messages) ->
|
||||
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prunePrivateMessagesLocked(recentConversationID: String) {
|
||||
val chats = _privateMessages.value.toMutableMap()
|
||||
val removedIDs = linkedSetOf<String>()
|
||||
val recentKey = chats.keys.firstOrNull { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(recentConversationID, ignoreCase = true)
|
||||
}
|
||||
if (recentKey != null) {
|
||||
val messages = chats[recentKey].orEmpty()
|
||||
val excess =
|
||||
messages.size - ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION
|
||||
if (excess > 0) {
|
||||
val removable = messages
|
||||
.dropLast(1)
|
||||
.sortedWith(privateMessagePruneComparator())
|
||||
.take(excess)
|
||||
.mapTo(linkedSetOf()) { it.id }
|
||||
removedIDs.addAll(removable)
|
||||
chats[recentKey] = messages.filterNot { it.id in removable }
|
||||
}
|
||||
}
|
||||
|
||||
privateWritesSinceGlobalPrune += 1
|
||||
if (privateWritesSinceGlobalPrune >= 64) {
|
||||
privateWritesSinceGlobalPrune = 0
|
||||
var totalMessages = chats.values.sumOf { it.size }
|
||||
var totalPayloadBytes = chats.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.sumOf(::privateMessagePayloadBytes)
|
||||
if (
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
) {
|
||||
val candidates = chats.values
|
||||
.asSequence()
|
||||
.flatMap { messages -> messages.dropLast(1).asSequence() }
|
||||
.sortedWith(privateMessagePruneComparator())
|
||||
.iterator()
|
||||
while (
|
||||
candidates.hasNext() &&
|
||||
(
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
)
|
||||
) {
|
||||
val candidate = candidates.next()
|
||||
if (!removedIDs.add(candidate.id)) continue
|
||||
totalMessages -= 1
|
||||
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
|
||||
}
|
||||
if (
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
) {
|
||||
// Enforce the hard bound even when every conversation contains only its
|
||||
// newest message. Read conversations still sort ahead of unread ones.
|
||||
val latestCandidates = chats.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.filterNot { it.id in removedIDs }
|
||||
.sortedWith(privateMessagePruneComparator())
|
||||
.iterator()
|
||||
while (
|
||||
latestCandidates.hasNext() &&
|
||||
(
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
)
|
||||
) {
|
||||
val candidate = latestCandidates.next()
|
||||
if (!removedIDs.add(candidate.id)) continue
|
||||
totalMessages -= 1
|
||||
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
|
||||
}
|
||||
}
|
||||
if (removedIDs.isNotEmpty()) {
|
||||
chats.keys.toList().forEach { key ->
|
||||
chats[key] = chats[key].orEmpty().filterNot {
|
||||
it.id in removedIDs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removedIDs.isNotEmpty()) {
|
||||
_privateMessages.value = chats.filterValues { it.isNotEmpty() }
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - removedIDs
|
||||
}
|
||||
}
|
||||
|
||||
private fun privateMessagePruneComparator(): Comparator<BitchatMessage> =
|
||||
compareByDescending<BitchatMessage> {
|
||||
it.id in _readPrivateMessageIDs.value
|
||||
}.thenBy {
|
||||
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MAX_VALUE
|
||||
}
|
||||
|
||||
private fun privateMessagePayloadBytes(message: BitchatMessage): Long =
|
||||
message.content.toByteArray(Charsets.UTF_8).size.toLong() +
|
||||
(message.encryptedContent?.size ?: 0) +
|
||||
message.mentions.orEmpty().sumOf {
|
||||
it.toByteArray(Charsets.UTF_8).size
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,36 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Reflects an incoming transport message into process-wide state before any downstream effects.
|
||||
*
|
||||
* Private-message admission is authoritative: a duplicate or a message rejected while panic mode
|
||||
* is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications.
|
||||
* Public and channel messages retain their existing best-effort behavior if state reflection fails.
|
||||
*/
|
||||
internal object IncomingMessageAdmission {
|
||||
fun admitToAppState(message: BitchatMessage): Boolean = try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peerID = message.senderPeerID?.takeIf(String::isNotBlank)
|
||||
?: return false
|
||||
AppStateStore.addPrivateMessage(peerID, message)
|
||||
}
|
||||
|
||||
message.channel != null -> {
|
||||
AppStateStore.addChannelMessage(message.channel, message)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
AppStateStore.addPublicMessage(message)
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Preserve the pre-existing best-effort dispatch for public/channel messages, but never
|
||||
// bypass private-message admission when persistence or canonicalization fails.
|
||||
!message.isPrivate
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,22 @@ internal object PrivateMessageArrivalOrder {
|
||||
}
|
||||
}
|
||||
|
||||
fun restore(persistedOrder: List<String>, liveMessageIDs: List<String>) {
|
||||
synchronized(this) {
|
||||
sequenceByMessageID.clear()
|
||||
nextSequence = 0L
|
||||
(persistedOrder + liveMessageIDs).forEach { messageID ->
|
||||
if (messageID !in sequenceByMessageID) {
|
||||
sequenceByMessageID[messageID] = nextSequence++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sequenceOf(messageID: String): Long? = synchronized(this) {
|
||||
sequenceByMessageID[messageID]
|
||||
}
|
||||
|
||||
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
|
||||
synchronized(this) {
|
||||
if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
@ -20,6 +21,8 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
private const val STORAGE_KEY = "seen_message_store_v1"
|
||||
private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS
|
||||
|
||||
// The constructor always receives applicationContext, so process lifetime is intentional.
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Volatile private var INSTANCE: SeenMessageStore? = null
|
||||
fun getInstance(appContext: Context): SeenMessageStore {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
@ -54,6 +57,7 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
locallyRead.add(id)
|
||||
trim(locallyRead)
|
||||
}
|
||||
AppStateStore.markPrivateMessageRead(id)
|
||||
persist()
|
||||
}
|
||||
|
||||
@ -65,6 +69,14 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun remove(ids: Set<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
delivered.removeAll(ids)
|
||||
locallyRead.removeAll(ids)
|
||||
readReceiptsSent.removeAll(ids)
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
locallyRead.clear()
|
||||
|
||||
@ -193,20 +193,29 @@ class ChatViewModel(
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
|
||||
internal val conversations: StateFlow<List<ConversationSummary>> = combine(
|
||||
state.unreadPrivateMessages,
|
||||
state.privateChats,
|
||||
state.nickname,
|
||||
state.connectedPeers
|
||||
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
|
||||
val seenStore = seenMessageStore
|
||||
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
buildUnreadConversationSummaries(
|
||||
val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID ->
|
||||
runCatching {
|
||||
ContactDirectory.aliasesForConversation(peerID) +
|
||||
ContactDirectory.canonicalConversationId(peerID)
|
||||
}.getOrDefault(setOf(peerID))
|
||||
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
}
|
||||
buildConversationSummaries(
|
||||
unreadConversationIDs = unreadConversationIDs,
|
||||
privateChats = chats,
|
||||
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
|
||||
canonicalize = ContactDirectory::canonicalConversationId,
|
||||
isMessageRead = { message -> seenStore.hasBeenReadLocally(message.id) }
|
||||
isMessageRead = { message ->
|
||||
com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) ||
|
||||
seenStore.hasBeenReadLocally(message.id)
|
||||
}
|
||||
).map { summary ->
|
||||
val resolution = ContactDirectory.resolve(summary.conversationID)
|
||||
val resolvedNostrPubkey = summary.nostrPubkey
|
||||
@ -221,6 +230,11 @@ class ChatViewModel(
|
||||
?.let(ContactIdentityResolver::nostrAliasForPubkey)
|
||||
?.let(::add)
|
||||
}.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
val connectedPeerID = connectedIdentitiesByPeer.entries
|
||||
.firstOrNull { (_, connectedAliases) ->
|
||||
connectedAliases.any(aliases::contains)
|
||||
}
|
||||
?.key
|
||||
|
||||
summary.copy(
|
||||
displayName = resolution.displayName
|
||||
@ -230,13 +244,14 @@ class ChatViewModel(
|
||||
?: summary.displayName,
|
||||
nostrPubkey = resolvedNostrPubkey,
|
||||
identityAliases = aliases,
|
||||
isConnected = aliases.any(connectedPeerIDSet::contains),
|
||||
isConnected = connectedPeerID != null,
|
||||
connectedPeerID = connectedPeerID,
|
||||
sourceGeohash = aliases
|
||||
.asSequence()
|
||||
.mapNotNull(GeohashConversationRegistry::get)
|
||||
.firstOrNull()
|
||||
)
|
||||
}
|
||||
}.let(::sortConversationSummaries)
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
@ -294,6 +309,12 @@ class ChatViewModel(
|
||||
loadAndInitialize()
|
||||
ContactDirectory.initialize(getApplication()) { mesh }
|
||||
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
|
||||
// Application startup performs the initial restore. Repeat it for every new UI owner
|
||||
// because a quick reopen can reuse a process whose in-memory state was cleared during
|
||||
// controlled shutdown.
|
||||
com.bitchat.android.services.AppStateStore.reloadConversationPersistence(
|
||||
getApplication()
|
||||
)
|
||||
// Mark queued private messages as failed when the router gives up on them
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID ->
|
||||
@ -327,6 +348,8 @@ class ChatViewModel(
|
||||
messages.any { message ->
|
||||
message.sender != myNick &&
|
||||
message.sender != "system" &&
|
||||
!com.bitchat.android.services.AppStateStore
|
||||
.isPrivateMessageRead(message.id) &&
|
||||
!seenMessageStore.hasBeenReadLocally(message.id)
|
||||
}
|
||||
}
|
||||
@ -445,7 +468,6 @@ class ChatViewModel(
|
||||
override fun onCleared() {
|
||||
geohashViewModel.shutdownUiSubscriptions()
|
||||
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null)
|
||||
super.onCleared()
|
||||
// Note: Mesh service lifecycle is now managed by MainActivity
|
||||
}
|
||||
|
||||
@ -518,6 +540,62 @@ class ChatViewModel(
|
||||
hidePrivateChatSheet()
|
||||
}
|
||||
|
||||
fun deletePrivateConversation(peerOrConversationID: String) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
|
||||
val deletedMessages = state.getPrivateChatsValue()
|
||||
.filterKeys { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
.values
|
||||
.flatten()
|
||||
val unreadAliases = matchingUnreadAliases(
|
||||
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
|
||||
canonicalConversationID = canonicalID,
|
||||
canonicalize = ContactDirectory::canonicalConversationId
|
||||
)
|
||||
val deletedMessageIDs =
|
||||
com.bitchat.android.services.AppStateStore.deletePrivateConversation(canonicalID)
|
||||
val retainedMessages =
|
||||
com.bitchat.android.services.AppStateStore.privateMessages.value.values.flatten()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
com.bitchat.android.features.file.FileUtils.deleteConversationMedia(
|
||||
context = getApplication(),
|
||||
deletedMessages = deletedMessages,
|
||||
retainedMessages = retainedMessages
|
||||
)
|
||||
}
|
||||
|
||||
state.setPrivateChats(
|
||||
ContactDirectory.canonicalizePrivateChats(
|
||||
com.bitchat.android.services.AppStateStore.privateMessages.value
|
||||
)
|
||||
)
|
||||
state.setUnreadPrivateMessages(
|
||||
state.getUnreadPrivateMessagesValue() - unreadAliases
|
||||
)
|
||||
seenMessageStore.remove(deletedMessageIDs)
|
||||
|
||||
val selected = state.getSelectedPrivateChatPeerValue()
|
||||
if (
|
||||
selected != null &&
|
||||
ContactDirectory.canonicalConversationId(selected)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
) {
|
||||
privateChatManager.endPrivateChat()
|
||||
setCurrentPrivateChatPeer(null)
|
||||
}
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
if (
|
||||
sheetPeer != null &&
|
||||
ContactDirectory.canonicalConversationId(sheetPeer)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
) {
|
||||
hidePrivateChatSheet()
|
||||
}
|
||||
clearNotificationsForSender(canonicalID)
|
||||
}
|
||||
|
||||
// MARK: - Open Latest Unread Private Chat
|
||||
|
||||
fun openLatestUnreadPrivateChat() {
|
||||
@ -1041,8 +1119,22 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
|
||||
private var panicClearInProgress = false
|
||||
|
||||
fun panicClearAllData() {
|
||||
if (panicClearInProgress) return
|
||||
panicClearInProgress = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
performPanicClearAllData()
|
||||
} finally {
|
||||
panicClearInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performPanicClearAllData() {
|
||||
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
|
||||
try {
|
||||
com.bitchat.android.geohash.LocationChannelManager
|
||||
@ -1053,8 +1145,16 @@ class ChatViewModel(
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
// become actionable against the fresh post-wipe identity.
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
|
||||
|
||||
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
|
||||
// any transport callback already in flight until the fresh identity is ready.
|
||||
clearAllMeshServiceData()
|
||||
val conversationsCleared =
|
||||
com.bitchat.android.services.AppStateStore
|
||||
.panicClearPrivateConversations()
|
||||
|
||||
// Clear all UI managers
|
||||
com.bitchat.android.services.AppStateStore.clear()
|
||||
messageManager.clearAllMessages()
|
||||
channelManager.clearAllChannels()
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
@ -1065,9 +1165,6 @@ class ChatViewModel(
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Clear all mesh service data
|
||||
clearAllMeshServiceData()
|
||||
|
||||
// Clear all cryptographic data
|
||||
clearAllCryptographicData()
|
||||
|
||||
@ -1099,8 +1196,17 @@ class ChatViewModel(
|
||||
val newNickname = "anon${Random.nextInt(1000, 9999)}"
|
||||
state.setNickname(newNickname)
|
||||
dataManager.saveNickname(newNickname)
|
||||
|
||||
|
||||
if (!conversationsCleared) {
|
||||
// Privacy wins over availability: keep private-message admission and transports
|
||||
// stopped if SQLite could not prove that the conversation history was erased.
|
||||
Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Recreate mesh service with fresh identity
|
||||
com.bitchat.android.services.AppStateStore
|
||||
.resumePrivateConversationsAfterPanic()
|
||||
recreateMeshServiceAfterPanic()
|
||||
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
|
||||
|
||||
@ -189,6 +189,9 @@ class CommandProcessor(
|
||||
// Clear private chat
|
||||
val peerID = state.getSelectedPrivateChatPeerValue()!!
|
||||
messageManager.clearPrivateMessages(peerID)
|
||||
// `/clear` removes history but should not navigate away from the chat the
|
||||
// command was issued in. A later message will repopulate this conversation.
|
||||
state.setSelectedPrivateChatPeer(peerID)
|
||||
}
|
||||
state.getCurrentChannelValue() != null -> {
|
||||
// Clear channel messages
|
||||
|
||||
135
app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt
Normal file
135
app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt
Normal file
@ -0,0 +1,135 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.PrivateMessageArrivalOrder
|
||||
|
||||
/**
|
||||
* Presence-independent presentation state for every retained private conversation.
|
||||
*/
|
||||
internal data class ConversationSummary(
|
||||
val conversationID: String,
|
||||
val displayName: String,
|
||||
val unreadCount: Int,
|
||||
val latestMessageAt: Long,
|
||||
val latestActivityOrder: Long,
|
||||
val latestMessageType: BitchatMessageType,
|
||||
val latestMessagePreview: String,
|
||||
val transport: DirectMessageTransport,
|
||||
val nostrPubkey: String?,
|
||||
val identityAliases: Set<String>,
|
||||
val isConnected: Boolean = false,
|
||||
val connectedPeerID: String? = null,
|
||||
val sourceGeohash: String? = null
|
||||
)
|
||||
|
||||
internal fun buildConversationSummaries(
|
||||
unreadConversationIDs: Set<String>,
|
||||
privateChats: Map<String, List<BitchatMessage>>,
|
||||
currentUserIdentifiers: Set<String>,
|
||||
canonicalize: (String) -> String,
|
||||
isMessageRead: (BitchatMessage) -> Boolean
|
||||
): List<ConversationSummary> {
|
||||
if (privateChats.isEmpty()) return emptyList()
|
||||
|
||||
val currentUsers = currentUserIdentifiers.filterTo(mutableSetOf()) { it.isNotBlank() }
|
||||
val unreadCanonicalIDs = unreadConversationIDs
|
||||
.mapTo(mutableSetOf()) { canonicalize(it).lowercase() }
|
||||
val aliasesByCanonicalID = linkedMapOf<String, MutableSet<String>>()
|
||||
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
|
||||
|
||||
privateChats.forEach { (sourceID, messages) ->
|
||||
val canonicalID = canonicalize(sourceID)
|
||||
aliasesByCanonicalID
|
||||
.getOrPut(canonicalID) { linkedSetOf() }
|
||||
.add(sourceID)
|
||||
messagesByCanonicalID
|
||||
.getOrPut(canonicalID) { mutableListOf() }
|
||||
.addAll(messages)
|
||||
}
|
||||
|
||||
return messagesByCanonicalID.mapNotNull { (conversationID, sourceMessages) ->
|
||||
val messages = sourceMessages.distinctBy { it.id }
|
||||
if (messages.isEmpty()) return@mapNotNull null
|
||||
|
||||
fun activityOrder(message: BitchatMessage): Long =
|
||||
PrivateMessageArrivalOrder.sequenceOf(message.id) ?: message.timestamp.time
|
||||
|
||||
val latest = messages.maxWithOrNull(
|
||||
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
|
||||
) ?: return@mapNotNull null
|
||||
val incoming = messages.filterNot {
|
||||
it.sender in currentUsers || it.sender == "system"
|
||||
}
|
||||
val latestIncoming = incoming.maxWithOrNull(
|
||||
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
|
||||
)
|
||||
val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs
|
||||
val unreadCount = if (canonicalUnread) {
|
||||
incoming.count { !isMessageRead(it) }.coerceAtLeast(1)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val aliases = aliasesByCanonicalID[conversationID].orEmpty()
|
||||
val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey
|
||||
val isNostrConversation = nostrPubkey != null ||
|
||||
aliases.any(::isNostrConversationKey) ||
|
||||
isNostrConversationKey(conversationID)
|
||||
val displayName = latestIncoming
|
||||
?.sender
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: latest.recipientNickname
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: latest.sender.takeIf {
|
||||
it.isNotBlank() && it !in currentUsers && it != "system"
|
||||
}
|
||||
?: conversationID.take(12)
|
||||
|
||||
ConversationSummary(
|
||||
conversationID = conversationID,
|
||||
displayName = displayName,
|
||||
unreadCount = unreadCount,
|
||||
latestMessageAt = latest.timestamp.time,
|
||||
latestActivityOrder = activityOrder(latest),
|
||||
latestMessageType = latest.type,
|
||||
latestMessagePreview = latest.conversationPreview(),
|
||||
transport = if (isNostrConversation) {
|
||||
DirectMessageTransport.NOSTR
|
||||
} else {
|
||||
DirectMessageTransport.MESH
|
||||
},
|
||||
nostrPubkey = nostrPubkey,
|
||||
identityAliases = (aliases + conversationID)
|
||||
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sortConversationSummaries(
|
||||
conversations: List<ConversationSummary>
|
||||
): List<ConversationSummary> = conversations.sortedWith(
|
||||
compareByDescending<ConversationSummary> { it.isConnected }
|
||||
.thenByDescending { it.unreadCount > 0 }
|
||||
.thenByDescending { it.latestActivityOrder }
|
||||
.thenBy { it.displayName.lowercase() }
|
||||
.thenBy { it.conversationID }
|
||||
)
|
||||
|
||||
private fun isNostrConversationKey(value: String): Boolean =
|
||||
value.startsWith("nostr_") || value.startsWith("nostr:")
|
||||
|
||||
private fun BitchatMessage.conversationPreview(): String {
|
||||
val preview = when (type) {
|
||||
BitchatMessageType.File -> content
|
||||
.substringAfterLast('/')
|
||||
.substringAfterLast('\\')
|
||||
else -> content
|
||||
}
|
||||
return preview
|
||||
.replace(CONVERSATION_PREVIEW_WHITESPACE, " ")
|
||||
.trim()
|
||||
.take(MAX_CONVERSATION_PREVIEW_LENGTH)
|
||||
}
|
||||
|
||||
private val CONVERSATION_PREVIEW_WHITESPACE = Regex("\\s+")
|
||||
private const val MAX_CONVERSATION_PREVIEW_LENGTH = 240
|
||||
@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@ -32,9 +33,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@ -50,6 +57,7 @@ import com.bitchat.android.favorites.FavoriteRelationship
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
@ -88,16 +96,32 @@ fun MeshPeerListSheet(
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle()
|
||||
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
||||
val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val geohashPeopleCount = geohashPeople.size
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
val unreadIdentityAliases = remember(unreadConversations) {
|
||||
unreadConversations
|
||||
val directPeerIdentityIDs = remember(peerDirect) {
|
||||
peerDirect
|
||||
.asSequence()
|
||||
.filter { (_, isDirect) -> isDirect }
|
||||
.mapTo(mutableSetOf()) { (peerID, _) -> peerID.lowercase() }
|
||||
}
|
||||
val wifiAwareIdentityIDs = remember(wifiAwarePeerIDs) {
|
||||
wifiAwarePeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
}
|
||||
val conversationIdentityAliases = remember(conversations) {
|
||||
conversations
|
||||
.flatMapTo(mutableSetOf()) { it.identityAliases }
|
||||
}
|
||||
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
|
||||
peerID.lowercase() in unreadIdentityAliases
|
||||
val aliases = runCatching {
|
||||
ContactDirectory.aliasesForConversation(peerID)
|
||||
}.getOrDefault(setOf(peerID))
|
||||
aliases.any { it.lowercase() in conversationIdentityAliases }
|
||||
}
|
||||
var pendingConversationDelete by remember {
|
||||
mutableStateOf<ConversationSummary?>(null)
|
||||
}
|
||||
|
||||
// Bottom sheet state
|
||||
@ -134,15 +158,20 @@ fun MeshPeerListSheet(
|
||||
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
|
||||
}
|
||||
|
||||
if (unreadConversations.isNotEmpty()) {
|
||||
item(key = "unread_private_messages_section") {
|
||||
UnreadDirectMessagesSection(
|
||||
conversations = unreadConversations,
|
||||
if (conversations.isNotEmpty()) {
|
||||
item(key = "private_conversations_section") {
|
||||
DirectMessagesSection(
|
||||
conversations = conversations,
|
||||
directPeerIdentityIDs = directPeerIdentityIDs,
|
||||
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { conversationID ->
|
||||
viewModel.showPrivateChatSheet(conversationID)
|
||||
onDismiss()
|
||||
},
|
||||
onDeleteRequested = { conversation ->
|
||||
pendingConversationDelete = conversation
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
@ -156,7 +185,7 @@ fun MeshPeerListSheet(
|
||||
iconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
title = stringResource(R.string.channels),
|
||||
modifier = Modifier.padding(
|
||||
top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp
|
||||
top = if (conversations.isNotEmpty()) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
@ -209,11 +238,11 @@ fun MeshPeerListSheet(
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss,
|
||||
excludedIdentityAliases = unreadIdentityAliases,
|
||||
excludedIdentityAliases = conversationIdentityAliases,
|
||||
modifier = Modifier.padding(
|
||||
top = if (
|
||||
joinedChannels.isNotEmpty() ||
|
||||
unreadConversations.isNotEmpty()
|
||||
conversations.isNotEmpty()
|
||||
) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
@ -224,7 +253,7 @@ fun MeshPeerListSheet(
|
||||
modifier = Modifier.padding(
|
||||
top = if (
|
||||
joinedChannels.isNotEmpty() ||
|
||||
unreadConversations.isNotEmpty()
|
||||
conversations.isNotEmpty()
|
||||
) 20.dp else 8.dp
|
||||
),
|
||||
connectedPeers = visibleConnectedPeers,
|
||||
@ -235,7 +264,7 @@ fun MeshPeerListSheet(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
peopleCount = peopleCount,
|
||||
excludedIdentityAliases = unreadIdentityAliases,
|
||||
excludedIdentityAliases = conversationIdentityAliases,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
@ -273,6 +302,54 @@ fun MeshPeerListSheet(
|
||||
}
|
||||
}
|
||||
|
||||
pendingConversationDelete?.let { conversation ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingConversationDelete = null },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.delete_conversation_title),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.delete_conversation_message,
|
||||
conversation.displayName
|
||||
),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deletePrivateConversation(conversation.conversationID)
|
||||
pendingConversationDelete = null
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingConversationDelete = null }) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -612,23 +689,32 @@ fun PeopleSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UnreadDirectMessagesSection(
|
||||
conversations: List<UnreadConversationSummary>,
|
||||
private fun DirectMessagesSection(
|
||||
conversations: List<ConversationSummary>,
|
||||
directPeerIdentityIDs: Set<String>,
|
||||
wifiAwareIdentityIDs: Set<String>,
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit,
|
||||
onDeleteRequested: (ConversationSummary) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val deleteDescription = stringResource(R.string.delete_conversation_action)
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_envelope,
|
||||
title = stringResource(R.string.cd_unread_private_messages)
|
||||
title = stringResource(R.string.conversations)
|
||||
)
|
||||
|
||||
Surface(
|
||||
@ -646,87 +732,106 @@ private fun UnreadDirectMessagesSection(
|
||||
Column {
|
||||
if (index > 0) SheetCardDivider()
|
||||
|
||||
val subtitle = when {
|
||||
conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}"
|
||||
conversation.transport == DirectMessageTransport.NOSTR ->
|
||||
stringResource(R.string.cd_reachable_via_nostr)
|
||||
!conversation.isConnected ->
|
||||
stringResource(R.string.cd_offline_mesh_chat)
|
||||
else -> null
|
||||
}
|
||||
val peerIdentity = conversation.nostrPubkey
|
||||
?.let(viewModel::peerIdentityForNostrPubkey)
|
||||
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
|
||||
val assignedColor = colorForPeer(peerIdentity, palette)
|
||||
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onPrivateChatStart(conversation.conversationID)
|
||||
}
|
||||
.padding(
|
||||
horizontal = SheetRowHorizontal,
|
||||
vertical = SheetRowVertical
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
val dismissState = rememberSwipeToDismissBoxState()
|
||||
val favoriteTargetID =
|
||||
conversation.connectedPeerID ?: conversation.conversationID
|
||||
val favoriteRelationship = remember(
|
||||
conversation.identityAliases,
|
||||
favoritePeers,
|
||||
peerFavoritedUs
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.accentOrange
|
||||
conversation.identityAliases
|
||||
.asSequence()
|
||||
.mapNotNull { alias ->
|
||||
runCatching {
|
||||
FavoritesPersistenceService.shared
|
||||
.getFavoriteStatus(alias)
|
||||
}.getOrNull()
|
||||
}
|
||||
.firstOrNull()
|
||||
}
|
||||
val fingerprint = conversation.connectedPeerID
|
||||
?.let(peerFingerprints::get)
|
||||
?: conversation.identityAliases
|
||||
.asSequence()
|
||||
.mapNotNull(peerFingerprints::get)
|
||||
.firstOrNull()
|
||||
?: ContactIdentityResolver
|
||||
.fingerprintFromContactConversationId(
|
||||
conversation.conversationID
|
||||
)
|
||||
?: favoriteRelationship?.peerNoisePublicKey?.let {
|
||||
ContactIdentityResolver.fingerprintHex(it)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val isFavorite = if (fingerprint != null) {
|
||||
fingerprint in favoritePeers
|
||||
} else {
|
||||
viewModel.isFavorite(favoriteTargetID)
|
||||
}
|
||||
val theyFavoritedUs =
|
||||
(fingerprint != null && fingerprint in peerFavoritedUs) ||
|
||||
favoriteRelationship?.theyFavoritedUs == true
|
||||
LaunchedEffect(dismissState.currentValue, conversation.conversationID) {
|
||||
if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onDeleteRequested(conversation)
|
||||
dismissState.reset()
|
||||
}
|
||||
}
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = true,
|
||||
enableDismissFromEndToStart = true,
|
||||
backgroundContent = {
|
||||
val alignment = when (dismissState.dismissDirection) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.errorContainer)
|
||||
.padding(horizontal = SheetRowHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = truncateNickname(baseNameRaw),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = assignedColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
horizontalArrangement = when (alignment) {
|
||||
Alignment.CenterStart -> Arrangement.Start
|
||||
else -> Arrangement.End
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitle != null) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = deleteDescription,
|
||||
tint = colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(PeerRowIconSize)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
text = stringResource(R.string.delete),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UnreadBadge(
|
||||
count = conversation.unreadCount,
|
||||
colorScheme = colorScheme
|
||||
) {
|
||||
ConversationRow(
|
||||
conversation = conversation,
|
||||
directPeerIdentityIDs = directPeerIdentityIDs,
|
||||
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
|
||||
viewModel = viewModel,
|
||||
isFavorite = isFavorite,
|
||||
theyFavoritedUs = theyFavoritedUs,
|
||||
deleteDescription = deleteDescription,
|
||||
onClick = {
|
||||
onPrivateChatStart(conversation.conversationID)
|
||||
},
|
||||
onToggleFavorite = {
|
||||
viewModel.toggleFavorite(favoriteTargetID)
|
||||
},
|
||||
onDeleteRequested = {
|
||||
onDeleteRequested(conversation)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -735,6 +840,203 @@ private fun UnreadDirectMessagesSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationRow(
|
||||
conversation: ConversationSummary,
|
||||
directPeerIdentityIDs: Set<String>,
|
||||
wifiAwareIdentityIDs: Set<String>,
|
||||
viewModel: ChatViewModel,
|
||||
isFavorite: Boolean,
|
||||
theyFavoritedUs: Boolean,
|
||||
deleteDescription: String,
|
||||
onClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onDeleteRequested: () -> Unit
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val liveIdentityIDs = conversation.identityAliases +
|
||||
listOfNotNull(conversation.connectedPeerID?.lowercase())
|
||||
val isWifiAware = liveIdentityIDs.any(wifiAwareIdentityIDs::contains)
|
||||
val isDirect = liveIdentityIDs.any(directPeerIdentityIDs::contains) ||
|
||||
conversation.connectedPeerID?.let { peerID ->
|
||||
runCatching {
|
||||
viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true
|
||||
}.getOrDefault(false)
|
||||
} == true
|
||||
val connectionDescription = meshConnectionDescription(
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
)
|
||||
val messagePreview = when (conversation.latestMessageType) {
|
||||
BitchatMessageType.Image -> stringResource(R.string.notification_sent_image)
|
||||
BitchatMessageType.Audio -> stringResource(R.string.notification_sent_voice)
|
||||
BitchatMessageType.File -> conversation.latestMessagePreview
|
||||
.takeIf(String::isNotBlank)
|
||||
?.let { "📎 $it" }
|
||||
?: stringResource(R.string.notification_sent_file)
|
||||
BitchatMessageType.Message -> conversation.latestMessagePreview.ifBlank { "…" }
|
||||
}
|
||||
val presenceDescription = when {
|
||||
conversation.isConnected -> connectionDescription
|
||||
conversation.transport == DirectMessageTransport.NOSTR ->
|
||||
stringResource(R.string.offline_reachable_via_nostr)
|
||||
else -> stringResource(R.string.offline_not_in_mesh)
|
||||
}
|
||||
val peerIdentity = conversation.nostrPubkey
|
||||
?.let(viewModel::peerIdentityForNostrPubkey)
|
||||
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
|
||||
val assignedColor = colorForPeer(peerIdentity, palette)
|
||||
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
|
||||
val relativeTime = remember(conversation.latestMessageAt) {
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
conversation.latestMessageAt,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
).toString()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.surface)
|
||||
.semantics {
|
||||
stateDescription = presenceDescription
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(deleteDescription) {
|
||||
onDeleteRequested()
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.padding(
|
||||
horizontal = SheetRowHorizontal,
|
||||
vertical = SheetRowVertical
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
conversation.isConnected -> Icon(
|
||||
painter = painterResource(
|
||||
conversationTransportIcon(
|
||||
isReachedOverInternet = false,
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
)
|
||||
),
|
||||
contentDescription = connectionDescription,
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
conversation.transport == DirectMessageTransport.NOSTR -> Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_globe),
|
||||
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.accentPurple
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = stringResource(R.string.offline_not_in_mesh),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = truncateNickname(baseNameRaw),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (conversation.unreadCount > 0) {
|
||||
FontWeight.Bold
|
||||
} else {
|
||||
FontWeight.Medium
|
||||
},
|
||||
color = assignedColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = messagePreview,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
Text(
|
||||
text = " · $relativeTime",
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UnreadBadge(
|
||||
count = conversation.unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clickable(onClick = onToggleFavorite),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isFavorite) {
|
||||
R.drawable.ic_spec_star_filled
|
||||
} else {
|
||||
R.drawable.ic_spec_star
|
||||
}
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
if (isFavorite) {
|
||||
R.string.cd_remove_favorite
|
||||
} else {
|
||||
R.string.cd_add_favorite
|
||||
}
|
||||
),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = if (isFavorite || theyFavoritedUs) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
palette.textTertiary
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
peerID: String,
|
||||
@ -755,6 +1057,10 @@ private fun PeerItem(
|
||||
showHashSuffix: Boolean = true
|
||||
) {
|
||||
val currentNickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val connectionDescription = meshConnectionDescription(
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
)
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseNameRaw, suffixRaw) = splitSuffix(displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
@ -816,11 +1122,7 @@ private fun PeerItem(
|
||||
isDirect = isDirect
|
||||
)
|
||||
),
|
||||
contentDescription = when {
|
||||
isWifiAware -> "Direct Wi-Fi Aware"
|
||||
isDirect -> "Direct Bluetooth"
|
||||
else -> "Routed"
|
||||
},
|
||||
contentDescription = connectionDescription,
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
@ -884,6 +1186,18 @@ private fun PeerItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun meshConnectionDescription(
|
||||
isWifiAware: Boolean,
|
||||
isDirect: Boolean
|
||||
): String = stringResource(
|
||||
when {
|
||||
isWifiAware -> R.string.cd_direct_wifi_aware
|
||||
isDirect -> R.string.cd_direct_bluetooth
|
||||
else -> R.string.cd_routed_mesh
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Reusable unread badge component for both channels and private messages
|
||||
*/
|
||||
|
||||
@ -3,7 +3,6 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.PrivateMessageArrivalOrder
|
||||
import java.util.*
|
||||
import java.util.Collections
|
||||
|
||||
@ -103,6 +102,15 @@ class MessageManager(private val state: ChatState) {
|
||||
|
||||
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val accepted = try {
|
||||
com.bitchat.android.services.AppStateStore.addPrivateMessage(
|
||||
conversationID,
|
||||
message
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!accepted) return
|
||||
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
if (!currentPrivateChats.containsKey(conversationID)) {
|
||||
currentPrivateChats[conversationID] = mutableListOf()
|
||||
@ -111,14 +119,15 @@ class MessageManager(private val state: ChatState) {
|
||||
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[conversationID] = chatMessages
|
||||
// Record the local arrival sequence before canonicalizing UI aliases.
|
||||
PrivateMessageArrivalOrder.record(message.id)
|
||||
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
|
||||
|
||||
// Mark as unread if not currently viewing this chat
|
||||
if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) {
|
||||
val selectedConversationID = state.getSelectedPrivateChatPeerValue()
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
if (
|
||||
selectedConversationID != conversationID &&
|
||||
message.sender != state.getNicknameValue()
|
||||
) {
|
||||
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
currentUnread.add(conversationID)
|
||||
state.setUnreadPrivateMessages(currentUnread)
|
||||
@ -128,6 +137,16 @@ class MessageManager(private val state: ChatState) {
|
||||
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
|
||||
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val accepted = try {
|
||||
com.bitchat.android.services.AppStateStore.addPrivateMessage(
|
||||
peerID = conversationID,
|
||||
msg = message,
|
||||
forceRead = true
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!accepted) return
|
||||
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
if (!currentPrivateChats.containsKey(conversationID)) {
|
||||
currentPrivateChats[conversationID] = mutableListOf()
|
||||
@ -135,18 +154,19 @@ class MessageManager(private val state: ChatState) {
|
||||
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[conversationID] = chatMessages
|
||||
// Record the local arrival sequence before canonicalizing UI aliases.
|
||||
PrivateMessageArrivalOrder.record(message.id)
|
||||
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun clearPrivateMessages(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
com.bitchat.android.services.AppStateStore.deletePrivateConversation(conversationID)
|
||||
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
||||
updatedChats[conversationID] = emptyList()
|
||||
updatedChats.keys.removeAll { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(conversationID, ignoreCase = true)
|
||||
}
|
||||
state.setPrivateChats(updatedChats)
|
||||
clearPrivateUnreadMessages(conversationID)
|
||||
}
|
||||
|
||||
fun initializePrivateChat(peerID: String) {
|
||||
@ -325,7 +345,10 @@ class MessageManager(private val state: ChatState) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) state.setPrivateChats(chats)
|
||||
if (changed) {
|
||||
state.setPrivateChats(chats.filterValues { it.isNotEmpty() })
|
||||
com.bitchat.android.services.AppStateStore.removePrivateMessage(messageID)
|
||||
}
|
||||
}
|
||||
// Channels
|
||||
run {
|
||||
|
||||
@ -188,21 +188,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG)
|
||||
}
|
||||
|
||||
private fun handleMessageReceived(message: BitchatMessage) {
|
||||
try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peer = message.senderPeerID ?: ""
|
||||
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
|
||||
}
|
||||
message.channel != null -> {
|
||||
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
|
||||
}
|
||||
else -> {
|
||||
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
private fun handleMessageReceived(message: BitchatMessage): Boolean {
|
||||
// Match BLE admission semantics: a private message rejected during panic or as a
|
||||
// duplicate must not create a notification after the conversation state was cleared.
|
||||
if (
|
||||
!com.bitchat.android.services.IncomingMessageAdmission
|
||||
.admitToAppState(message)
|
||||
) return false
|
||||
|
||||
if (delegate == null && message.isPrivate) {
|
||||
try {
|
||||
@ -215,6 +207,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -74,6 +74,13 @@
|
||||
<!-- 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="cd_location_notes">Location notes</string>
|
||||
<string name="cd_teleported">Teleported</string>
|
||||
<string name="cd_tor_status">Tor status</string>
|
||||
@ -252,6 +259,9 @@
|
||||
<string name="cd_checking_battery_optimization">Checking Battery Optimization</string>
|
||||
<string name="cd_not_supported_battery_optimization">Battery Optimization Not Supported</string>
|
||||
<string name="cd_bluetooth">Bluetooth</string>
|
||||
<string name="cd_direct_wifi_aware" tools:ignore="MissingTranslation">Direct Wi-Fi Aware</string>
|
||||
<string name="cd_direct_bluetooth" tools:ignore="MissingTranslation">Direct Bluetooth</string>
|
||||
<string name="cd_routed_mesh" tools:ignore="MissingTranslation">Routed mesh</string>
|
||||
<string name="cd_unread_message">Unread message</string>
|
||||
<string name="cd_open_map">Open map</string>
|
||||
<string name="cd_remove_bookmark">Remove bookmark</string>
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
package com.bitchat.android.features.file
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FileUtilsConversationCleanupTest {
|
||||
|
||||
@Test
|
||||
fun `deleting conversation removes only unreferenced app-owned media`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val testDirectory = File(
|
||||
context.cacheDir,
|
||||
"conversation-cleanup-${UUID.randomUUID()}"
|
||||
).apply { mkdirs() }
|
||||
val deletedOnly = File(testDirectory, "deleted.jpg").apply {
|
||||
writeBytes(byteArrayOf(1))
|
||||
}
|
||||
val shared = File(testDirectory, "shared.jpg").apply {
|
||||
writeBytes(byteArrayOf(2))
|
||||
}
|
||||
val outsideAppStorage = File.createTempFile(
|
||||
"conversation-cleanup-",
|
||||
".jpg"
|
||||
).apply {
|
||||
writeBytes(byteArrayOf(3))
|
||||
}
|
||||
|
||||
try {
|
||||
FileUtils.deleteConversationMedia(
|
||||
context = context,
|
||||
deletedMessages = listOf(
|
||||
mediaMessage("deleted", deletedOnly),
|
||||
mediaMessage("shared-deleted", shared),
|
||||
mediaMessage("outside", outsideAppStorage)
|
||||
),
|
||||
retainedMessages = listOf(mediaMessage("shared-retained", shared))
|
||||
)
|
||||
|
||||
assertFalse(deletedOnly.exists())
|
||||
assertTrue(shared.exists())
|
||||
assertTrue(outsideAppStorage.exists())
|
||||
} finally {
|
||||
testDirectory.deleteRecursively()
|
||||
outsideAppStorage.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaMessage(id: String, file: File) = BitchatMessage(
|
||||
id = id,
|
||||
sender = "alice",
|
||||
content = file.absolutePath,
|
||||
type = BitchatMessageType.Image,
|
||||
timestamp = Date(1L),
|
||||
isPrivate = true
|
||||
)
|
||||
}
|
||||
@ -4,19 +4,23 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Date
|
||||
|
||||
class AppStateStoreTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@ -189,4 +193,79 @@ class AppStateStoreTest {
|
||||
.deliveryStatus
|
||||
assertTrue(status is DeliveryStatus.Read)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long lived process applies the same bounded private history policy`() {
|
||||
AppStateStore.setNickname("me")
|
||||
repeat(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION) { index ->
|
||||
AppStateStore.addPrivateMessage(
|
||||
"peer-a",
|
||||
BitchatMessage(
|
||||
id = "incoming-$index",
|
||||
sender = "alice",
|
||||
content = "message",
|
||||
timestamp = Date(index.toLong()),
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
}
|
||||
AppStateStore.addPrivateMessage(
|
||||
"peer-a",
|
||||
BitchatMessage(
|
||||
id = "latest-outgoing",
|
||||
sender = "me",
|
||||
content = "latest",
|
||||
timestamp = Date(Long.MAX_VALUE),
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
|
||||
val retained = AppStateStore.privateMessages.value.getValue("peer-a")
|
||||
assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, retained.size)
|
||||
assertEquals("incoming-1", retained.first().id)
|
||||
assertEquals("latest-outgoing", retained.last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private message admission is atomic and can durably classify known read messages`() {
|
||||
val message = BitchatMessage(
|
||||
id = "same-transport-message",
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(1L),
|
||||
isPrivate = true
|
||||
)
|
||||
|
||||
assertTrue(AppStateStore.addPrivateMessage("peer-a", message, forceRead = true))
|
||||
assertFalse(AppStateStore.addPrivateMessage("peer-a", message))
|
||||
|
||||
assertEquals(1, AppStateStore.privateMessages.value.getValue("peer-a").size)
|
||||
assertTrue(AppStateStore.isPrivateMessageRead(message.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panic clear rejects private messages until explicitly resumed`() {
|
||||
val beforePanic = BitchatMessage(
|
||||
id = "before-panic",
|
||||
sender = "alice",
|
||||
content = "erase me",
|
||||
timestamp = Date(1L),
|
||||
isPrivate = true
|
||||
)
|
||||
val duringPanic = beforePanic.copy(id = "during-panic")
|
||||
val afterPanic = beforePanic.copy(id = "after-panic")
|
||||
|
||||
assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic))
|
||||
assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() })
|
||||
assertTrue(AppStateStore.privateMessages.value.isEmpty())
|
||||
assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic))
|
||||
|
||||
AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
|
||||
assertTrue(AppStateStore.addPrivateMessage("peer-a", afterPanic))
|
||||
assertEquals(
|
||||
listOf(afterPanic),
|
||||
AppStateStore.privateMessages.value.getValue("peer-a")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,219 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversationDatabaseTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private lateinit var database: ConversationDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "conversation-test-${UUID.randomUUID()}.db"
|
||||
database = ConversationDatabase(context, databaseName)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message fields read state and delivery status survive reopen`() {
|
||||
val message = BitchatMessage(
|
||||
id = "round-trip",
|
||||
sender = "alice",
|
||||
content = "photo path",
|
||||
type = BitchatMessageType.Image,
|
||||
timestamp = Date(123_456L),
|
||||
isRelay = true,
|
||||
originalSender = "alice-original",
|
||||
isPrivate = true,
|
||||
recipientNickname = "me",
|
||||
senderPeerID = "0123456789abcdef",
|
||||
mentions = listOf("me", "bob"),
|
||||
channel = "private",
|
||||
encryptedContent = byteArrayOf(1, 2, 3),
|
||||
isEncrypted = true,
|
||||
deliveryStatus = DeliveryStatus.Delivered("me", Date(123_999L)),
|
||||
senderNostrPubkey = "a".repeat(64)
|
||||
)
|
||||
|
||||
database.upsertMessage(
|
||||
conversationID = "contact_alice",
|
||||
aliases = setOf("contact_alice", "0123456789abcdef"),
|
||||
displayName = "alice",
|
||||
message = message,
|
||||
isRead = false
|
||||
)
|
||||
database.markRead(message.id)
|
||||
database.updateDeliveryStatus(
|
||||
message.id,
|
||||
DeliveryStatus.Read("me", Date(124_000L))
|
||||
)
|
||||
database.close()
|
||||
|
||||
database = ConversationDatabase(context, databaseName)
|
||||
val restored = database.loadSnapshot()
|
||||
val restoredMessage = restored.chats.getValue("contact_alice").single()
|
||||
|
||||
assertEquals(message.id, restoredMessage.id)
|
||||
assertEquals(message.sender, restoredMessage.sender)
|
||||
assertEquals(message.content, restoredMessage.content)
|
||||
assertEquals(message.type, restoredMessage.type)
|
||||
assertEquals(message.timestamp, restoredMessage.timestamp)
|
||||
assertEquals(message.mentions, restoredMessage.mentions)
|
||||
assertEquals(message.senderNostrPubkey, restoredMessage.senderNostrPubkey)
|
||||
assertArrayEquals(message.encryptedContent, restoredMessage.encryptedContent)
|
||||
assertEquals(
|
||||
DeliveryStatus.Read("me", Date(124_000L)),
|
||||
restoredMessage.deliveryStatus
|
||||
)
|
||||
assertEquals(setOf(message.id), restored.readMessageIDs)
|
||||
assertEquals(listOf(message.id), restored.arrivalOrder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aliases merge into one conversation and duplicate transport delivery stays unique`() {
|
||||
val first = message("first", "alice", 100L)
|
||||
val second = message("second", "alice", 200L)
|
||||
|
||||
database.upsertMessage("mesh-alias", setOf("mesh-alias"), "alice", first, false)
|
||||
database.upsertMessage("nostr_alias", setOf("nostr_alias"), "alice", second, false)
|
||||
database.mergeAliases(
|
||||
targetConversationID = "contact_alice",
|
||||
aliases = setOf("mesh-alias", "nostr_alias", "contact_alice")
|
||||
)
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "nostr_alias"),
|
||||
"alice",
|
||||
first,
|
||||
false
|
||||
)
|
||||
|
||||
val restored = database.loadSnapshot()
|
||||
assertEquals(setOf("contact_alice"), restored.chats.keys)
|
||||
assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversation deletion cascades messages while a later message starts fresh`() {
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice"),
|
||||
"alice",
|
||||
message("old", "alice", 100L),
|
||||
false
|
||||
)
|
||||
|
||||
database.deleteConversation(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice")
|
||||
)
|
||||
assertTrue(database.loadSnapshot().chats.isEmpty())
|
||||
|
||||
database.close()
|
||||
database = ConversationDatabase(context, databaseName)
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice"),
|
||||
"alice",
|
||||
message("old", "alice", 100L),
|
||||
false
|
||||
)
|
||||
assertTrue(database.loadSnapshot().chats.isEmpty())
|
||||
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice"),
|
||||
"alice",
|
||||
message("new", "alice", 200L),
|
||||
false
|
||||
)
|
||||
val restored = database.loadSnapshot()
|
||||
assertEquals(listOf("new"), restored.chats.getValue("contact_alice").map { it.id })
|
||||
assertFalse(restored.readMessageIDs.contains("old"))
|
||||
assertTrue(restored.deletedMessageIDs.contains("old"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per conversation retention keeps the newest bounded history`() {
|
||||
val total = ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + 5
|
||||
repeat(total) { index ->
|
||||
database.upsertMessage(
|
||||
conversationID = "contact_alice",
|
||||
aliases = setOf("contact_alice"),
|
||||
displayName = "alice",
|
||||
message = message("message-$index", "alice", index.toLong()),
|
||||
isRead = true
|
||||
)
|
||||
}
|
||||
|
||||
val snapshot = database.loadSnapshot()
|
||||
val messages = snapshot.chats.getValue("contact_alice")
|
||||
assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, messages.size)
|
||||
assertEquals("message-5", messages.first().id)
|
||||
assertEquals("message-${total - 1}", messages.last().id)
|
||||
assertEquals(
|
||||
(0 until 5).mapTo(mutableSetOf()) { "message-$it" },
|
||||
snapshot.deletedMessageIDs
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global retention is hard bounded when every conversation has one message`() {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
database = ConversationDatabase(
|
||||
context = context,
|
||||
databaseName = databaseName,
|
||||
maxMessagesPerConversation = 10,
|
||||
maxMessagesTotal = 3,
|
||||
maxPayloadBytes = Long.MAX_VALUE
|
||||
)
|
||||
repeat(4) { index ->
|
||||
database.upsertMessage(
|
||||
conversationID = "peer-$index",
|
||||
aliases = setOf("peer-$index"),
|
||||
displayName = "peer $index",
|
||||
message = message("single-$index", "peer $index", index.toLong()),
|
||||
isRead = true
|
||||
)
|
||||
}
|
||||
|
||||
database.pruneToRetentionLimits()
|
||||
|
||||
val snapshot = database.loadSnapshot()
|
||||
assertEquals(3, snapshot.chats.values.sumOf { it.size })
|
||||
assertFalse(snapshot.chats.containsKey("peer-0"))
|
||||
assertTrue(snapshot.deletedMessageIDs.contains("single-0"))
|
||||
}
|
||||
|
||||
private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = "hello-$id",
|
||||
timestamp = Date(timestamp),
|
||||
isPrivate = true,
|
||||
senderPeerID = "0123456789abcdef"
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversationRepositoryTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val dispatcher = executor.asCoroutineDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "conversation-repository-test-${UUID.randomUUID()}.db"
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
dispatcher.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reload restores persisted history after initial process restore`() {
|
||||
val repository = ConversationRepository(
|
||||
context = context,
|
||||
dispatcher = dispatcher,
|
||||
databaseName = databaseName
|
||||
)
|
||||
val message = BitchatMessage(
|
||||
id = "persisted-message",
|
||||
sender = "alice",
|
||||
content = "survives restart",
|
||||
timestamp = Date(100L),
|
||||
isPrivate = true
|
||||
)
|
||||
repository.upsertMessage(
|
||||
conversationID = "peer-alice",
|
||||
aliases = setOf("peer-alice"),
|
||||
displayName = "alice",
|
||||
message = message,
|
||||
isRead = true
|
||||
)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
|
||||
val initialSnapshot = AtomicReference<PersistedConversationSnapshot>()
|
||||
repository.initialize(initialSnapshot::set)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
assertEquals(
|
||||
listOf(message),
|
||||
initialSnapshot.get().chats.getValue("peer-alice")
|
||||
)
|
||||
|
||||
val reloadedSnapshot = AtomicReference<PersistedConversationSnapshot>()
|
||||
repository.reload(reloadedSnapshot::set)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
assertEquals(
|
||||
listOf(message),
|
||||
reloadedSnapshot.get().chats.getValue("peer-alice")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panic clear drains queued writes and leaves database empty`() {
|
||||
val repository = ConversationRepository(
|
||||
context = context,
|
||||
dispatcher = dispatcher,
|
||||
databaseName = databaseName
|
||||
)
|
||||
repository.upsertMessage(
|
||||
conversationID = "peer-alice",
|
||||
aliases = setOf("peer-alice"),
|
||||
displayName = "alice",
|
||||
message = BitchatMessage(
|
||||
id = "queued-before-panic",
|
||||
sender = "alice",
|
||||
content = "must be erased",
|
||||
timestamp = Date(100L),
|
||||
isPrivate = true
|
||||
),
|
||||
isRead = true
|
||||
)
|
||||
|
||||
assertTrue(runBlocking { repository.clearAllAndWait() })
|
||||
|
||||
val snapshot = AtomicReference<PersistedConversationSnapshot>()
|
||||
repository.reload(snapshot::set)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
assertTrue(snapshot.get().chats.isEmpty())
|
||||
assertTrue(snapshot.get().readMessageIDs.isEmpty())
|
||||
assertTrue(snapshot.get().deletedMessageIDs.isEmpty())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class IncomingMessageAdmissionTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private message rejected during panic cannot continue transport dispatch`() {
|
||||
assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() })
|
||||
|
||||
assertFalse(
|
||||
IncomingMessageAdmission.admitToAppState(
|
||||
privateMessage(id = "during-panic")
|
||||
)
|
||||
)
|
||||
assertTrue(AppStateStore.privateMessages.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate private transport delivery is rejected before downstream effects`() {
|
||||
val message = privateMessage(id = "same-message-over-two-transports")
|
||||
|
||||
assertTrue(IncomingMessageAdmission.admitToAppState(message))
|
||||
assertFalse(IncomingMessageAdmission.admitToAppState(message))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public and channel messages preserve best effort admission`() {
|
||||
val public = BitchatMessage(
|
||||
id = "public",
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(1L)
|
||||
)
|
||||
val channel = public.copy(id = "channel", channel = "#mesh")
|
||||
|
||||
assertTrue(IncomingMessageAdmission.admitToAppState(public))
|
||||
assertTrue(IncomingMessageAdmission.admitToAppState(channel))
|
||||
assertTrue(AppStateStore.publicMessages.value.contains(public))
|
||||
}
|
||||
|
||||
private fun privateMessage(id: String) = BitchatMessage(
|
||||
id = id,
|
||||
sender = "alice",
|
||||
content = "secret",
|
||||
timestamp = Date(1L),
|
||||
isPrivate = true,
|
||||
senderPeerID = "peer-a"
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.PrivateMessageArrivalOrder
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class ConversationSummaryTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
PrivateMessageArrivalOrder.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read offline conversation remains present`() {
|
||||
val message = incoming("message", "alice", 100L)
|
||||
PrivateMessageArrivalOrder.record(message.id)
|
||||
|
||||
val conversations = buildConversationSummaries(
|
||||
unreadConversationIDs = emptySet(),
|
||||
privateChats = mapOf("alice-peer" to listOf(message)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { true }
|
||||
)
|
||||
|
||||
assertEquals(1, conversations.size)
|
||||
assertEquals("alice-peer", conversations.single().conversationID)
|
||||
assertEquals(0, conversations.single().unreadCount)
|
||||
assertFalse(conversations.single().isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical aliases yield one conversation with unique messages`() {
|
||||
val first = incoming("first", "alice", 300L)
|
||||
val second = incoming("second", "alice", 100L)
|
||||
PrivateMessageArrivalOrder.record(first.id)
|
||||
PrivateMessageArrivalOrder.record(second.id)
|
||||
|
||||
val conversations = buildConversationSummaries(
|
||||
unreadConversationIDs = setOf("mesh-alias"),
|
||||
privateChats = mapOf(
|
||||
"mesh-alias" to listOf(first),
|
||||
"nostr_alias" to listOf(first, second)
|
||||
),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { "contact_alice" },
|
||||
isMessageRead = { false }
|
||||
)
|
||||
|
||||
assertEquals(1, conversations.size)
|
||||
assertEquals(2, conversations.single().unreadCount)
|
||||
assertEquals(
|
||||
setOf("mesh-alias", "nostr_alias", "contact_alice"),
|
||||
conversations.single().identityAliases
|
||||
)
|
||||
assertEquals("alice", conversations.single().displayName)
|
||||
assertEquals(DirectMessageTransport.NOSTR, conversations.single().transport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online conversations sort before newer offline conversations`() {
|
||||
val offline = summary(
|
||||
id = "offline",
|
||||
isConnected = false,
|
||||
unreadCount = 3,
|
||||
activity = 300L
|
||||
)
|
||||
val onlineRead = summary(
|
||||
id = "online-read",
|
||||
isConnected = true,
|
||||
unreadCount = 0,
|
||||
activity = 100L
|
||||
)
|
||||
val onlineUnread = summary(
|
||||
id = "online-unread",
|
||||
isConnected = true,
|
||||
unreadCount = 1,
|
||||
activity = 50L
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("online-unread", "online-read", "offline"),
|
||||
sortConversationSummaries(listOf(offline, onlineRead, onlineUnread))
|
||||
.map { it.conversationID }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview follows local arrival order instead of remote timestamp`() {
|
||||
val first = incoming(
|
||||
id = "first",
|
||||
sender = "alice",
|
||||
timestamp = 900L,
|
||||
content = "older arrival"
|
||||
)
|
||||
val second = incoming(
|
||||
id = "second",
|
||||
sender = "alice",
|
||||
timestamp = 100L,
|
||||
content = " latest\nmessage "
|
||||
)
|
||||
PrivateMessageArrivalOrder.record(first.id)
|
||||
PrivateMessageArrivalOrder.record(second.id)
|
||||
|
||||
val conversation = buildConversationSummaries(
|
||||
unreadConversationIDs = emptySet(),
|
||||
privateChats = mapOf("alice-peer" to listOf(first, second)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { true }
|
||||
).single()
|
||||
|
||||
assertEquals("latest message", conversation.latestMessagePreview)
|
||||
assertEquals(BitchatMessageType.Message, conversation.latestMessageType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file preview contains filename without local path`() {
|
||||
val message = incoming(
|
||||
id = "file",
|
||||
sender = "alice",
|
||||
timestamp = 100L,
|
||||
content = "/private/conversations/quarterly report.pdf",
|
||||
type = BitchatMessageType.File
|
||||
)
|
||||
|
||||
val conversation = buildConversationSummaries(
|
||||
unreadConversationIDs = emptySet(),
|
||||
privateChats = mapOf("alice-peer" to listOf(message)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { true }
|
||||
).single()
|
||||
|
||||
assertEquals("quarterly report.pdf", conversation.latestMessagePreview)
|
||||
assertEquals(BitchatMessageType.File, conversation.latestMessageType)
|
||||
}
|
||||
|
||||
private fun incoming(
|
||||
id: String,
|
||||
sender: String,
|
||||
timestamp: Long,
|
||||
content: String = "hello",
|
||||
type: BitchatMessageType = BitchatMessageType.Message
|
||||
) = BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = content,
|
||||
type = type,
|
||||
timestamp = Date(timestamp),
|
||||
isPrivate = true
|
||||
)
|
||||
|
||||
private fun summary(
|
||||
id: String,
|
||||
isConnected: Boolean,
|
||||
unreadCount: Int,
|
||||
activity: Long
|
||||
) = ConversationSummary(
|
||||
conversationID = id,
|
||||
displayName = id,
|
||||
unreadCount = unreadCount,
|
||||
latestMessageAt = activity,
|
||||
latestActivityOrder = activity,
|
||||
latestMessageType = BitchatMessageType.Message,
|
||||
latestMessagePreview = "hello",
|
||||
transport = DirectMessageTransport.MESH,
|
||||
nostrPubkey = null,
|
||||
identityAliases = setOf(id),
|
||||
isConnected = isConnected
|
||||
)
|
||||
}
|
||||
@ -77,6 +77,7 @@ val sharedSourceIncludes = listOf(
|
||||
"com/bitchat/android/services/AppStateStore.kt",
|
||||
"com/bitchat/android/services/ContactDirectory.kt",
|
||||
"com/bitchat/android/services/ContactIdentityResolver.kt",
|
||||
"com/bitchat/android/services/ConversationRepository.kt",
|
||||
"com/bitchat/android/services/PrivateMessageArrivalOrder.kt",
|
||||
"com/bitchat/android/services/SeenMessageStore.kt",
|
||||
"com/bitchat/android/services/VerificationService.kt",
|
||||
|
||||
@ -227,18 +227,27 @@ class WearMeshService private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
|
||||
try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peer = message.senderPeerID ?: return
|
||||
AppStateStore.addPrivateMessage(peer, message)
|
||||
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
|
||||
}
|
||||
message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message)
|
||||
else -> AppStateStore.addPublicMessage(message)
|
||||
private fun handleMessageReceived(
|
||||
message: com.bitchat.android.model.BitchatMessage
|
||||
): Boolean = try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peer = message.senderPeerID ?: return false
|
||||
if (!AppStateStore.addPrivateMessage(peer, message)) return false
|
||||
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
message.channel != null -> {
|
||||
AppStateStore.addChannelMessage(message.channel, message)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
AppStateStore.addPublicMessage(message)
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
!message.isPrivate
|
||||
}
|
||||
|
||||
fun startServices() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user