Merge branch 'mantra' into claude/african-dialect-convenience-de2f69

One file overlapped and git merged it cleanly: ChatRoomDetailScreen.kt, where
mantra turned `parentChatRoomIds` into `parents` carrying each parent's room, and
this branch added the "Add dialects from a list" button under the dialect list.
Checked rather than assumed -- the merged file is mantra's version plus only this
branch's additions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-03 00:38:21 +02:00
commit 4a11cc6ed9
12 files changed
+457 -244

No files matched your search

@@ -192,8 +192,8 @@ class DatabaseChatRepository(
false
}
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = try {
SubgroupManager.parentsOf(database, chatRoomId)
override suspend fun parentsOf(chatRoomId: String): List<SubgroupManager.Parent> = try {
SubgroupManager.parentGroupsOf(database, chatRoomId)
} catch (e: Throwable) {
logger.e("Error reading the parents of $chatRoomId", e)
emptyList()
@@ -842,6 +842,35 @@ object SubgroupManager {
database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds()
.orEmpty()
/**
* A group this room is a child of, plus the room itself where this device
* has it.
*
* [room] is the only place a parent's *name* can come from: a certificate
* names the child, never the certifier, so a device that is not in the
* parent room knows the lineage and not what to call it. Null is therefore
* the ordinary case for a subgroup member who is not also a parent member,
* and the id is all such a screen can honestly show.
*/
data class Parent(
val chatRoomId: HexKey,
val room: LocalChatRoom?,
)
/**
* [parentsOf], with each parent's room where this device holds one.
*
* The same verified set in the same order -- this only looks each id up, and
* a parent whose room is missing stays in the list rather than being dropped.
*/
suspend fun parentGroupsOf(database: MantraDatabase, chatRoomId: String): List<Parent> =
parentsOf(database, chatRoomId).map { parentChatRoomId ->
Parent(
chatRoomId = parentChatRoomId,
room = database.chatRoomDao().findChatRoomById(parentChatRoomId)
)
}
/**
* What a certificate arriving in a room is, or null if it is nothing this
* room should believe.
@@ -19,7 +19,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
interface ChatRepository {
suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>>
@@ -78,8 +78,12 @@ interface ChatRepository {
* may be adopted by another later. A member welcomed in after the founding
* holds no state and gets an empty list even when the room really is a
* subgroup.
*
* Each carries the parent's room where this device has one, which is the
* only place its name can come from -- a certificate names the child and
* never the certifier.
*/
suspend fun parentsOf(chatRoomId: String): List<HexKey>
suspend fun parentsOf(chatRoomId: String): List<SubgroupManager.Parent>
/**
* One event the group signed, as the group signed it, or null when this
@@ -308,7 +312,13 @@ interface ChatRepository {
): MarmotReindexReport
companion object {
val NO_OP_CHAT_REPOSITORY = object: ChatRepository {
/**
* A chat repository with no database behind it: every room is absent, every
* membership change is refused with a reason, and no member throws. See
* [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent
* misattribution rather than a loud failure, and for the rules a new member follows.
*/
val NO_OP_CHAT_REPOSITORY: ChatRepository = object : ChatRepository {
override suspend fun requestGroupHistoryIfMissing(
chatRoomId: String,
userPublicKey: HexKey
@@ -323,7 +333,9 @@ interface ChatRepository {
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> =
emptyList()
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = emptyList()
override suspend fun parentsOf(
chatRoomId: String
): List<SubgroupManager.Parent> = emptyList()
override suspend fun groupSignedEvent(
chatRoomId: String,
@@ -338,9 +350,8 @@ interface ChatRepository {
coordinatorPublicKey: HexKey,
): String? = "No database"
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
return flow { }
}
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> =
flowOf(emptyList())
override suspend fun getChatRoomListByPublicKey(publicKey: String): List<LocalChatRoom> {
return emptyList()
@@ -375,22 +386,18 @@ interface ChatRepository {
userPublicKey: HexKey
): Boolean = false
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant> {
TODO("Not yet implemented")
}
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant> =
emptyList()
override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? {
TODO("Not yet implemented")
}
override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? =
null
override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage> {
return emptyList()
}
override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow<List<LocalChatMessage>> {
return flow {
}
}
override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow<List<LocalChatMessage>> =
flowOf(emptyList())
override suspend fun getOrCreateChatRoom(
chatRoomId: String,
@@ -407,13 +414,11 @@ interface ChatRepository {
return null
}
override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? {
TODO("Not yet implemented")
}
override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? =
null
override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow<MarmotKeyPackage?> {
TODO("Not yet implemented")
}
override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow<MarmotKeyPackage?> =
flowOf(null)
override suspend fun createNip17ChatRoom(
userPublicKey: HexKey,
@@ -424,23 +429,30 @@ interface ChatRepository {
return null
}
/**
* The empty id, meaning no room was created.
*
* The only member here whose return type cannot say "nothing": it owes the id
* of a room, and `String` has no absent value. Both creators beside it can say
* they failed -- [createNip17ChatRoom] returns null, [createMarmotGroup] an
* [MarmotGroupCreation.Outcome] -- so this one's signature is the odd one out
* rather than this body being the odd answer. Nothing reaches it in practice:
* `ChatRoomMessagingViewModel.initiateNewChat` calls it only once a profile and
* a key package have both resolved, and against this object neither ever does.
*/
override suspend fun createMlsDirectMessage(
name: String?,
description: String?,
userPublicKey: HexKey,
peerPublicKey: HexKey,
peerKeyPackage: MarmotKeyPackage
): String {
TODO("Not yet implemented")
}
): String = ""
override suspend fun inviteMember(
localChatRoom: LocalChatRoom,
peerPublicKey: HexKey,
peerKeyPackage: MarmotKeyPackage
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun addMembers(
localChatRoom: LocalChatRoom,
@@ -464,45 +476,36 @@ interface ChatRepository {
localChatRoom: LocalChatRoom,
messageType: Kind,
directMessageRecipientPublicKey: HexKey?
) {
// TODO("Not yet implemented")
}
) = Unit
override fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? {
return null
}
override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow<List<GiftWrapPayload>> {
TODO("Not yet implemented")
}
override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow<List<GiftWrapPayload>> =
flowOf(emptyList())
override suspend fun sealGiftWrapPayload(
giftWrapPayload: GiftWrapPayload,
nostrSignerSync: NostrSignerSync
) {
TODO("Not yet implemented")
}
) = Unit
// None of the three calls onCompletion: nothing was left or deleted, and each
// of these callbacks navigates away from a room that is still there.
override suspend fun leaveChatRoom(
localChatRoom: LocalChatRoom,
onCompletion: () -> Unit
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun softDeleteChatRoom(
chatRoom: ChatRoom,
onCompletion: () -> Unit
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun deleteChatRoom(
chatRoom: ChatRoom,
onCompletion: () -> Unit
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun reindexMarmotGroupEvents(
chatRoomId: String,
@@ -5,6 +5,7 @@ import press.mantra.compose.database.model.MarmotKeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
interface MarmotRepository {
@@ -33,40 +34,35 @@ interface MarmotRepository {
suspend fun encryptAndSendMarmotInnerEvent(marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync)
companion object {
val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository {
/**
* A repository with no key packages behind it: inert, and no member throws. See
* [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent
* misattribution rather than a loud failure, and for the rules a new member follows.
*/
val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE: MarmotRepository = object : MarmotRepository {
override suspend fun publishMarmotKeyPackageBundle(
publicKey: HexKey,
nsecPassword: String
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) {
TODO("Not yet implemented")
}
override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) = Unit
override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow<List<MarmotKeyPackageBundle>> {
TODO("Not yet implemented")
}
override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow<List<MarmotKeyPackageBundle>> =
flowOf(emptyList())
override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle> {
return emptyList()
}
override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle> =
emptyList()
override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow<List<MarmotInnerEvent>> {
TODO("Not yet implemented")
}
override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow<List<MarmotInnerEvent>> =
flowOf(emptyList())
override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow<MarmotKeyPackageBundle?> {
TODO("Not yet implemented")
}
override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow<MarmotKeyPackageBundle?> =
flowOf(null)
override suspend fun encryptAndSendMarmotInnerEvent(
marmotInnerEvent: MarmotInnerEvent,
nostrSignerSync: NostrSignerSync
) {
TODO("Not yet implemented")
}
) = Unit
}
}
}
}
@@ -20,6 +20,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
interface NostrRepository {
suspend fun getLocalAccounts(): List<LocalAccount>
@@ -209,78 +210,97 @@ interface NostrRepository {
suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List<BroadcastNostrEventRequest>)
companion object {
val NO_OP_NOSTR_REPOSITORY = object : NostrRepository {
override suspend fun getLocalAccounts(): List<LocalAccount> {
TODO("Not yet implemented")
}
/**
* A repository with nothing behind it: every question answers "nothing here",
* every write goes nowhere, and **no member throws**.
*
* Previews and test fixtures are the only callers, and both want inertness. It is
* written out here because these members were once `TODO()`, and a `TODO()` in a
* no-op is not a loud failure -- it is a silent misattribution. Callers reach most
* of them from a `viewModelScope.launch(Dispatchers.IO)` that nobody awaits, so the
* `NotImplementedError` escapes to the platform handler, is stashed by
* kotlinx-coroutines-test's `ExceptionCollector`, and is re-thrown as
* `UncaughtExceptionsBeforeTest` inside *the next* `runTest` -- under that test's
* name, with the real culprit buried as a suppressed cause. One `TODO()` here
* (`queueNegentropySynchronizeRequest`, reached from
* `ChatMessageListViewModel.scheduleSynchronization`) failed `NavigationRoutingTest`
* four times a run for a call that test never made.
*
* So the rules for a member added here, which the other three no-op repositories
* follow too:
*
* - **Answer the call, never stub it out.** `NoOpRepositoryTotalityJvmTest` reads
* these four objects and fails if an unimplemented stub reappears in one.
* - **A flow emits its empty value once** -- `flowOf(null)`, `flowOf(emptyList())` --
* rather than `flow { }`. A screen collecting an empty flow waits in Loading
* forever; one collecting `flowOf(null)` renders its empty state, which is what
* a preview is for.
* - **A callback is not invoked.** Nothing happened, and calling `onCompletion` or
* `onSuccess` would say it did -- letting a test pass on the fixture rather than
* on the code under test.
* - **Throwing is never the way to be loud.** A member that a test must not call is
* the test's assertion to make, not the fixture's: override it with a recorder and
* assert it stayed untouched. An exception thrown here lands on whichever test
* runs next.
*
* A test that needs a real answer from one member overrides just that one:
* `object : NostrRepository by NO_OP_NOSTR_REPOSITORY { … }`.
*/
val NO_OP_NOSTR_REPOSITORY: NostrRepository = object : NostrRepository {
override suspend fun getLocalAccounts(): List<LocalAccount> = emptyList()
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> {
TODO("Not yet implemented")
}
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> =
flowOf(null)
override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? {
TODO("Not yet implemented")
}
override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? = null
override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?> {
TODO("Not yet implemented")
}
override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?> =
flowOf(null)
override suspend fun observeRelation(
publicKey: HexKey,
activePublicKey: HexKey
): Flow<List<Connection>> {
TODO("Not yet implemented")
}
): Flow<List<Connection>> = flowOf(emptyList())
override suspend fun deleteConnection(connection: Connection) {
TODO("Not yet implemented")
}
override suspend fun deleteConnection(connection: Connection) = Unit
override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<List<UnsignedNostrEvent>> {
TODO("Not yet implemented")
}
override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<List<UnsignedNostrEvent>> =
flowOf(emptyList())
override suspend fun observePendingBroadcastNostrEventRequests(): Flow<LocalBroadcastNostrEventRequest> {
TODO("Not yet implemented")
}
override suspend fun observePendingBroadcastNostrEventRequests(): Flow<LocalBroadcastNostrEventRequest?> =
flowOf(null)
override suspend fun reopenInbox(activeKeyPair: KeyPair): Int = 0
override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow<SynchronizeNostrEventRequest?> {
TODO("Not yet implemented")
}
override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow<SynchronizeNostrEventRequest?> =
flowOf(null)
override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow<NegentropySynchronizeRequest?> {
TODO("Not yet implemented")
}
override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow<NegentropySynchronizeRequest?> =
flowOf(null)
// onCompletion is deliberately not called: no profile was created, and a
// fixture that reports completion is a fixture a test can pass against.
override suspend fun createNewProfile(
publicKey: HexKey,
name: String?,
biography: String?,
onCompletion: suspend () -> Unit
) {
TODO("")
}
) = Unit
override suspend fun signInToProfile(publicKey: HexKey) {
TODO("Not yet implemented")
}
override suspend fun signInToProfile(publicKey: HexKey) = Unit
override suspend fun setUpProfileForExistingKey(
publicKey: HexKey,
placeholderUnsignedNostrEventId: Long,
name: String?,
biography: String?,
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun forgetLocalAccount(publicKey: HexKey) {
TODO("Not yet implemented")
}
override suspend fun forgetLocalAccount(publicKey: HexKey) = Unit
// Neither onSuccess nor onError: the note was not written, and it did not fail
// to be written either -- there was nowhere to write it. A test that wants
// either branch overrides this member and calls the one it is testing.
override suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List<HexKey>,
@@ -289,74 +309,50 @@ interface NostrRepository {
quotedNostrEvent: LocalNostrEvent?,
onError: () -> Unit,
onSuccess: () -> Unit
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? {
return null
}
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? =
null
override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List<UnsignedNostrEvent>) {
TODO("Not yet implemented")
}
override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List<UnsignedNostrEvent>) = Unit
override suspend fun wipeDatabase() {
}
override suspend fun wipeDatabase() = Unit
override suspend fun publishNostrEvent(
unsignedNostrEvent: UnsignedNostrEvent,
nostrEvent: NostrEvent,
relayURLs: List<String>,
activeKeyPair: KeyPair
) {
}
) = Unit
override suspend fun broadcastProcessed(
broadcastNostrEventRequest: BroadcastNostrEventRequest,
status: String
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun requeueStaleBroadcastNostrEventRequests(): Int {
TODO("Not yet implemented")
}
override suspend fun requeueStaleBroadcastNostrEventRequests(): Int = 0
override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) {
TODO("Not yet implemented")
}
override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit
override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) {
TODO("Not yet implemented")
}
override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit
override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) {
TODO("Not yet implemented")
}
override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) = Unit
override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) {
TODO("Not yet implemented")
}
override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) = Unit
override suspend fun saveNostrEvent(
nostrEvent: NostrEvent,
synchronizeNostrEventRequest: SynchronizeNostrEventRequest,
synchronizationRelayURLs: List<String>,
activeKeyPair: KeyPair
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun saveNostrEvent(
nostrEvent: NostrEvent,
negentropySynchronizeRequest: NegentropySynchronizeRequest,
synchronizationRelayURLs: List<String>,
activeKeyPair: KeyPair
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun saveNostrEvent(
nostrEvent: NostrEvent,
@@ -364,82 +360,54 @@ interface NostrRepository {
synchronizationRelayURLs: List<String>,
level: Int,
activeKeyPair: KeyPair
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>) {
TODO("Not yet implemented")
}
override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>) = Unit
override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List<NegentropySynchronizeRequest>) {
TODO("Not yet implemented")
}
override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List<NegentropySynchronizeRequest>) = Unit
override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow<Int> {
TODO("Not yet implemented")
}
override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow<Int> =
flowOf(0)
override suspend fun getNostrEvent(
publicKey: HexKey,
kind: Kind
): NostrEvent? {
TODO("Not yet implemented")
}
): NostrEvent? = null
override suspend fun observeNostrFeed(): Flow<List<LocalNostrEvent>> {
TODO("Not yet implemented")
}
override suspend fun observeNostrFeed(): Flow<List<LocalNostrEvent>> = flowOf(emptyList())
override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow<List<LocalNostrEvent>> {
TODO("Not yet implemented")
}
override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow<List<LocalNostrEvent>> =
flowOf(emptyList())
override suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array<SynchronizationFilter>,
applyLimits: Boolean
): List<NostrEvent> {
TODO("Not yet implemented")
}
): List<NostrEvent> = emptyList()
override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow<LocalNostrEvent?> {
TODO("Not yet implemented")
}
override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow<LocalNostrEvent?> =
flowOf(null)
override suspend fun searchableProfiles(excludingPublicKeys: Array<HexKey>): List<Profile> {
TODO("Not yet implemented")
}
override suspend fun searchableProfiles(excludingPublicKeys: Array<HexKey>): List<Profile> =
emptyList()
override suspend fun filterProfiles(searchTerm: String): List<LocalProfile> {
TODO("Not yet implemented")
}
override suspend fun filterProfiles(searchTerm: String): List<LocalProfile> = emptyList()
override suspend fun observeProfileWithFollowers(publicKey: String): Flow<LocalProfileWithFollowers?> {
TODO("Not yet implemented")
}
override suspend fun observeProfileWithFollowers(publicKey: String): Flow<LocalProfileWithFollowers?> =
flowOf(null)
override suspend fun observeProfileWithFollowing(publicKey: String): Flow<LocalProfileWithFollowing?> {
TODO("Not yet implemented")
}
override suspend fun observeProfileWithFollowing(publicKey: String): Flow<LocalProfileWithFollowing?> =
flowOf(null)
override suspend fun getRecentSearches(): List<RecentSearch> {
TODO("Not yet implemented")
}
override suspend fun getRecentSearches(): List<RecentSearch> = emptyList()
override suspend fun removeRecentSearch(recentSearch: RecentSearch) {
TODO("Not yet implemented")
}
override suspend fun removeRecentSearch(recentSearch: RecentSearch) = Unit
override suspend fun saveSearchQuery(
query: String,
synchronizationFilterArray: press.mantra.compose.database.model.typealiases.SynchronizationFilterArray?
) {
TODO("Not yet implemented")
}
) = Unit
override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List<BroadcastNostrEventRequest>) {
TODO("Not yet implemented")
}
override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List<BroadcastNostrEventRequest>) = Unit
}
}
}
}
@@ -7,10 +7,15 @@ interface SearchRepository {
)
companion object {
val NO_OP_SEARCH_REPOSITORY = object: SearchRepository {
override suspend fun submitSearch(synchronizeNostrEventRequests: List<press.mantra.compose.database.model.SynchronizeNostrEventRequest>) {
TODO("Not yet implemented")
}
/**
* A search that reaches no relay: inert, and the one member does not throw. See
* [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent
* misattribution rather than a loud failure.
*/
val NO_OP_SEARCH_REPOSITORY: SearchRepository = object : SearchRepository {
override suspend fun submitSearch(
synchronizeNostrEventRequests: List<press.mantra.compose.database.model.SynchronizeNostrEventRequest>
) = Unit
}
}
}
}
@@ -285,15 +285,40 @@ fun ChatRoomDetailScreen(
// later. They are a set rather than a path -- neither
// is "the" parent and the order says nothing about
// which came first -- so each gets an equal row.
chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId ->
item(key = "parent-$parentChatRoomId") {
chatRoomDetailUIState.parents.forEach { parent ->
item(key = "parent-${parent.chatRoomId}") {
// The parent's own name, which only its room
// can give: a certificate names the child and
// never the certifier, so a member of the
// subgroup who is not also in the parent has
// the id and nothing else to call it by.
val name = parent.room
?.chatRoom
?.subject
?.takeIf { it.isNotBlank() }
// Shown either way -- as the headline where
// there is no name, and under it where there
// is, since it is the part that can be
// compared against what the lineage says.
val identifier: @Composable () -> Unit = {
Text(
text = parent.chatRoomId
.take(16)
.inComparableGroups(),
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
}
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
onNavigateToRoute.invoke(
ChatRoomDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = parentChatRoomId,
chatRoomId = parent.chatRoomId,
relayHint = relayHint
)
)
@@ -312,19 +337,25 @@ fun ChatRoomDetailScreen(
contentDescription = Decorative
)
},
headlineContent = {
// The label keeps its place above the
// name rather than being replaced by
// it: a card carrying a group's name
// and nothing else says nothing about
// which way the lineage runs, and the
// signing key's card -- same icon
// column, same shape -- sits directly
// above this one.
overlineContent = {
Text(text = stringResource(Res.string.parent_group))
},
supportingContent = {
Text(
text = parentChatRoomId
.take(16)
.inComparableGroups(),
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
}
headlineContent = {
if (name != null) {
Text(text = name)
} else {
identifier()
}
},
supportingContent = identifier.takeIf { name != null }
)
}
}
@@ -83,7 +83,7 @@ class ChatRoomDetailViewModel(
groupKeyState = chatRepository.groupKeyState(chatRoomId),
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
subgroups = chatRepository.subgroupsOf(chatRoomId),
parentChatRoomIds = chatRepository.parentsOf(chatRoomId),
parents = chatRepository.parentsOf(chatRoomId),
canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
chatRepository.canSign(chatRoomId),
adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId),
@@ -45,15 +45,18 @@ sealed interface ChatRoomDetailUIState {
*/
val subgroups: List<SubgroupManager.Subgroup> = emptyList(),
/**
* The group this room is a subgroup of, or null when it is nobody's
* The groups this room is a subgroup of, empty when it is nobody's
* child.
*
* Verified: it comes off the room's key state, which only carries a
* parent whose certificate was checked. A member welcomed in after the
* founding holds no state and so sees nothing here, even in a room that
* really is a subgroup.
*
* Each carries the parent's room where this device holds one, so the
* card can name the group rather than only identify it.
*/
val parentChatRoomIds: List<HexKey> = emptyList(),
val parents: List<SubgroupManager.Parent> = emptyList(),
/**
* Whether this device could open a subgroup here at all.
*
@@ -777,6 +777,54 @@ class SubgroupManagerJvmTest {
Unit
}
@Test
fun `a parent this device is in is named by its room`() = runBlocking {
parentWith(members = listOf(alice, bob))
childWithKeyState(parents = listOf(parentRoomId))
val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single()
assertEquals(parentRoomId, parent.chatRoomId)
// The room, and nothing else, is where a parent's name comes from: a
// certificate names the child and never the certifier.
assertEquals("Ekklesia", parent.room?.chatRoom?.subject)
}
@Test
fun `a parent this device is not in is still listed, with nothing to name it`() = runBlocking {
// No parent room: the ordinary position of a subgroup member who is not
// also in the group above. The lineage is known -- it was checked
// against that group's own signature -- so the parent stays in the list
// and only its name is missing.
childWithKeyState(parents = listOf(parentRoomId))
val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single()
assertEquals(parentRoomId, parent.chatRoomId)
assertNull(parent.room)
}
@Test
fun `two parents keep the lineage's own order, named or not`() = runBlocking {
parentWith(members = listOf(alice, bob))
childWithKeyState(parents = listOf(parentRoomId, siblingRoomId))
val parents = SubgroupManager.parentGroupsOf(db, childRoomId)
// The same set in the same order -- canonical, which is sorted by id and
// so says nothing about which parent came first. Asserted against
// `parentsOf` rather than against a written-out order, since the ids are
// derived from keys and nobody should have to know how two of them sort.
assertEquals(
SubgroupManager.parentsOf(db, childRoomId),
parents.map { it.chatRoomId }
)
assertEquals(
mapOf(parentRoomId to "Ekklesia", siblingRoomId to null),
parents.associate { it.chatRoomId to it.room?.chatRoom?.subject }
)
}
private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? =
SubgroupManager.refuseAdoption(
database = db,
@@ -0,0 +1,138 @@
package press.mantra.compose.repository
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Every member of every `NO_OP_*` repository answers its call. None is left stubbed out.
*
* Every one in `commonMain/.../repository/`, found by reading the directory rather than by
* naming files -- see [declaration].
*
* The rule is worth a test of its own because breaking it does not fail where it is broken.
* A `TODO()` in one of these objects throws `NotImplementedError`, and the callers are view
* models that reach their repository from `viewModelScope.launch(Dispatchers.IO)` -- a
* coroutine nobody awaits. The error escapes to the platform handler, where
* kotlinx-coroutines-test's `ExceptionCollector` stashes it (`unprocessedExceptions`, because
* a `runBlocking` test has no `TestScope` registered to take it), and the *next* `runTest` to
* start drains the stash and rethrows it as `UncaughtExceptionsBeforeTest` -- under that
* test's name, for a call it never made. The real stack trace survives only as a suppressed
* cause several frames down.
*
* That cost a debugging session once: `queueNegentropySynchronizeRequest` was a `TODO()`,
* `CeremonyQueueRoutingJvmTest` reached it through `ChatMessageListViewModel.initiate()`, and
* `NavigationRoutingTest > an account already on the device does not stay on the loading
* screen` failed four times a run while the test that caused it passed. Seventeen test files
* build on `NO_OP_NOSTR_REPOSITORY` alone, so one stub is never one test's problem.
*
* So this is checked at the only altitude where it can be checked cheaply and totally: the
* source. Calling all sixty-odd members for real would mean a fixture for every parameter
* type, and a test that fails because a fixture was wrong tells you less than this one does.
* A reflective sweep has the same problem -- it cannot tell a body that threw from an argument
* it could not build.
*
* It fails under its own name, which is the whole point.
*/
class NoOpRepositoryTotalityJvmTest {
/**
* Every file that declares one, found rather than listed.
*
* A list would be wrong the first time a ninth repository is added -- and was: the
* first version of this test named six files when the tree held eight, having missed
* `MantraRepository` and `CachingImportRepository` (both total, which is luck rather
* than cover). Discovery is the point of the test, so it discovers.
*/
private val declaration = Regex("""\bval\s+NO_OP_[A-Z0-9_]+""")
/**
* A floor, in the spirit of `docs/scripts/m3-audit.sh`: the way this test breaks
* silently is by finding nothing to check -- a moved package, a renamed convention --
* and then passing. Eight is the count on 2026-10-02. Raise it when one is added.
*/
private val knownSingletons = 8
@Test
fun `no no-op repository leaves a member unimplemented`() {
val directory = repositoryDirectory()
val sources = (directory.listFiles() ?: emptyArray())
.filter { it.isFile && it.name.endsWith(".kt") }
.map { it.name to it.readText() }
.filter { (_, source) -> declaration.containsMatchIn(source) }
.sortedBy { (name, _) -> name }
assertTrue(
sources.size >= knownSingletons,
"Found ${sources.size} no-op repositories in ${directory.path}, expected at " +
"least $knownSingletons. Either one was removed -- lower the floor in the " +
"same change -- or this test has stopped finding them and is passing on " +
"nothing. Found: ${sources.map { it.first }}"
)
val offences = sources.flatMap { (name, source) -> stubbedMembers(name, source) }
assertEquals(
emptyList(),
offences,
"A no-op repository member is stubbed out. Give it the answer a repository with " +
"nothing behind it would give -- null, an empty collection, flowOf(...) of " +
"one of those, Unit, 0, false -- and never TODO(). See the contract on " +
"NostrRepository.NO_OP_NOSTR_REPOSITORY. Stubs found:\n" +
offences.joinToString("\n") { " $it" }
)
}
/**
* `file:line member` for every `TODO(` in code, comments excluded -- the contract on
* `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its prose, and a doc comment explaining the
* rule must not trip the check that enforces it.
*/
private fun stubbedMembers(name: String, source: String): List<String> {
val found = mutableListOf<String>()
var inBlockComment = false
var member = "(before the first declaration)"
source.lines().forEachIndexed { index, raw ->
var line = raw
if (inBlockComment) {
val end = line.indexOf("*/")
if (end < 0) return@forEachIndexed
line = line.substring(end + 2)
inBlockComment = false
}
val blockStart = line.indexOf("/*")
if (blockStart >= 0) {
inBlockComment = !line.substring(blockStart).contains("*/")
line = line.substring(0, blockStart)
}
val lineComment = line.indexOf("//")
if (lineComment >= 0) line = line.substring(0, lineComment)
if (line.contains("fun ")) member = line.trim()
if (line.contains("TODO(")) found += "$name:${index + 1} $member"
}
return found
}
/**
* Found by walking up from the working directory rather than taken from it, because the
* one is the `composeApp` project directory under `:composeApp:jvmTest` and the other is
* the repository root when the same test is run from the root project. A test about
* things that fail silently does not get to pass because it could not find its input.
*/
private fun repositoryDirectory(): File {
val relative = "composeApp/src/commonMain/kotlin/press/mantra/compose/repository"
var directory: File? = File("").absoluteFile
while (directory != null) {
val candidate = File(directory, relative)
if (candidate.isDirectory) return candidate
directory = directory.parentFile
}
throw AssertionError(
"Could not find $relative above ${File("").absolutePath} -- this test read nothing."
)
}
}
@@ -13,7 +13,6 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.delay
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
@@ -152,23 +151,16 @@ class CeremonyQueueRoutingJvmTest {
val viewModel = ChatMessageListViewModel(
initialChatMessageListUIState = ChatMessageListUIState.Loading,
localChatRoom = room(),
// Not the bare no-op. `initiate` calls `scheduleSynchronization` on
// its second line, which launches on `Dispatchers.IO` and -- because
// The bare no-op is safe again. `initiate` calls `scheduleSynchronization`
// on its second line, which launches on `Dispatchers.IO` and -- because
// `room()` gives every participant a null profile, so there is always
// somebody it cannot name -- reaches
// `queueNegentropySynchronizeRequest`. That member is one of the
// forty-one `TODO("Not yet implemented")` bodies on
// NO_OP_NOSTR_REPOSITORY, so it throws in a coroutine nobody awaits.
//
// Nothing catches it, so it reaches the default handler, and the next
// `runTest` in the same JVM reports it as `UncaughtExceptionsBeforeTest`
// -- against its own name. That is how this test failed
// `NavigationRoutingTest` four times a run while passing itself.
nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
override suspend fun queueNegentropySynchronizeRequest(
negentropySynchronizeRequests: List<NegentropySynchronizeRequest>
) = Unit
},
// somebody it cannot name -- reaches `queueNegentropySynchronizeRequest`.
// That member used to be one of forty-one `TODO()` bodies on
// NO_OP_NOSTR_REPOSITORY and threw into whichever `runTest` ran next,
// which is how this test failed `NavigationRoutingTest` four times a run
// while passing itself. The override that stood here is gone because the
// no-op is total now; `NoOpRepositoryTotalityJvmTest` keeps it that way.
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {