persistent

This commit is contained in:
callebtc 2026-07-29 03:41:24 +02:00
parent 7fad2f2202
commit d3f01f27c6
6 changed files with 1712 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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
)
}

View File

@ -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"
)
}

View File

@ -0,0 +1,77 @@
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.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")
)
}
}

View File

@ -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
)
}