Merge branch 'mantra' into claude/member-invite-transcript-7a2d63

Brings in `ChatRoom.joinedGroupAt` and the pre-join indexing gate, plus schema
v15. No conflict: mantra's only edit to `MarmotOutboundDao` is in
`createMlsDirectMessageChatRoom`, stamping the new column as it builds the
ChatRoom, and every line of this branch's is further down -- `inviteMember`,
`addMembersToChatRoom`, `deliveryWelcome` and the three new announce helpers.

The two changes do meet in one place, and it is worth saying why nothing had to
be done about it. `MIGRATION_14_15` deletes transcript lines, which is exactly
the sort of thing that could quietly eat the lines this branch adds. It cannot:
the delete is scoped to `ChatMessage.UNRESOLVED_MARMOT_TYPES` and to rows whose
`marmotGroupEventId` names an event older than the room, and a membership line
is neither -- it is not a placeholder for an event still to come, and it has no
group event behind it at all. Nor could it ever be in reach, because these lines
are written by the *inviter*, whose own room has no epoch predating them.

828 tests pass -- 526 jvm, 302 android. The six new ones are this branch's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 20:03:35 +02:00
14 changed files with 6402 additions and 27 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 14,
version = 15,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -255,6 +255,14 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// meanings would have met. Room can rename a column and cannot rewrite the
// rows in the same breath, so this is a manual migration passed to the
// builder rather than an entry here. See MIGRATION_13_14.
//
// v15 adds the nullable ChatRoom.joinedGroupAt, which says when this
// device became a member and so which of the group's messages were never
// its to read. Adding a nullable column is a shape Room migrates itself;
// deleting the placeholder chat lines already written for those messages
// is not, and a member who joined a busy room is looking at a screenful of
// them. Manual for that half, so it too is passed to the builder rather
// than listed here. See MIGRATION_14_15.
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -5,6 +5,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import press.mantra.compose.database.migrations.MIGRATION_3_4
import press.mantra.compose.database.migrations.MIGRATION_9_10
import press.mantra.compose.database.migrations.MIGRATION_13_14
import press.mantra.compose.database.migrations.MIGRATION_14_15
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -18,12 +19,13 @@ fun getRoomDatabase(
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
): press.mantra.compose.database.MantraDatabase {
return builder
// Everything else Room generates itself. These three move data rather than
// Everything else Room generates itself. These four move data rather than
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
// chat rows, 9->10 copies a session's per-event columns onto the items
// table before dropping them, and 13->14 renames a column and rewrites the
// chat rows that named it the old way.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14)
// table before dropping them, 13->14 renames a column and rewrites the
// chat rows that named it the old way, and 14->15 adds a column and deletes
// the chat rows written for messages from before this device joined.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()

View File

@@ -82,12 +82,19 @@ abstract class MarmotOutboundDao(
)
// Save Chat Room
//
// Member since the group existed, because this device is what created it:
// there is no epoch of this group that predates us, and so nothing in it
// for ChatRoom.predatesMembership to hold back.
val createdAt = Clock.System.now()
val chatRoom = ChatRoom(
id = nostrGroupId,
userPublicKey = userPublicKey,
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
subject = name,
description = description
description = description,
joinedGroupAt = createdAt,
createdAt = createdAt,
)
database.chatRoomDao().upsert(
chatRoom

View File

@@ -706,6 +706,14 @@ abstract class NostrDao(
description = group.currentMarmotData()?.description?.ifBlank { null },
initialGiftWrapPayloadId = decryptedGiftWrapPayload.id,
createdAt = decryptedGiftWrapPayload.createdAt,
// The Welcome's own `created_at`, which the
// inviter stamps as it mints the Welcome out of
// the Add commit that made us a member. That
// commit is what created the epoch we are joining
// at, so it is the group's clock on when this
// room's history stops being ours to read. See
// ChatRoom.predatesMembership.
joinedGroupAt = decryptedGiftWrapPayload.createdAt,
mlsGroupState = group.saveState().encodeTls().toHex(),
)
)
@@ -1145,6 +1153,13 @@ abstract class NostrDao(
* see [reindexMarmotGroupEvents]. Throws for a room this device cannot process
* the event against at all, which the caller decides what to do about: a first
* delivery lets it roll back its transaction, a replay logs it and moves on.
*
* An event from before this device joined is not read and not filed -- see
* [ChatRoom.predatesMembership]. Nothing about it is this device's: not the
* epoch key it was encrypted under, and so not the message either. Reading it
* anyway is how a room a member was invited to yesterday opened on a screenful
* of "Undecryptable Message" above the conversation, one line for every
* message the group had sent before they arrived.
*/
private suspend fun indexMarmotGroupEvent(
groupEvent: GroupEvent,
@@ -1154,6 +1169,11 @@ abstract class NostrDao(
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
?: throw MarmotMissingChatGroupException("Couldn't find chatRoom for ${groupEvent.id}")
if (localChatRoom.chatRoom.predatesMembership(Instant.fromEpochSeconds(groupEvent.createdAt))) {
logger.d("${groupEvent.id} predates this device joining $chatRoomId, nothing to index")
return
}
// Through the cache rather than rebuilt here, so the secret
// tree's skipped-generation keys survive from one message to
// the next. Two events published in the same instant arrive in
@@ -1314,16 +1334,22 @@ abstract class NostrDao(
* recovered by one pass can be what lets the next read the messages that were
* waiting on it.
*
* Events from before this device joined are left out of the sweep entirely --
* see [ChatRoom.predatesMembership]. They are the one part of the backlog a
* replay can say something about in advance: no pass will ever read them, so
* replaying them only spends a refused decrypt each time and reports every one
* of them as a failure, on a room where nothing is wrong.
*
* What this cannot do is recover a message whose key is gone: an application
* message the ratchet has already advanced past, or one from an epoch that
* predates this device joining. Those stay unreadable however often they are
* replayed.
* message the ratchet has already advanced past. Those stay unreadable however
* often they are replayed.
*/
open suspend fun reindexMarmotGroupEvents(
chatRoomId: String,
activeKeyPair: KeyPair,
): MarmotReindexReport {
val userPublicKey = activeKeyPair.pubKey.toHex()
val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom
// The query's LIKE only narrows; the h tag is what decides the room. Sorted
// by CommitOrdering's own comparator rather than left in createdAt order, so
@@ -1338,7 +1364,20 @@ abstract class NostrDao(
}
.sortedWith(CommitOrdering.comparator)
logger.i("Reindex $chatRoomId: ${groupEvents.size} stored group event(s)")
// Held back rather than dropped from the report: they are counted in
// `stored` and again on their own, so the screen can say a room is mostly
// older than the member reading it instead of calling those events read.
//
// A room with no row to ask keeps every event, so a replay against one
// does what it always did rather than quietly finding nothing to do.
val (readable, predatingMembership) = groupEvents.partition { groupEvent ->
chatRoom?.predatesMembership(Instant.fromEpochSeconds(groupEvent.createdAt)) != true
}
logger.i(
"Reindex $chatRoomId: ${groupEvents.size} stored group event(s), " +
"${predatingMembership.size} from before this device joined"
)
// Held commits are what a replay is most often for, and they are only ever
// cleared by one applying -- see MarmotInboundManager.forgetPendingCommits.
@@ -1351,7 +1390,8 @@ abstract class NostrDao(
return MarmotReindexSweep.run(
stored = groupEvents.size,
unresolved = groupEvents.filterNot { it.id in resolved },
predatingMembership = predatingMembership.size,
unresolved = readable.filterNot { it.id in resolved },
replay = { groupEvent ->
indexMarmotGroupEvent(
groupEvent = groupEvent,

View File

@@ -0,0 +1,58 @@
package press.mantra.compose.database.migrations
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import press.mantra.compose.database.model.ChatMessage
/**
* Records when this device joined each room, and clears the lines it wrote for
* messages it was never able to read.
*
* A member added to a group is given the key schedule from their own epoch
* forward and nothing before it. Every kind:445 the group published earlier is
* still on the relays, still syncs down, and is still unreadable -- so the room
* they opened for the first time led with a run of "Undecryptable Message",
* one per message sent before they arrived, above the conversation they were
* actually invited to.
*
* Two changes, one shape and one data, which is why this is a manual migration
* rather than an `AutoMigration` Room could generate:
*
* - `ChatRoom.joinedGroupAt` says when this device became a member, which is
* what `ChatRoom.predatesMembership` reads to leave those events alone. It
* is left null here rather than backfilled from `createdAt`: null already
* means "ask `createdAt`" -- see `ChatRoom.memberSince` -- and copying the
* value would turn a fallback into a claim this migration is in no position
* to make.
* - The placeholder lines already written for those events are deleted. Fixing
* the write path only stops the next one; nothing rewrites a line that is
* already in the transcript, so a member who joined last week would go on
* seeing their run of them forever.
*
* Only the two types in [ChatMessage.UNRESOLVED_MARMOT_TYPES] are deleted, and
* only where the group event behind them predates the room. Those lines say
* nothing by design -- they stand in for an event that was never read -- so
* removing one loses nothing, while every other line is the final word on its
* group event and is left alone. The group events themselves stay: this is
* about what the room shows, not about forgetting what arrived.
*
* `createdAt` is compared rather than `joinedGroupAt` because the column was
* added in this same migration and is null for every row in the database being
* migrated. It is the same comparison `memberSince` falls back to.
*/
val MIGRATION_14_15 = object : Migration(14, 15) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `ChatRoom` ADD COLUMN `joinedGroupAt` INTEGER")
val placeholderTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES.joinToString(", ") { "'$it'" }
connection.execSQL(
"DELETE FROM `ChatMessage` WHERE `messageType` IN ($placeholderTypes) " +
"AND `marmotGroupEventId` IN (" +
"SELECT `MarmotGroupEvent`.`id` FROM `MarmotGroupEvent` " +
"JOIN `ChatRoom` ON `ChatRoom`.`id` = `MarmotGroupEvent`.`chatRoomId` " +
"WHERE `MarmotGroupEvent`.`createdAt` < `ChatRoom`.`createdAt`)"
)
}
}

View File

@@ -79,6 +79,24 @@ data class ChatRoom(
val leftGroupAt: Instant? = null,
/**
* When this device became a member of the group, or null for a room that
* predates the column.
*
* The pair to [leftGroupAt], and the thing [memberSince] is really asking
* for. Written from the moment the group's own clock says our epoch began:
* the Welcome's `created_at`, which the inviter stamps as it mints the
* Welcome out of the Add commit that made us a member, or the room's own
* creation for a group this device started at epoch 0.
*
* Stored rather than read off [createdAt] because the two are only
* incidentally equal. [createdAt] is row bookkeeping -- when this device
* first wrote the row down -- and the question asked here decides which of
* the group's messages are ours to read at all. That is not a fact to leave
* hanging off a timestamp somebody could reasonably repurpose.
*/
val joinedGroupAt: Instant? = null,
/**
* When this device last asked the group for its signed history, or null if
* it never has or the answer has since arrived.
@@ -144,6 +162,43 @@ data class ChatRoom(
}
/**
* The moment this device joined, falling back to when it wrote the room down.
*
* A room joined before [joinedGroupAt] existed has no recorded answer, and
* [createdAt] is both the best one available and the one every path that
* writes [joinedGroupAt] would have written anyway: a joiner's row is created
* from the Welcome, and a creator's when it creates the group.
*/
val memberSince: Instant get() = joinedGroupAt ?: createdAt
/**
* Whether something the group published at [publishedAt] belongs to an epoch
* this device was never in.
*
* MLS gives a joiner the key schedule from their own epoch forward and
* nothing before it, so a kind:445 older than [memberSince] cannot be read
* now and cannot be read later -- not by waiting, and not by replaying it.
* The point of asking is to leave those alone rather than file a line saying
* a message arrived that nobody can show.
*
* Time is the only thing to ask it of. The epoch a kind:445 was encrypted
* under is inside the outer layer, so an event this device cannot decrypt
* cannot be asked what epoch it is from, and "before we joined", "from an
* epoch we have not caught up to" and "from an epoch that fell out of the
* retention window" all look identical from the outside. What separates the
* first from the other two is that it was published before the group made
* the epoch we joined at.
*
* Strictly before, so an event stamped in the same second as our Welcome is
* still read. The error worth avoiding runs one way: an unreadable event
* costs a wasted decrypt, and a discarded readable one is a message the
* member never sees. The commit that added us sits exactly on that boundary
* and is unreadable by construction -- it is the last act of the epoch
* before ours -- so a room may still show one placeholder for it.
*/
fun predatesMembership(publishedAt: Instant): Boolean = publishedAt < memberSince
fun toMlsGroup(): MlsGroup? {
return mlsGroupState?.let {
return MlsGroup.restore(

View File

@@ -7,17 +7,23 @@ package press.mantra.compose.database.model.types
* can say what happened rather than leaving the user to guess from the message
* list whether anything moved.
*
* @param stored every kind:445 held locally for the room.
* @param unresolved how many of those had nothing to show for them, or only a
* @param stored every kind:445 held locally for the room, [predatingMembership]
* included. They are held, so they are counted.
* @param predatingMembership how many of [stored] the group published before this
* device joined, which a replay does not touch -- see
* `ChatRoom.predatesMembership`. Reported rather than folded into [stored]
* silently, because "20 events, all read" is not true of a room where 15 of them
* were never this device's to read, and a member who was invited into an old
* room deserves the difference said out loud rather than left as a discrepancy.
* @param unresolved how many of the rest had nothing to show for them, or only a
* placeholder line -- the ones a replay was allowed to touch.
* @param recovered how many of [unresolved] came out with something to show:
* a message, an applied commit, an entity added to the group's library.
* @param failed how many threw while being replayed. Expected to be non-zero
* on a room with events from before this device joined, whose epoch secrets it
* never held and never will.
* @param failed how many threw while being replayed.
*/
data class MarmotReindexReport(
val stored: Int = 0,
val predatingMembership: Int = 0,
val unresolved: Int = 0,
val recovered: Int = 0,
val failed: Int = 0,

View File

@@ -230,10 +230,17 @@ object MarmotInboundManager {
)
if (mlsBytes == null) {
// Expected when this kind:445 was encrypted with an epoch
// key that predates our join (classical MLS forward
// secrecy), or when the sender's epoch has drifted. Not
// an error — callers should log at DEBUG.
// Expected when the sender's epoch has drifted, or when a
// key that predates our join is what this was encrypted
// with (classical MLS forward secrecy). Not an error —
// callers should log at DEBUG.
//
// The second of those is now rare rather than routine:
// `NostrDao.indexMarmotGroupEvent` holds back anything the
// group published before this device joined, so what
// reaches here from before our epoch is only what sits on
// the boundary — see ChatRoom.predatesMembership. A room
// full of these is a sign that gate is not being applied.
GroupEventResult.UndecryptableOuterLayer(
localChatRoom.chatRoom.id,
retainedEpochCount = retainedExporterSecrets(localChatRoom.chatRoom.id).size,
@@ -656,9 +663,11 @@ object MarmotInboundManager {
*
* Returns null when neither the current epoch key nor any retained key
* decrypts. This happens normally for commits/application messages from
* epochs that predate our join (we never held those keys), so callers
* should treat null as an expected "nothing to do here" outcome and log
* at DEBUG, not as an error.
* epochs we never held the keys for, so callers should treat null as an
* expected "nothing to do here" outcome and log at DEBUG, not as an error.
* Most of that class of event no longer gets this far: what predates this
* device's join is held back before any of it is attempted -- see
* `ChatRoom.predatesMembership`.
*/
private fun tryDecryptOuterLayer(
mlsGroup: MlsGroup,

View File

@@ -36,6 +36,11 @@ object MarmotReindexSweep {
/**
* @param stored how many group events the room holds in total, for the report.
* @param predatingMembership how many of [stored] the caller held back because
* they were published before this device joined, for the report. Carried
* through rather than worked out here for the same reason [stored] is: the
* sweep decides how many times to go round, not what is worth going round
* for.
* @param unresolved those with nothing to show for them, in the order to replay
* them. Anything already read must be left out: a replay is only ever allowed
* to touch events it cannot make worse.
@@ -48,6 +53,7 @@ object MarmotReindexSweep {
*/
suspend fun <T> run(
stored: Int,
predatingMembership: Int = 0,
unresolved: List<T>,
maxPasses: Int = DEFAULT_MAX_PASSES,
replay: suspend (T) -> Unit,
@@ -85,6 +91,7 @@ object MarmotReindexSweep {
return MarmotReindexReport(
stored = stored,
predatingMembership = predatingMembership,
unresolved = unresolved.size,
recovered = recovered,
failed = remaining.size,

View File

@@ -632,13 +632,27 @@ private fun ReindexMarmotGroupEventsButton(
when (reindexState) {
is ChatRoomDetailViewModel.ReindexState.Done -> {
val report = reindexState.report
// What was published before this member joined is named rather
// than counted as read. Their epoch keys were never on this
// device, so "all read" would be a claim about messages nobody
// here can open -- and a room that is mostly older than the
// member is the ordinary case for anyone invited into one.
val beforeJoining =
if (report.predatingMembership > 0) {
" · ${report.predatingMembership} from before you joined"
} else {
""
}
Text(
text = when {
report.isNoOp -> "Nothing to reindex · ${report.stored} event(s) all read"
report.isNoOp ->
"Nothing to reindex · ${report.stored - report.predatingMembership} " +
"event(s) all read$beforeJoining"
report.recovered > 0 && report.failed > 0 ->
"Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable"
report.recovered > 0 -> "Recovered ${report.recovered} of ${report.unresolved} event(s)"
else -> "${report.failed} event(s) still unreadable"
"Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable$beforeJoining"
report.recovered > 0 ->
"Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining"
else -> "${report.failed} event(s) still unreadable$beforeJoining"
},
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center

View File

@@ -0,0 +1,103 @@
package press.mantra.compose.database.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Which of a group's kind:445 events are this device's to read at all.
*
* MLS hands a joiner the key schedule from their own epoch forward and nothing
* before it, but the relay hands them the whole room. Everything the group
* published earlier still syncs down and is still permanently unreadable, so a
* member invited into a busy room opened it on a run of "Undecryptable Message"
* above the conversation they were invited to.
*
* The epoch an event was encrypted under is inside the layer that will not
* decrypt, so the only thing left to ask is when it was published. That makes
* the boundary a judgement call rather than a fact, and the direction it errs in
* is what these pin down: an unreadable event kept costs one refused decrypt, a
* readable event discarded is a message the member never sees.
*/
class ChatRoomMembershipWindowTest {
private val roomId = "a".repeat(64)
private val user = "b".repeat(64)
private fun room(
joinedGroupAt: Instant?,
createdAt: Instant = Instant.fromEpochSeconds(5_000),
) = ChatRoom(
id = roomId,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
joinedGroupAt = joinedGroupAt,
createdAt = createdAt,
)
@Test
fun `a message from before the welcome is not this devices to read`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(1_999)))
}
@Test
fun `a message from after the welcome is`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_001)))
}
/**
* The boundary, and the reason it is drawn strictly.
*
* Nostr stamps `created_at` in whole seconds, so the second the Welcome was
* minted holds both the commit that added us -- the last act of the epoch
* before ours, unreadable by construction -- and any message another member
* sent the instant they applied it. Only one of those two can be had, and a
* message is worth more than a spared decrypt.
*/
@Test
fun `a message from the very second of the welcome is kept`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_000)))
}
/**
* A room joined before the column existed. `createdAt` is when this device
* wrote the row down, which for a joiner is the Welcome it wrote it from --
* the same answer every path that sets `joinedGroupAt` would have written.
*/
@Test
fun `a room with no recorded join falls back to when it was written down`() {
val chatRoom = room(joinedGroupAt = null, createdAt = Instant.fromEpochSeconds(5_000))
assertEquals(Instant.fromEpochSeconds(5_000), chatRoom.memberSince)
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(4_999)))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(5_000)))
}
/**
* A device that joined a room it had already heard of -- its own invite
* gift wrap arrived out of order, say, and the row was written before the
* Welcome was processed. The recorded join is the group's own account of
* when our epoch began and beats this device's account of when it started
* keeping notes.
*/
@Test
fun `a recorded join wins over when the row was written`() {
val chatRoom = room(
joinedGroupAt = Instant.fromEpochSeconds(9_000),
createdAt = Instant.fromEpochSeconds(5_000),
)
assertEquals(Instant.fromEpochSeconds(9_000), chatRoom.memberSince)
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(6_000)))
}
}

View File

@@ -0,0 +1,174 @@
package press.mantra.compose.database.dao
import androidx.room3.Room
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant
/**
* What a room does with the kind:445 events the group published before this
* device was in it.
*
* They arrive whatever anyone wants: MLS gives a joiner the key schedule from
* their own epoch forward, the relay gives them the whole room, and negentropy
* syncs the lot down on first open. Every one of those events is permanently
* unreadable, and reading them anyway is what put a run of "Undecryptable
* Message" above the conversation a member had just been invited to.
*
* Asserted at the DAO because the decision is only worth anything where the
* backlog is: `indexMarmotGroupEvent` for the events as they land, and
* `reindexMarmotGroupEvents` for the replay that would otherwise spend a refused
* decrypt on each of them every time a member asks a room to try again.
*
* The room here has no MLS state, so nothing this replays can be read. That is
* the point: it separates events left alone because they predate the join from
* events tried and failed, which is the whole distinction under test.
*/
class MarmotPreJoinIndexingJvmTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
@AfterTest
fun closeDb() = db.close()
private val keyPair = KeyPair()
private val user = keyPair.pubKey.toHexKey()
private val roomId = "a".repeat(64)
private val joinedAt = Instant.fromEpochSeconds(5_000)
private suspend fun seedRoom(joinedGroupAt: Instant? = joinedAt) {
val profileEventId = "f".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = profileEventId,
pubKey = user,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(
Profile(publicKey = user, userName = "member", nostrEventId = profileEventId)
)
db.chatRoomDao().upsert(
ChatRoom(
id = roomId,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
joinedGroupAt = joinedGroupAt,
createdAt = joinedGroupAt ?: joinedAt,
)
)
}
/** One of the group's kind:445 events, stored the way a sync stores it. */
private suspend fun seedGroupEvent(id: String, createdAt: Instant): String {
val eventId = id.padEnd(64, '0')
db.nostrEventDao().upsert(
NostrEvent(
id = eventId,
pubKey = "b".repeat(64),
kind = 445,
tags = arrayOf(arrayOf("h", roomId)),
content = "ciphertext",
sig = "0".repeat(128),
createdAt = createdAt,
)
)
return eventId
}
private suspend fun lineFor(groupEventId: String): ChatMessage? =
db.chatMessageDao().getChatMessagesByMarmotGroupEventId(groupEventId)
private suspend fun reindex() = db.nostrDao().reindexMarmotGroupEvents(
chatRoomId = roomId,
activeKeyPair = keyPair,
)
@Test
fun `a message from before the join leaves no line in the transcript`() = runBlocking {
seedRoom()
val before = seedGroupEvent("1", Instant.fromEpochSeconds(4_000))
reindex()
assertNull(
lineFor(before),
"a message this device never held the epoch key for is not a message it can show",
)
}
/**
* The accounting. `stored` counts what the room holds, because it holds it,
* and `predatingMembership` says how much of that a replay was never going to
* touch; the rest counts only what it did touch. A sweep that reported the
* pre-join backlog as failures said a room was broken when it was working
* exactly as designed.
*/
@Test
fun `the pre-join backlog is counted apart from what was replayed`() = runBlocking {
seedRoom()
seedGroupEvent("1", Instant.fromEpochSeconds(3_000))
seedGroupEvent("2", Instant.fromEpochSeconds(4_000))
val report = reindex()
assertEquals(2, report.stored)
assertEquals(2, report.predatingMembership)
assertEquals(0, report.unresolved)
assertEquals(0, report.failed)
assertEquals(0, report.recovered)
}
/**
* The other half, and the reason this is a cutoff rather than a blanket
* refusal to replay. An event from after the join that could not be read is a
* message waiting on a commit that has not landed, which is exactly what a
* replay exists to pick up.
*/
@Test
fun `an event from after the join is still replayed`() = runBlocking {
seedRoom()
seedGroupEvent("1", Instant.fromEpochSeconds(4_000))
seedGroupEvent("2", Instant.fromEpochSeconds(6_000))
val report = reindex()
assertEquals(2, report.stored)
assertEquals(1, report.predatingMembership)
assertEquals(1, report.unresolved, "only the event from after the join was replayed")
}
/**
* A room joined before `joinedGroupAt` existed. `createdAt` is the same
* answer -- a joiner's row is written from the Welcome -- so the fix reaches
* rooms that were already on the device rather than only ones joined since.
*/
@Test
fun `a room with no recorded join still holds back its pre-join backlog`() = runBlocking {
seedRoom(joinedGroupAt = null)
val before = seedGroupEvent("1", Instant.fromEpochSeconds(4_000))
val report = reindex()
assertNull(lineFor(before))
assertEquals(0, report.unresolved)
}
}

View File

@@ -0,0 +1,250 @@
package press.mantra.compose.database.migrations
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.model.ChatMessage
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The v14 -> v15 migration, against a real database holding the lines it exists
* to clear out.
*
* Fixing the write path only stops the next one. Nothing rewrites a chat line
* that is already in the transcript, so a member who joined a busy room a week
* ago would go on opening it to the same run of "Undecryptable Message" -- one
* per message the group sent before they arrived -- however many versions later.
* That is the half of this migration worth asserting.
*
* What it must not do matters just as much. The delete is aimed at rows that say
* nothing by design, and everything else in the transcript is the final word on
* its group event: a message that was read, a commit that was applied, a line
* this device wrote for something it sent. A rule with a join and a comparison in
* it can take too much, and there is no undo.
*
* Run against the migration's own SQL on a bare connection rather than through
* Room, the way `ChronicleRenameMigrationJvmTest` is: Room's version wiring
* belongs to `PlatformDatabaseBuilder` and is the same for every migration in
* that list.
*/
class JoinedGroupAtMigrationJvmTest {
private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:")
@AfterTest
fun close() = connection.close()
/** v14's three tables, verbatim from `schemas/14.json`. */
private fun createV14() {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `ChatRoom` (`id` TEXT NOT NULL, " +
"`userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, " +
"`mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, " +
"`chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " +
"`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, " +
"`deletedAt` INTEGER, PRIMARY KEY(`id`), " +
"FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `MarmotGroupEvent` (`id` TEXT NOT NULL, " +
"`userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, " +
"`chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, " +
"`encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, " +
"`createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " +
"`savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, " +
"PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `ChatMessage` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, " +
"`giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, " +
"`marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, " +
"`replyToMessageId` INTEGER, `quotedMessageId` INTEGER, " +
"`content` TEXT NOT NULL, `messageType` TEXT NOT NULL, " +
"`directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, " +
"`createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " +
"`savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, " +
"FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
}
/** A room this device wrote down at [createdAt], which is when it joined. */
private fun insertRoom(id: String = "room", createdAt: Long) = connection.execSQL(
"INSERT INTO `ChatRoom` VALUES ('$id', 'user', NULL, NULL, NULL, NULL, NULL, NULL, " +
"$createdAt, $createdAt, $createdAt, NULL, NULL)"
)
private fun insertGroupEvent(id: String, createdAt: Long, room: String = "room") =
connection.execSQL(
"INSERT INTO `MarmotGroupEvent` (`id`, `userPublicKey`, `publicKey`, `chatRoomId`, " +
"`signature`, `encryptedContent`, `createdAt`, `updatedAt`, `savedAt`) " +
"VALUES ('$id', 'user', 'sender', '$room', 'sig', 'ciphertext', " +
"$createdAt, $createdAt, $createdAt)"
)
private fun insertLine(
groupEventId: String?,
messageType: String,
room: String = "room",
content: String = "line",
) = connection.execSQL(
"INSERT INTO `ChatMessage` (`senderPublicKey`, `isUserMessage`, `marmotGroupEventId`, " +
"`chatRoomId`, `content`, `messageType`, `createdAt`, `updatedAt`, `savedAt`) " +
"VALUES ('user', 0, ${groupEventId?.let { "'$it'" } ?: "NULL"}, '$room', " +
"'$content', '$messageType', 1000, 1000, 1000)"
)
private fun lines(): List<String> =
connection.prepare("SELECT `content` FROM `ChatMessage` ORDER BY `id`").use { statement ->
buildList { while (statement.step()) add(statement.getText(0)) }
}
private fun columns(table: String): List<String> =
connection.prepare("PRAGMA table_info(`$table`)").use { statement ->
buildList { while (statement.step()) add(statement.getText(1)) }
}
@Test
fun `the column arrives, and every room reads as not having recorded a join`() = runBlocking {
createV14()
insertRoom(createdAt = 5_000)
MIGRATION_14_15.migrate(connection)
assertTrue("joinedGroupAt" in columns("ChatRoom"))
// Null on purpose. It already means "ask createdAt" -- see
// ChatRoom.memberSince -- and backfilling it would turn a fallback into
// a claim this migration is in no position to make.
connection.prepare("SELECT `joinedGroupAt` FROM `ChatRoom`").use { statement ->
assertTrue(statement.step() && statement.isNull(0))
}
}
/**
* `ALTER TABLE ... ADD COLUMN` appends, so the column lands last rather than
* where v15 declares it. Pinned because it looks like a mismatch and is not:
* Room compares a table's columns by name, and its own generated migration
* for a nullable addition appends in exactly this way.
*/
@Test
fun `the column is appended, which is where Room's own migrations put one`() = runBlocking {
createV14()
MIGRATION_14_15.migrate(connection)
assertEquals("joinedGroupAt", columns("ChatRoom").last())
}
@Test
fun `the placeholder lines for messages sent before this device joined are cleared`() =
runBlocking {
createV14()
insertRoom(createdAt = 5_000)
insertGroupEvent("before", createdAt = 4_000)
insertLine("before", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "gone")
MIGRATION_14_15.migrate(connection)
assertEquals(emptyList(), lines())
}
/**
* A commit held back because two competed for the same epoch reads the same
* way to the transcript, so it goes by the same rule. Both types are
* [ChatMessage.UNRESOLVED_MARMOT_TYPES] -- lines that stand in for an event
* that was never read -- and removing one loses nothing.
*/
@Test
fun `a pending commit line from before the join goes too`() = runBlocking {
createV14()
insertRoom(createdAt = 5_000)
insertGroupEvent("before", createdAt = 4_000)
insertLine("before", ChatMessage.TYPE_PENDING_COMMIT, content = "gone")
MIGRATION_14_15.migrate(connection)
assertEquals(emptyList(), lines())
}
/**
* The one this rule could get wrong. A placeholder for an event from after
* the join is a message still waiting on a commit that has not landed, and a
* replay is expected to recover it -- see `NostrDao.reindexMarmotGroupEvents`.
*/
@Test
fun `a placeholder for an event from after the join is left to be recovered`() = runBlocking {
createV14()
insertRoom(createdAt = 5_000)
insertGroupEvent("after", createdAt = 6_000)
insertLine("after", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "still waiting")
MIGRATION_14_15.migrate(connection)
assertEquals(listOf("still waiting"), lines())
}
@Test
fun `a message that was read is left alone however old it is`() = runBlocking {
createV14()
insertRoom(createdAt = 5_000)
insertGroupEvent("read", createdAt = 4_000)
insertLine("read", "message", content = "hello")
MIGRATION_14_15.migrate(connection)
assertEquals(listOf("hello"), lines())
}
/**
* Lines with no group event behind them: NIP-17 messages, and everything a
* session writes about itself. The delete reaches them through
* `marmotGroupEventId`, and a null one joins to nothing, but SQL nulls are
* quiet enough about it to be worth an assertion.
*/
@Test
fun `a line with no group event behind it is out of reach of the rule`() = runBlocking {
createV14()
insertRoom(createdAt = 5_000)
insertLine(null, ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "not marmot")
MIGRATION_14_15.migrate(connection)
assertEquals(listOf("not marmot"), lines())
}
/**
* The comparison is per room. Two rooms joined at different times share one
* transcript table, and a run of placeholders in the older room says nothing
* about the newer one's.
*/
@Test
fun `each room is measured against its own join`() = runBlocking {
createV14()
insertRoom(id = "old", createdAt = 1_000)
insertRoom(id = "new", createdAt = 9_000)
insertGroupEvent("inOld", createdAt = 4_000, room = "old")
insertGroupEvent("inNew", createdAt = 4_000, room = "new")
insertLine("inOld", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, room = "old", content = "kept")
insertLine("inNew", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, room = "new", content = "gone")
MIGRATION_14_15.migrate(connection)
assertEquals(listOf("kept"), lines())
}
}