mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
persist
This commit is contained in:
parent
f5915c4c31
commit
7fad2f2202
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,11 @@ 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 val _nickname = MutableStateFlow("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
@ -55,6 +62,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 +143,51 @@ 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 (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) {
|
||||
@ -158,6 +219,7 @@ object AppStateStore {
|
||||
}
|
||||
if (changed) {
|
||||
_privateMessages.value = map
|
||||
conversationRepository?.updateDeliveryStatus(messageID, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -166,6 +228,14 @@ object AppStateStore {
|
||||
if (keysToMerge.isEmpty()) return
|
||||
synchronized(this) {
|
||||
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 +283,85 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun markPrivateMessageRead(messageID: String) {
|
||||
synchronized(this) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPersistedPrivateConversations() {
|
||||
synchronized(this) {
|
||||
conversationRepository?.clearAll()
|
||||
_privateMessages.value = emptyMap()
|
||||
_readPrivateMessageIDs.value = emptySet()
|
||||
_selectedPrivateChatPeer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun addChannelMessage(channel: String, msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
@ -230,12 +379,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 +403,147 @@ object AppStateStore {
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
|
||||
private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
|
||||
synchronized(this) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
@ -1055,6 +1133,8 @@ class ChatViewModel(
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
|
||||
// Clear all UI managers
|
||||
com.bitchat.android.services.AppStateStore.clearPersistedPrivateConversations()
|
||||
com.bitchat.android.services.AppStateStore.clear()
|
||||
messageManager.clearAllMessages()
|
||||
channelManager.clearAllChannels()
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,29 @@ 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)
|
||||
|
||||
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 +729,63 @@ 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 dismissState = rememberSwipeToDismissBoxState()
|
||||
LaunchedEffect(dismissState.currentValue, conversation.conversationID) {
|
||||
if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onDeleteRequested(conversation)
|
||||
dismissState.reset()
|
||||
}
|
||||
}
|
||||
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)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = true,
|
||||
enableDismissFromEndToStart = true,
|
||||
backgroundContent = {
|
||||
val alignment = when (dismissState.dismissDirection) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
.padding(
|
||||
horizontal = SheetRowHorizontal,
|
||||
vertical = SheetRowVertical
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
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,
|
||||
deleteDescription = deleteDescription,
|
||||
onClick = {
|
||||
onPrivateChatStart(conversation.conversationID)
|
||||
},
|
||||
onDeleteRequested = {
|
||||
onDeleteRequested(conversation)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -735,6 +794,171 @@ private fun UnreadDirectMessagesSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationRow(
|
||||
conversation: ConversationSummary,
|
||||
directPeerIdentityIDs: Set<String>,
|
||||
wifiAwareIdentityIDs: Set<String>,
|
||||
viewModel: ChatViewModel,
|
||||
deleteDescription: String,
|
||||
onClick: () -> 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = messagePreview,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = relativeTime,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 1
|
||||
)
|
||||
UnreadBadge(
|
||||
count = conversation.unreadCount,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
peerID: String,
|
||||
@ -755,6 +979,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 +1044,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 +1108,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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -4,6 +4,7 @@ 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
|
||||
@ -189,4 +190,53 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user