Merge branch 'mantra' into claude/home-chat-previews-e1913f
Two conflicts, and both are the same collision: mantra took schema v15 while this branch was also calling its migration v15. **The version number.** mantra's v15 adds `ChatRoom.joinedGroupAt` with a manual MIGRATION_14_15, because half of what it does -- deleting the placeholder chat lines already written for messages sent before this device joined -- is not a shape Room generates. That is the older claim on the number and it keeps it. The index migration here becomes `AutoMigration(from = 15, to = 16)` and the database goes to v16, so a device that has already run v15 gets the index on top of it rather than the two fighting over one version. `15.json` is resolved to mantra's wholesale -- an add/add conflict between two unrelated schemas is not something to merge line by line -- and 16.json is regenerated from the build. Checked rather than assumed: 16.json differs from 15.json in exactly one place, `index_ChatMessage_chatRoomId`, and no table's fields, createSql or other indices move. **mantra's new membership lines needed handling here**, and nothing would have told me: `98f766f` added `TYPE_MEMBER_INVITED`, `TYPE_MEMBER_INVITE_SENT` and `TYPE_MEMBER_INVITE_FAILED`, which the transcript renders as system notices. The chat list preview dispatches on the same question the transcript does -- is this somebody's words -- and a type missing from that check falls through to the chat bubble branch. A room whose newest line was an invite would have previewed as "Alice: Invited Bob to the group", which reads as Alice having said it. Exactly the failure `ChatMessage.MEMBERSHIP_TYPES`' own comment warns about, one screen over from where it was written. So `MEMBERSHIP_TYPES` joins the ritual and chronicle sets in `lastChatMessagePreviewText`. They are not in the AUTHORED sets -- their content is a whole sentence with the invitee's name already in it -- so they stand alone, which is what the transcript does with them too. One new test, over all three types rather than a representative one, since the set is the thing being relied on. Nothing else needed reconciling. mantra's pre-join fix filters at indexing time and deletes the rows outright, so the last-message subquery sees fewer rows and needs no `memberSince` clause of its own to stay in step with the transcript. 550 jvm tests and 319 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
30c732af67
24 files changed
+7054
-109
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 15,
|
||||
"identityHash": "9bf80a49771342e992ef3ad38208bf4c",
|
||||
"identityHash": "513c8f6ec811352bd00aba1d34651d76",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BroadcastNostrEventReceipt",
|
||||
@@ -390,17 +390,6 @@
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ChatMessage_chatRoomId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"chatRoomId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "ChatRoom",
|
||||
@@ -666,7 +655,7 @@
|
||||
},
|
||||
{
|
||||
"tableName": "ChatRoom",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` 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 )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
@@ -705,6 +694,11 @@
|
||||
"columnName": "leftGroupAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "joinedGroupAt",
|
||||
"columnName": "joinedGroupAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "chronicleRequestedAt",
|
||||
"columnName": "chronicleRequestedAt",
|
||||
@@ -5642,7 +5636,7 @@
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9bf80a49771342e992ef3ad38208bf4c')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '513c8f6ec811352bd00aba1d34651d76')"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 15,
|
||||
version = 16,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -256,11 +256,19 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// 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 an index on ChatMessage.chatRoomId. No column changes and no
|
||||
// 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.
|
||||
//
|
||||
// v16 adds an index on ChatMessage.chatRoomId. No column changes and no
|
||||
// rows move -- the chat list now looks up each room's newest line, and
|
||||
// without the index that lookup reads every message on the device. Room
|
||||
// creates an index on its own.
|
||||
AutoMigration(from = 14, to = 15),
|
||||
AutoMigration(from = 15, to = 16),
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
+6
-4
@@ -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()
|
||||
|
||||
+194
-28
@@ -21,6 +21,7 @@ import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.exceptions.MarmotMissingChatGroupException
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.extensions.exporterSecret
|
||||
import press.mantra.compose.extensions.shortened
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW
|
||||
import press.mantra.compose.nostr.MarmotDelivery
|
||||
@@ -81,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
|
||||
@@ -231,6 +239,12 @@ abstract class MarmotOutboundDao(
|
||||
}.onFailure {
|
||||
logger.e("Failed to invite $peerPublicKey to ${localChatRoom.chatRoom.id}", it)
|
||||
notAdded.add(peerPublicKey)
|
||||
announceInviteFailed(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
peerPublicKey = peerPublicKey,
|
||||
reason = it.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +301,30 @@ abstract class MarmotOutboundDao(
|
||||
)
|
||||
)
|
||||
|
||||
// One line each, as `inviteMember` writes for the invites it makes -- this
|
||||
// path does its own commit and never goes through it. Written before the
|
||||
// Welcomes rather than after, so a delivery that fails has an invite to be
|
||||
// read against instead of a failure on its own.
|
||||
peers.forEach { (peerPublicKey, _) ->
|
||||
announceMembership(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
messageType = ChatMessage.TYPE_MEMBER_INVITED,
|
||||
content = "Invited ${memberName(peerPublicKey)} to the group",
|
||||
)
|
||||
}
|
||||
|
||||
val welcomeBytes = commitResult.welcomeBytes
|
||||
if (welcomeBytes == null) {
|
||||
logger.e("Batched commit for ${localChatRoom.chatRoom.id} produced no welcome")
|
||||
peers.forEach { (peerPublicKey, _) ->
|
||||
announceInviteFailed(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
peerPublicKey = peerPublicKey,
|
||||
reason = "the group produced no invitation to send",
|
||||
)
|
||||
}
|
||||
return peers.map { it.first }
|
||||
}
|
||||
|
||||
@@ -300,17 +335,20 @@ abstract class MarmotOutboundDao(
|
||||
val notAdded = mutableListOf<HexKey>()
|
||||
|
||||
peers.forEach { (peerPublicKey, peerKeyPackage) ->
|
||||
runCatching {
|
||||
deliveryWelcome(
|
||||
nostrGroupId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
welcomeBytes = welcomeBytes,
|
||||
peerKeyPackageEventId = peerKeyPackage.id,
|
||||
relays = relays,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
}.onFailure {
|
||||
logger.e("Failed to deliver the welcome to $peerPublicKey", it)
|
||||
// `deliveryWelcome` swallows what it catches and reports it as a
|
||||
// transcript line instead, so its answer is what says whether this peer
|
||||
// was reached -- a runCatching here would see nothing to catch.
|
||||
val delivered = deliveryWelcome(
|
||||
nostrGroupId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
welcomeBytes = welcomeBytes,
|
||||
peerKeyPackageEventId = peerKeyPackage.id,
|
||||
relays = relays,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
|
||||
if (!delivered) {
|
||||
logger.e("Failed to deliver the welcome to $peerPublicKey")
|
||||
notAdded.add(peerPublicKey)
|
||||
}
|
||||
}
|
||||
@@ -318,6 +356,100 @@ abstract class MarmotOutboundDao(
|
||||
return notAdded
|
||||
}
|
||||
|
||||
/**
|
||||
* One line in the room's transcript, about a membership change.
|
||||
*
|
||||
* The same shape as `ChronicleManager.announce`, and for the same reason: this
|
||||
* is the device saying what it just did, not an event anybody sent. Content is
|
||||
* a whole sentence, so nothing prefixes a name to it -- see
|
||||
* [ChatMessage.MEMBERSHIP_TYPES].
|
||||
*/
|
||||
private suspend fun announceMembership(
|
||||
chatRoomId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
messageType: String,
|
||||
content: String,
|
||||
) {
|
||||
database.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
content = content,
|
||||
messageType = messageType,
|
||||
chatRoomId = chatRoomId,
|
||||
senderPublicKey = userPublicKey,
|
||||
isUserMessage = true,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call an invitee on a membership line.
|
||||
*
|
||||
* Written into the content rather than resolved at render time, because these
|
||||
* lines are about something that happened once and the AUTHORED-set machinery
|
||||
* that follows a rename is for lines with an actor. A member being invited has
|
||||
* a Profile row by construction -- Participant.participantPublicKey is a
|
||||
* foreign key onto it -- so the fallback is for the delivery path, which runs
|
||||
* long after the invite and reads its peer back out of a key package.
|
||||
*/
|
||||
private suspend fun memberName(publicKey: HexKey): String =
|
||||
database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey()
|
||||
?: publicKey.shortened()
|
||||
|
||||
/**
|
||||
* The Welcome for an invite made earlier has gone out.
|
||||
*
|
||||
* Written by the deferred delivery site only -- `DatabaseNostrRepository`, once
|
||||
* a relay has acknowledged the commit. An invite into a group that was still
|
||||
* just its creator sends its Welcome in the same breath as the invite, so its
|
||||
* [ChatMessage.TYPE_MEMBER_INVITED] line already says this and a second one
|
||||
* would only be the same second told twice. See [ChatMessage.MEMBERSHIP_TYPES].
|
||||
*/
|
||||
suspend fun announceInviteSent(
|
||||
chatRoomId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
peerKeyPackageEventId: HexKey,
|
||||
) {
|
||||
val peerPublicKey = database.marmotKeyPackageDao()
|
||||
.getMarmotKeyPackageById(peerKeyPackageEventId)?.publicKey
|
||||
|
||||
announceMembership(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
messageType = ChatMessage.TYPE_MEMBER_INVITE_SENT,
|
||||
content = peerPublicKey?.let { "Sent ${memberName(it)} their invitation" }
|
||||
?: "Sent the invitation",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An invite did not make it, and nothing else will say so.
|
||||
*
|
||||
* The reason is put in the line because it is the only copy the user gets: the
|
||||
* screen that asked for the invite is gone by the time the Welcome is
|
||||
* delivered, and the failures that happen before it closes take the whole
|
||||
* transaction -- and the invite line inside it -- down with them. See
|
||||
* `DatabaseChatRepository.inviteMember`.
|
||||
*/
|
||||
suspend fun announceInviteFailed(
|
||||
chatRoomId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
peerPublicKey: HexKey?,
|
||||
reason: String?,
|
||||
) {
|
||||
val who = peerPublicKey?.let { "${memberName(it)}'s invitation" } ?: "the invitation"
|
||||
val why = reason?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ""
|
||||
|
||||
announceMembership(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
messageType = ChatMessage.TYPE_MEMBER_INVITE_FAILED,
|
||||
content = "Couldn't send $who$why",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun inviteMember(
|
||||
nostrGroupId: HexKey,
|
||||
mlsGroup: MlsGroup,
|
||||
@@ -357,6 +489,20 @@ abstract class MarmotOutboundDao(
|
||||
"KeyPackage credential identity does not match memberPubKey"
|
||||
}
|
||||
|
||||
// Say so now, not when the Welcome eventually goes out. On the deferred path
|
||||
// that is a relay round trip away and may never happen at all, and until this
|
||||
// line existed the room showed nothing whatsoever in the meantime -- an invite
|
||||
// that was queued, one that failed to reach the wire and one that was never
|
||||
// made all looked identical from the transcript. Inside the caller's
|
||||
// transaction, so an invite that does not survive `addMember` leaves no claim
|
||||
// that it did.
|
||||
announceMembership(
|
||||
chatRoomId = nostrGroupId,
|
||||
userPublicKey = userPublicKey,
|
||||
messageType = ChatMessage.TYPE_MEMBER_INVITED,
|
||||
content = "Invited ${memberName(peerPublicKey)} to the group",
|
||||
)
|
||||
|
||||
val retainedBefore = mlsGroup.retainedSecrets()
|
||||
val commitResult = mlsGroup.addMember(
|
||||
peerKeyPackage.tlsEncodedMarmotKeyPackage
|
||||
@@ -462,6 +608,16 @@ abstract class MarmotOutboundDao(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the invitee's Welcome on the outbound queue.
|
||||
*
|
||||
* Returns whether it got there. The caller cannot see that any other way -- the
|
||||
* body swallows what it catches, deliberately, because on the deferred path this
|
||||
* runs from a relay acknowledgement with no invite screen left to fail back to.
|
||||
* What replaced reporting nothing at all is the [ChatMessage.TYPE_MEMBER_INVITE_FAILED]
|
||||
* line written below, which outlives the screen and is the only account of a
|
||||
* Welcome that never went out.
|
||||
*/
|
||||
suspend fun deliveryWelcome(
|
||||
nostrGroupId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
@@ -469,7 +625,7 @@ abstract class MarmotOutboundDao(
|
||||
peerKeyPackageEventId: HexKey,
|
||||
relays: List<String>,
|
||||
createdAt: Instant
|
||||
) {
|
||||
): Boolean {
|
||||
try {
|
||||
val welcomeBase64 = Base64.encode(
|
||||
source = welcomeBytes
|
||||
@@ -507,22 +663,14 @@ abstract class MarmotOutboundDao(
|
||||
)
|
||||
)
|
||||
|
||||
// The invite's own transcript line is not written here. It used to be, and
|
||||
// that put nothing in the room for an invite into an established group --
|
||||
// where this runs a relay round trip later, if at all, and never at all if
|
||||
// the ack does not come. It also hung off the two lookups below, so a
|
||||
// missing key package or profile cost the line and not just the name.
|
||||
// `inviteMember` writes it when the invite is made instead; what this
|
||||
// function still owes the room is the failure below.
|
||||
database.marmotKeyPackageDao().getMarmotKeyPackageById(peerKeyPackageEventId)?.let { marmotKeyPackage ->
|
||||
database.profileDao().getProfileByPublicKey(marmotKeyPackage.publicKey)?.let { profile ->
|
||||
// Save chatMessage for the invite...
|
||||
database.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
content = "Invited ${profile.humanReadableNameOrPubkey() ?: "participant"} to chat", // use profile.humanReadable...
|
||||
chatRoomId = nostrGroupId,
|
||||
senderPublicKey = userPublicKey,
|
||||
isUserMessage = true, // TODO: This is information message...
|
||||
giftWrapPayloadId = welcomeEventId,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// The group's signed work, offered to the member being invited.
|
||||
// Nothing else will ever show it to them: MLS gives a joiner no
|
||||
// history, and a group-signed event never travels -- every device
|
||||
@@ -550,8 +698,26 @@ abstract class MarmotOutboundDao(
|
||||
recipient = marmotKeyPackage.publicKey,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Failed to deliver welcome:", e)
|
||||
|
||||
// The room's only account of this. Its own runCatching because the
|
||||
// failure being reported may well be the database, and a throw from
|
||||
// here would be one the callers on the immediate path do not expect --
|
||||
// taking the invite's transaction down over a line about it.
|
||||
runCatching {
|
||||
announceInviteFailed(
|
||||
chatRoomId = nostrGroupId,
|
||||
userPublicKey = userPublicKey,
|
||||
peerPublicKey = database.marmotKeyPackageDao()
|
||||
.getMarmotKeyPackageById(peerKeyPackageEventId)?.publicKey,
|
||||
reason = e.message,
|
||||
)
|
||||
}.onFailure { logger.e("Failed to record the undelivered welcome:", it) }
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+58
@@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,49 @@ data class ChatMessage(
|
||||
TYPE_CHRONICLE_RECEIVED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Adding a member to the group, as lines in the group's chat.
|
||||
*
|
||||
* An invite is two separate things, and in an established group they can be
|
||||
* minutes apart: the membership change this device commits, and the Welcome
|
||||
* that lets the invitee in going on the wire. The second waits on a relay
|
||||
* acknowledging the commit -- see docs/marmot-membership.md -- and it is the
|
||||
* one that can fail after the screen that asked for it has closed, which
|
||||
* leaves the transcript as the only place able to say so.
|
||||
*
|
||||
* So [TYPE_MEMBER_INVITED] is written the moment the invite is made, and
|
||||
* [TYPE_MEMBER_INVITE_SENT] only where the Welcome is delivered later than
|
||||
* that. Where the two happen together -- a group that is still only its
|
||||
* creator has nobody to inform, so its Welcome goes out immediately -- there
|
||||
* is one line, because there was one event.
|
||||
*
|
||||
* Written by the inviter's device, for itself. None of these travels: what
|
||||
* the group receives is the commit, and each member's transcript is written
|
||||
* from what it made of that. A member watching from the side sees nothing
|
||||
* here, correctly.
|
||||
*
|
||||
* Content is a whole sentence rather than a predicate, so these stay out of
|
||||
* the AUTHORED sets and nothing prefixes a name to them. The invitee's name
|
||||
* is written into the content the way the chronicle's is, which does not
|
||||
* follow a rename and is the accepted cost for a line about a thing that
|
||||
* happened once.
|
||||
*/
|
||||
const val TYPE_MEMBER_INVITED = "memberInvited"
|
||||
const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent"
|
||||
const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed"
|
||||
|
||||
/**
|
||||
* Every membership line, for the one check the transcript dispatches on.
|
||||
*
|
||||
* A type missing from here renders as a chat bubble -- silently, and looking
|
||||
* exactly like the inviter having said "Invited Bob to the group".
|
||||
*/
|
||||
val MEMBERSHIP_TYPES = setOf(
|
||||
TYPE_MEMBER_INVITED,
|
||||
TYPE_MEMBER_INVITE_SENT,
|
||||
TYPE_MEMBER_INVITE_FAILED,
|
||||
)
|
||||
|
||||
const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer"
|
||||
const val TYPE_PENDING_COMMIT = "pendingCommit"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+7
-5
@@ -99,10 +99,11 @@ data class LocalChatRoom(
|
||||
*
|
||||
* Follows the transcript's own dispatch, for the same reasons it does:
|
||||
*
|
||||
* - a ritual or chronicle line is nobody's words, and its content is written as a
|
||||
* predicate for an actor's name -- so the authored ones get that name in front and
|
||||
* the rest stand alone. Prefixing "a shared key ceremony started" with a member
|
||||
* would read as them having announced it.
|
||||
* - a ritual, chronicle or membership line is nobody's words. The authored ones
|
||||
* have content written as a predicate for an actor's name, so they get that name
|
||||
* in front; the rest are whole sentences already and stand alone. Prefixing "a
|
||||
* shared key ceremony started" with a member would read as them having announced
|
||||
* it, and prefixing "Invited Bob to the group" with one reads as them saying it.
|
||||
* - a private message this device cannot open has no words to show. Saying that a
|
||||
* private message was sent, and to whom, is all the group can learn from the line
|
||||
* and all the preview should claim.
|
||||
@@ -114,7 +115,8 @@ data class LocalChatRoom(
|
||||
|
||||
if (lastChatMessage.messageType in ChatMessage.DKG_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.FROST_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES
|
||||
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES
|
||||
) {
|
||||
val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
|
||||
|
||||
+11
-5
@@ -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,
|
||||
|
||||
+24
-5
@@ -168,11 +168,30 @@ class DatabaseChatRepository(
|
||||
peerPublicKey: HexKey,
|
||||
peerKeyPackage: MarmotKeyPackage
|
||||
) {
|
||||
database.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peerPublicKey,
|
||||
peerKeyPackage = peerKeyPackage
|
||||
)
|
||||
try {
|
||||
database.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peerPublicKey,
|
||||
peerKeyPackage = peerKeyPackage
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
// Outside the transaction that just went down, which is the point: the
|
||||
// invite line `inviteMember` writes went with it, so an invite refused
|
||||
// for want of MLS state or over a mismatched credential left the room
|
||||
// with nothing at all to show. The caller still gets the throw and
|
||||
// still puts its own message on the invite screen -- this is the copy
|
||||
// that is still there tomorrow.
|
||||
logger.e("Failed to invite $peerPublicKey to ${localChatRoom.chatRoom.id}", e)
|
||||
runCatching {
|
||||
database.marmotOutboundDao().announceInviteFailed(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
peerPublicKey = peerPublicKey,
|
||||
reason = e.message,
|
||||
)
|
||||
}.onFailure { logger.e("Failed to record the refused invite:", it) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
override suspend fun addMembers(
|
||||
localChatRoom: LocalChatRoom,
|
||||
|
||||
+37
-7
@@ -439,15 +439,45 @@ class DatabaseNostrRepository(
|
||||
database.chatRoomDao().findChatRoomById(marmotCommitResult.chatRoomId)?.let { localChatRoom ->
|
||||
// TODO: Update status of participant Invitation.PENDING -> Invitation.SENT
|
||||
logger.d("localChatRoom: $localChatRoom")
|
||||
marmotCommitResult.welcomeBytes?.let { welcomeBytes ->
|
||||
logger.d("welcomeBytes: ${welcomeBytes.toHex()}")
|
||||
database.marmotOutboundDao().deliveryWelcome(
|
||||
nostrGroupId = marmotCommitResult.chatRoomId,
|
||||
val welcomeBytes = marmotCommitResult.welcomeBytes
|
||||
if (welcomeBytes == null) {
|
||||
// The commit was acknowledged and there is nothing to let
|
||||
// the invitee in with, so this invite is over. Nobody is
|
||||
// waiting on the answer by now -- the screen that asked
|
||||
// closed when the commit was made -- which is why it goes
|
||||
// in the room rather than to a caller.
|
||||
database.marmotOutboundDao().announceInviteFailed(
|
||||
chatRoomId = marmotCommitResult.chatRoomId,
|
||||
userPublicKey = marmotCommitResult.userPublicKey,
|
||||
peerPublicKey = database.marmotKeyPackageDao()
|
||||
.getMarmotKeyPackageById(marmotCommitResult.peerKeyPackageEventId)
|
||||
?.publicKey,
|
||||
reason = "the commit carried no invitation",
|
||||
)
|
||||
return@let
|
||||
}
|
||||
|
||||
logger.d("welcomeBytes: ${welcomeBytes.toHex()}")
|
||||
val delivered = database.marmotOutboundDao().deliveryWelcome(
|
||||
nostrGroupId = marmotCommitResult.chatRoomId,
|
||||
userPublicKey = marmotCommitResult.userPublicKey,
|
||||
welcomeBytes = welcomeBytes,
|
||||
peerKeyPackageEventId = marmotCommitResult.peerKeyPackageEventId,
|
||||
createdAt = marmotCommitResult.createdAt,
|
||||
relays = Relays.DefaultDMRelayList.map { it.url } // TODO: Get localChatRoom relays...
|
||||
)
|
||||
|
||||
// This is the deferred delivery site, and the only one where
|
||||
// the Welcome goes out later than the invite that made it --
|
||||
// so it is the only one that owes the room a second line. An
|
||||
// invite whose room was still just its creator sent its
|
||||
// Welcome in the same breath and said so once already. A
|
||||
// failure needs nothing here: `deliveryWelcome` files its own.
|
||||
if (delivered) {
|
||||
database.marmotOutboundDao().announceInviteSent(
|
||||
chatRoomId = marmotCommitResult.chatRoomId,
|
||||
userPublicKey = marmotCommitResult.userPublicKey,
|
||||
welcomeBytes = welcomeBytes,
|
||||
peerKeyPackageEventId = marmotCommitResult.peerKeyPackageEventId,
|
||||
createdAt = marmotCommitResult.createdAt,
|
||||
relays = Relays.DefaultDMRelayList.map { it.url } // TODO: Get localChatRoom relays...
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-7
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
+27
-1
@@ -424,6 +424,24 @@ class ChatMessageListViewModel(
|
||||
return@items
|
||||
}
|
||||
|
||||
// Inviting somebody is nobody's words either, and
|
||||
// for a while it was not in the room at all: the
|
||||
// line was written when the Welcome went out, which
|
||||
// on the deferred path is a relay round trip away
|
||||
// and may never happen. Passed as answered and
|
||||
// settled for the same reason the chronicle's are
|
||||
// -- these report rather than ask, so they stay in
|
||||
// the quiet tint and offer nothing to review.
|
||||
if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES) {
|
||||
RitualNotice(
|
||||
localChatMessage = localChatMessage,
|
||||
isAnswered = true,
|
||||
isSettled = true,
|
||||
onClick = {}
|
||||
)
|
||||
return@items
|
||||
}
|
||||
|
||||
// Signing lines are the same kind of thing and get
|
||||
// the same treatment -- nobody said them either --
|
||||
// but they lead somewhere else, because what a
|
||||
@@ -745,6 +763,13 @@ private fun RitualNotice(
|
||||
ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download
|
||||
|
||||
// An invite made and an invite sent are two separate steps on the deferred
|
||||
// path, so they get separate icons -- the whole reason both lines exist is
|
||||
// to be able to see that the first happened and the second did not.
|
||||
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
|
||||
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
|
||||
|
||||
else -> Icons.Default.PanTool
|
||||
}
|
||||
|
||||
@@ -763,7 +788,8 @@ private fun RitualNotice(
|
||||
|
||||
val tint = when {
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ->
|
||||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ->
|
||||
MaterialTheme.colorScheme.error
|
||||
isRequest -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
+103
@@ -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)))
|
||||
}
|
||||
}
|
||||
+19
@@ -153,6 +153,25 @@ class ChatRoomLastMessagePreviewTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An invite is nobody's words either, and its content is a whole sentence with the
|
||||
* invitee's name already in it. A prefix here reads as the inviter having said
|
||||
* "Invited Bob to the group" out loud.
|
||||
*/
|
||||
@Test
|
||||
fun `a membership line stands alone`() {
|
||||
ChatMessage.MEMBERSHIP_TYPES.forEach { messageType ->
|
||||
assertEquals(
|
||||
"Invited Bob to the group",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(content = "Invited Bob to the group", messageType = messageType)
|
||||
).lastChatMessagePreviewText(),
|
||||
"$messageType should not be attributed to its sender"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bystanders' copy of a private message has no content. An empty preview would
|
||||
* read as the sender having said nothing, so the line says what the group can in
|
||||
|
||||
+125
-18
@@ -6,6 +6,7 @@ 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.MarmotKeyPackage
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
@@ -51,25 +52,36 @@ class MarmotOutboundDaoJvmTest {
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val peer = KeyPair().pubKey.toHexKey()
|
||||
private val secondPeer = KeyPair().pubKey.toHexKey()
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound
|
||||
* gift wrap has, which is the case the guard exists for.
|
||||
* A member the database knows by name. Every invitee needs one --
|
||||
* Participant.participantPublicKey is a foreign key onto Profile -- and the membership
|
||||
* lines read the name back out of it.
|
||||
*/
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
private suspend fun seedProfile(publicKey: String, name: String, nostrEventId: String) {
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound
|
||||
* gift wrap has, which is the case the guard exists for.
|
||||
*/
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
seedProfile(user, "user", "c".repeat(64))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
@@ -100,18 +112,7 @@ class MarmotOutboundDaoJvmTest {
|
||||
*/
|
||||
private suspend fun seedMlsRoom(): LocalChatRoom {
|
||||
val stateless = seedStatelessRoom()
|
||||
val peerEventId = "e".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = peerEventId,
|
||||
pubKey = peer,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = peer, userName = "peer", nostrEventId = peerEventId))
|
||||
seedProfile(peer, "peer", "e".repeat(64))
|
||||
val mlsGroup = MlsGroup.create(
|
||||
identity = user.hexToByteArray(),
|
||||
initialExtensions = listOf(
|
||||
@@ -265,4 +266,110 @@ class MarmotOutboundDaoJvmTest {
|
||||
assertEquals(1, retained.size, "the pre-commit epoch was not retained")
|
||||
assertEquals(0L, retained.single().epoch, "a freshly created group is at epoch 0")
|
||||
}
|
||||
|
||||
// ---- What the room is told about it -----------------------------------
|
||||
|
||||
private suspend fun transcript() = db.chatMessageDao().getChatMessagesByChatRoomId(roomId)
|
||||
.map { it.chatMessage }
|
||||
|
||||
/**
|
||||
* The invite has to reach the transcript, and as a membership line rather than a chat
|
||||
* bubble. It used to be written by `deliveryWelcome` -- which meant it depended on the
|
||||
* invitee's key package and profile both being found, and neither is here, so the room
|
||||
* was told nothing at all about an invite that succeeded.
|
||||
*/
|
||||
@Test
|
||||
fun `inviting a member puts a line in the room`() = runBlocking {
|
||||
val localChatRoom = seedMlsRoom()
|
||||
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
val line = transcript().singleOrNull { it.messageType == ChatMessage.TYPE_MEMBER_INVITED }
|
||||
assertNotNull(line, "the invite left no line in the room")
|
||||
assertTrue(line.content.contains("peer"), "the line does not name the invitee: ${line.content}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the line exists for. A group that already has members defers its Welcome until
|
||||
* a relay acknowledges the commit -- see docs/marmot-membership.md -- so an invite made
|
||||
* here is on nothing but a promise, and until the line moved to invite time the room said
|
||||
* nothing whatsoever in the meantime and nothing ever if the ack never came.
|
||||
*/
|
||||
@Test
|
||||
fun `an invite into an established group is in the room before its welcome goes out`() = runBlocking {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = seedMlsRoom(),
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
seedProfile(secondPeer, "second", "f".repeat(64))
|
||||
|
||||
// Re-read: the first invite advanced the epoch and persisted new state.
|
||||
val established = assertNotNull(db.chatRoomDao().findChatRoomById(roomId))
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = established,
|
||||
peerPublicKey = secondPeer,
|
||||
peerKeyPackage = marmotKeyPackageFor(secondPeer),
|
||||
)
|
||||
|
||||
val invited = transcript().filter { it.messageType == ChatMessage.TYPE_MEMBER_INVITED }
|
||||
assertEquals(2, invited.size, "the deferred invite left no line in the room")
|
||||
assertTrue(
|
||||
invited.any { it.content.contains("second") },
|
||||
"the second invitee is not named: ${invited.map { it.content }}",
|
||||
)
|
||||
|
||||
// Nothing has acknowledged the commit, so the Welcome has not gone out and nothing
|
||||
// may claim it has. That absence is what makes a stuck invite visible.
|
||||
assertTrue(
|
||||
transcript().none { it.messageType == ChatMessage.TYPE_MEMBER_INVITE_SENT },
|
||||
"a welcome was reported sent before any relay acknowledged the commit",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership lines are not somebody's words, and the transcript decides that from the
|
||||
* type alone. One missing from [ChatMessage.MEMBERSHIP_TYPES] renders as a chat bubble,
|
||||
* looking exactly like the inviter having said "Invited peer to the group".
|
||||
*/
|
||||
@Test
|
||||
fun `the invite line is a membership line and not a bubble`() = runBlocking {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = seedMlsRoom(),
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
transcript().all { it.messageType in ChatMessage.MEMBERSHIP_TYPES },
|
||||
"an invite wrote something the transcript would render as a bubble: " +
|
||||
transcript().map { it.messageType },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused invite must not leave the room claiming one was made. The line is written
|
||||
* inside the caller's transaction precisely so that it goes down with everything else the
|
||||
* refused invite touched -- what tells the user instead is
|
||||
* `DatabaseChatRepository.inviteMember`, from outside it.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused invite leaves no claim that one was made`() = runBlocking {
|
||||
runCatching {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = seedStatelessRoom(),
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
transcript().none { it.messageType == ChatMessage.TYPE_MEMBER_INVITED },
|
||||
"the room was told about an invite that was refused",
|
||||
)
|
||||
}
|
||||
}
|
||||
+174
@@ -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)
|
||||
}
|
||||
}
|
||||
+250
@@ -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())
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package press.mantra.compose.database.repository
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.MarmotKeyPackage
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.exceptions.MarmotMissingChatGroupException
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the room is told when an invite does not happen.
|
||||
*
|
||||
* `MarmotOutboundDao.inviteMemberToChatRoom` writes the invite's transcript line inside its
|
||||
* own transaction, which is right: an invite that does not survive `addMember` must not leave
|
||||
* the room claiming one was made. But it means a refusal takes the only account of itself
|
||||
* down with it, and the screen that asked has closed by the time anybody looks -- so the
|
||||
* repository is where a failure has to be written from, outside the transaction that rolled
|
||||
* back.
|
||||
*/
|
||||
class DatabaseChatRepositoryJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val peer = KeyPair().pubKey.toHexKey()
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
private val repository = DatabaseChatRepository(
|
||||
database = db,
|
||||
scope = CoroutineScope(Dispatchers.Unconfined),
|
||||
)
|
||||
|
||||
private suspend fun seedProfile(publicKey: String, name: String, nostrEventId: String) {
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
/** A room restored from an inbound gift wrap: no MLS state, so nothing to invite into. */
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
seedProfile(user, "user", "c".repeat(64))
|
||||
seedProfile(peer, "peer", "e".repeat(64))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
subject = "a restored room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
private fun keyPackage() = MarmotKeyPackage(
|
||||
id = "d".repeat(64),
|
||||
publicKey = peer,
|
||||
tlsEncodedMarmotKeyPackage = ByteArray(0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a refused invite is written into the room it was refused for`() = runBlocking<Unit> {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
assertFailsWith<MarmotMissingChatGroupException> {
|
||||
repository.inviteMember(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
|
||||
val line = db.chatMessageDao().getChatMessagesByChatRoomId(roomId)
|
||||
.map { it.chatMessage }
|
||||
.singleOrNull { it.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED }
|
||||
|
||||
assertNotNull(line, "the refused invite left no line in the room")
|
||||
assertTrue(line.content.contains("peer"), "the line does not name the invitee: ${line.content}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller still has to hear about it. The transcript line is the copy that is there
|
||||
* tomorrow; the throw is what puts a message on the invite screen today, and swallowing it
|
||||
* would pop the user back to the chat as though the invite had gone out.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused invite still reaches the caller`() = runBlocking<Unit> {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
assertFailsWith<MarmotMissingChatGroupException> {
|
||||
repository.inviteMember(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,37 @@ The group state is persisted after `commit()` and before any Welcome is delivere
|
||||
so a crash between the two leaves the group at the epoch the Welcomes describe
|
||||
rather than one behind it.
|
||||
|
||||
## What the room is told
|
||||
|
||||
An invite writes three kinds of line into the room's transcript, none of which
|
||||
travels — see `ChatMessage.MEMBERSHIP_TYPES`. They exist because the deferral
|
||||
above is invisible from anywhere else: the screen that asks for an invite closes
|
||||
the moment the commit is made, and everything that can still go wrong goes wrong
|
||||
after that.
|
||||
|
||||
| line | written by | says |
|
||||
|-----------------------------|-----------------------------------------|---------------------------------------|
|
||||
| `TYPE_MEMBER_INVITED` | `inviteMember`, `addMembersToChatRoom` | the membership change was made |
|
||||
| `TYPE_MEMBER_INVITE_SENT` | `DatabaseNostrRepository` | the deferred Welcome went on the wire |
|
||||
| `TYPE_MEMBER_INVITE_FAILED` | `deliveryWelcome`, `DatabaseChatRepository` | it did not, and why |
|
||||
|
||||
The invite line is written **when the invite is made**, inside the caller's
|
||||
transaction. Both halves of that matter: written any later and an invite waiting
|
||||
on an ack that never comes leaves the room showing nothing, which is what this
|
||||
looked like before; written outside the transaction and an invite that does not
|
||||
survive `addMember` leaves the room claiming one was made.
|
||||
|
||||
Only the immediate branch's Welcome goes out in the same breath as the invite, so
|
||||
only the deferred branch owes a second line. Where the two are one event, one line
|
||||
is the whole truth — and `createdAt` is stored to the second, so a second line
|
||||
would be one the transcript could not reliably order after the first anyway.
|
||||
|
||||
A refusal is the awkward case, because rolling the transaction back is right and
|
||||
takes the account of it with it. `DatabaseChatRepository.inviteMember` catches,
|
||||
writes the failed line from outside the transaction, and rethrows — the throw is
|
||||
what puts a message on the invite screen now, the line is what is still there
|
||||
tomorrow.
|
||||
|
||||
## Other things that bite
|
||||
|
||||
**Sequential invites each advance the epoch.** Where they still happen — the
|
||||
@@ -140,9 +171,12 @@ error.
|
||||
|
||||
**A member with no published key package cannot be added.** A Marmot invite needs
|
||||
the invitee's `MarmotKeyPackage`. Both call sites look it up with a timeout and
|
||||
collect the ones that failed. Today that only reaches the log — the
|
||||
`TODO: Update status of participant Invitation.PENDING -> Invitation.SENT` at the
|
||||
Welcome delivery site is the same gap seen from the other end.
|
||||
collect the ones that failed. That is still only a log and a returned list —
|
||||
nothing puts it in the room, because it happens before there is an invite to write
|
||||
a line about. The `TODO: Update status of participant Invitation.PENDING ->
|
||||
Invitation.SENT` at the Welcome delivery site is the same gap seen from the other
|
||||
end: the transcript lines above say what happened, but a Participant row still
|
||||
carries no state saying where its invite got to.
|
||||
|
||||
**`deliveryWelcome` uses `Relays.DefaultDMRelayList`, not the room's relays.**
|
||||
There is a `TODO: Get localChatRoom relays...` on the ack-triggered call site.
|
||||
Reference in new issue
Block a user