99f48d176974cf064a292fa6dae7ae18712dda4c
883 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
99f48d1769 |
fix(build): restore the lightning-kmp-app pin a merge regressed
`claude/focused-brattain-433b3b` was bootstrapped with the submodule at `84cc44c8`
and never moved it, so every `git add -A` in that worktree staged the older gitlink
over whatever `mantra` had reached. The fast-forward just carried that back here,
undoing three commits of the library: the merged threshold fix, secp256k1-kmp's linux
cinterop sync fix, and the advance that went with them.
`e0d2b823` is what `mantra` pinned at
|
||
|
|
c0db6908a1 |
Merge branch 'mantra' into claude/focused-brattain-433b3b
Takes the dialect catalogue, the subgroups-for-entities line and the first four ios phases -- thirty-two commits. **Renumbered again, and this time the collision was not only a number.** `mantra` reached 23 with two nullable `DkgSession` columns saying what a subgroup is *for*. That is the table this branch's first migration rebuilds. So the three become 23->24, 24->25 and 25->26, and the rebuild's column list grew by `originKind` and `originId` -- a rebuild is the one kind of migration that cannot ignore a column it knows nothing about, because leaving one out drops it. **And the rebuild had a bug that only this merge could expose.** It copied with `SELECT *`, which was fine while every file reaching it had the entity's declared column order. An `AutoMigration` that adds a column does it with `ALTER TABLE ... ADD COLUMN`, appending it physically at the end -- so a file that came through mantra's 22->23 hands `SELECT *` the columns in a different order than the backup table declares them, and the copy lands shifted. `DkgSessionMigrationThroughRoomJvmTest` and `ChatRoomSplitThroughRoomJvmTest` both failed with `NOT NULL constraint failed: DkgSessionBackup.updatedAt`, which is `originKind` arriving where a timestamp was expected. Both copies name their columns now. Schemas 24 and 25 were regenerated, since each describes one of this branch's migrations applied to a base that has moved. 26 is the current shape. The chain checks out: 23 adds the origin columns, 24 keys the ritual by profile and carries them, 25 splits the membership off the room, 26 puts the sender's identity on a transcript line. Four new reads of a room's MLS state came with the dialect and project detail screens, and ask through the viewing profile like the rest. 1298 tests, 0 failures. |
||
|
|
2f542408c9 |
docs(subgroups): the dialect catalogue is in the tree now, so cite it again
Written while the catalogue was on an unmerged branch, so three citations were replaced with the reasoning stated directly. It landed on mantra during the build and the merge brought it in, so `DialectScaffold`, `AfricanLanguages` and docs/african-dialect-catalogue.md are all here. The restated reasoning stays rather than turning back into links -- a plan that explains its own rule survives the file it borrowed it from being moved -- but two cross-references are worth having. `DialectScaffold` is the closest thing in the tree to `SubgroupOrigin`, and `DialectScaffold.pendingIn` is Phase 7's `ceremoniesFor` solving the same race one layer up: read what is already in flight, so two admins looking at the same screen do not propose the same thing twice. Eighty dialects there, two rooms for one project here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b8d08ebf64 | Merge branch 'mantra' into claude/subgroup-convenience-function-31b1b2 | ||
|
|
e5d966bc6a |
docs(subgroups): what the implementation chose differently, and one floor that cannot move
The deviations table every doc in here carries, and it is the most-read part of them. Seven entries, of which three are worth the reader's time. `proposeBirthCertificate` was planned to take an `origin` parameter; it already takes the whole `DkgSession`, so it reads `key.origin()` and nothing in the repository or the ritual view model changed. A parameter would have been a second source for one fact, and the proposer's copy of it at that. Phase 3 put a button on four screens and Phase 7 replaced all four with one `LazyListScope` extension, so they were written twice. The giveaway was already in Phase 0.3, which argued the dialect's entry point had to be a screen because a card cannot *grow* into a list -- the same argument applied to all four, and the section should have been planned where the buttons were. And Phase 8's own claim was wrong: the M3 adaptive floor cannot be raised by this work. The floors count `MaterialTheme.breakpoint`, `BoxWithConstraints`, the pane scaffolds and the navigation components; `readableContent()` and `NavigateBackButton` are neither, so a screen that follows every rule in CLAUDE.md adds nothing to either count. Raising a floor to the number the tree happens to sit at would make it fail the next time something unrelated was removed. The floors guard two-pane layout and the navigation shell, which a detail screen is not, and they stay where phase 6 of the conformance work left them. Recorded rather than quietly dropped, because the next plan will want to make the same assumption. Pointers from docs/README.md, from docs/subgroups.md beside the multiple-parents line, and from docs/project-feature.md -- where a project's own room was the first thing that file said it lacked, and which now says the rows stay in the parent's tables all the same. Phase 8 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5f68112717 |
feat(subgroups): and the other direction -- what has been made for this
The phase that makes the link worth having. Until now a certificate could say what a room was for and nothing could ask the question backwards, so a member looking at a project had no way to find the room the group made for it. `subgroupsFor` is a filter over `subgroupsOf` rather than a query of its own, because the certificates are already the record and a second read could disagree with the first about which of them verify. The case that earns its test is a sibling subgroup for something else: a filter on the wrong field would include it and still read as a working screen. `ceremoniesFor` is the half that prevents a real race. Two admins open a project's screen, neither can see the other's ceremony, and the group gets two ceremonies and two rooms for one project -- the screen that invites the act is the screen that cannot otherwise see the act in progress. Finished and failed are both left out, for different reasons: a complete ceremony is a subgroup that was made and belongs in the list above, and a failed one is nothing to offer back. The stage filter is in Kotlin rather than in the `WHERE`, because `DkgRitualStage` goes through a converter and a SQL comparison would be the query deciding what the converter's output looks like. Nothing is refused. A group may want two rooms for one artifact -- two teams, two bodies of work -- and deciding otherwise would be this device deciding a group's structure. So the button changes its words to "give this another room" instead of disappearing, and the deliberate second room stays one tap away while the accidental one becomes hard to make by mistake. The section is one `LazyListScope` extension for all four screens rather than four copies, which also replaces the four button blocks Phase 3 added. Four copies of "has this been given a room of its own" would drift into four answers. It hides itself entirely when there is nothing made, nothing running and nothing this device could start, rather than leaving a heading over a gap. Phase 7 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7640461345 |
feat(subgroups): say what the room is for, where a quorum is deciding
This is the phase the origin tag was for. A parent admin approving a birth certificate is putting the group's signature to a room id produced by a ceremony most of them were not in; until now the screen offered them a name and a head count, which say how big the thing is and not what it does. The proposal now reads "#Setswana Luke · for the project Gospel of John · run by 4 members", and that middle clause is the only line that says what is being agreed to. `SubgroupManager.Subgroup` gains the parsed origin beside the name and admins it already parses, so every existing reader keeps working and the room's subgroup list can say what each child is for -- as an overline, because the supporting line is already carrying whether this device can open the room and both are worth a line. Names are resolved where the device holds the row and shown as a kind plus a short hash where it does not, which is `describe`'s existing bargain. That case is a fact rather than a gap: these entities live in the parent's tables, so a certificate is allowed to name a row the reader will never hold, and a test asserts the honest version rather than letting it read as a broken screen. An unrecognised kind is named by its number, or on the card left out entirely -- describing something wrongly is worse than not describing it. Resolution reuses the four `getX(id)` repository functions that already exist rather than adding a query, and happens once per load in the view model, because a composable cannot await a database. In `ChatRoomDetailViewModel` the list is read into a local first: resolving inside the argument list would make the map and the cards depend on evaluation order. The card's four strings are four complete sentences rather than a kind word interpolated into one, since a translated fragment assembled at runtime is a sentence no translator ever saw. Phase 6 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c77187e78d |
feat(subgroups): schema 23 -- what a room is for survives the ceremony
Two nullable DkgSession columns and one tag on the ritual proposal, so the thing a subgroup is being made for reaches the certificate. It has to travel this way rather than in the coordinator's head. The certificate is proposed *after* the ceremony and by whoever is able to -- any parent admin holding a share, who may never have seen the screen that started it -- which is why every step of a subgroup reads stored rows rather than a session handed down from a screen. The origin is one more row to read. And because it rides the proposal, every participant's session carries it and not only the coordinator's: an admin asked for the certificate reads what the room is for off their own session instead of taking the proposer's word. That is the same road `subject` and `participantPublicKeys` took in v19, for the same reason, and the migration comment says so. Neither column is a foreign key, following parentChatRoomId's precedent in v17 and with a sharper version of its reason: these entities live in the *parent's* tables, and a subgroup's admins need never have been in the parent, so naming a row this device does not hold is the normal state. A cascade would also mean deleting a project deletes a room. There is a test for the dangling case, since nothing in the schema would otherwise say it was intended. It authenticates nothing. Anybody can put any kind and any id on a proposal, as with the parent beside it, and `parseOrigin`'s comment says so where a reader will be standing. The claim that decides anything is the certificate, which the parent's quorum signs. One deviation from the plan, which said `proposeBirthCertificate` would take an `origin` parameter and the view model would pass it. It already takes the whole `DkgSession`, so it reads `key.origin()` instead -- a parameter would have been a second source for one fact, and the proposer's copy of it at that. Nothing in the repository or the ritual view model had to change. Schema 23 collides with an unmerged branch holding 23, 24 and 25; Phase 0.2 of docs/subgroups-for-entities.md records why this takes 23 anyway. Phase 5 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8d1e341131 |
feat(subgroups): a certificate can say what the room is for
One optional tag -- `["subgroupFor", "<kind>", "<id>"]` -- and it is the whole of the link between a subgroup and the dialect, artifact, translation or project it was made for. The certificate is the only artefact in the relationship that is signed, travels, and is already read on both sides, so the link goes there rather than in a local column nobody else can see or a room name that is not an identity. The signature already covers the tags, since an event id hashes over them, so nothing is added to the claim by putting it there -- only to what a parent admin can see before agreeing. That is the same bargain `SubgroupKeyTag` made. Both halves or neither. An id alone would have to be looked up in four tables to be understood, and a reader without the kind cannot tell "a project this device does not hold" from "not a project at all"; a kind alone names a category. The pair follows `PayloadKindTag`/`PayloadIdTag`, which carry the same two for the same reason. The tag sits after the named tags and before the run of `p` tags, and the position is load bearing rather than incidental: a reader that predates this takes the tags it knows off the front and ignores the rest, while one inserted into the admin run would split the founding roster for anybody reading positionally. `SubgroupLineage` fixes its order for the same kind of reason, and a test asserts the position rather than trusting the comment. `certifies` is untouched, and that is the point of the third test. The origin joins the name and the `p` tags as founding history: a certificate may name a row this device will never hold, because a subgroup's members need never have been in the parent, and a certificate with no origin at all is valid because every one signed before today has none. What the quorum signs is the intention that the room be for that entity; nothing vouches that the entity exists, and nothing can. Phase 4 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2c3c949b86 |
feat(subgroups): ask for a room from the thing that needs one
Four buttons, one per entity, each opening the existing picker with the name already in it. This is the cheap version of the feature complete: a member looking at a translation can give it a room without backing out to the group, finding "Add subgroup" and retyping a name the app already knew. `SelectSubgroupAdminsRoute` grows three nullable fields. The name is a suggestion and stays editable -- a group renaming its own room is not a route's business to prevent -- and it is seeded in the view model's constructor rather than in `initiate`, because a field somebody may already be typing into must not be overwritten by a load completing underneath them. The origin is two flat fields rather than one type, because a route is `@Serializable` and is rebuilt from a bundle. The nav host reassembles them and requires both or neither: half a reference is a reference with the resolvable part removed. The view model then only holds it. Riding it onto the ceremony needs a `DkgSession` column and a tag on the proposal, which is Phase 5, and the comment at the `proposeRitual` call says so rather than leaving a parameter that looks forgotten. The gate is `canAddSubgroup` on all four states and not `canSign`: a share of the key *and* `mlsGroupState != null`, because a NIP-17 room has no admins to be a parent's and these entities can live in one. Three states had to learn it. `ProjectDetailUIState` and `TranslationArtifactVersionDetailUIState` already carried `canSign`; `ArtifactDetailUIState` carried no capability flag at all, so the artifact screen and its view model gained a `ChatRepository` as well -- the one place this phase is more than a button. Each flag is computed off `SubgroupOrigin.of(entity).parentChatRoomId` rather than the route's `chatRoomId`. They are the same room in every path that exists, and reading it through the function is what keeps the parent of a subgroup the room the entity actually lives in if a route is ever built by hand. Hidden rather than disabled where it cannot work, which is what the room's own subgroup entry does and for the same reason: a device with no share cannot open the certificate session at all, and `proposeSigningBatch` throws rather than refusing. Phase 3 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b504ae5fb |
Merge branch 'mantra' into claude/serene-dubinsky-41325e
The ios build line, ten commits, against one nip30303 tag fix. Not a single
file is touched by both sides, so nothing had to be reconciled: this branch
changes TranslationArtifactVersionIdTag and adds TagRoundTripTest, and mantra
arrives with the Darwin ktor engine, the Room constructor, the gradle 9 stub
task, the pin bump and the comma sweep.
|
||
|
|
0727a04bae |
feat(dialects): a dialect is a thing you can open
Artifacts, translations and projects each have a route, a state, a view model and a screen. A dialect had a `ListItem` inside `ChatRoomDetailScreen`'s `LazyColumn` with no `onClick`, which made it the one entity in a room a member could look at and not open. This is that screen, and the card finally gets the `onClick` it never had. The two lists are the reason it exists. A dialect is a name, a language code and a country; those three sit at the bottom under the details the way they do everywhere else, and what a member opens a dialect to find out is what the group has written in it. `MantraArtifact.dialectId` and `MantraTranslationArtifactVersion.dialectId` are the only two columns in the app that point at a dialect, and neither had a query. Both new queries are scoped by the dialect alone, with no `chatRoomId` beside it. `dialectId` is a foreign key into this room's own rows, so the key already confines the read to one room and a second filter would be a second spelling of a constraint that is already enforced. That is the one-room scope decision made load-bearing rather than restated; when a dialect can be named across rooms -- Phase 9 of docs/project-feature.md -- these two queries are among the places that have to learn about it, which is why the reasoning is on them rather than here. `Loaded` also carries `canAddSubgroup`, which Phase 3 needs and which is the same two conditions `ChatRoomDetailUIState` already states: a Marmot room, and a share of its key. It reads the room off the *dialect's* `chatRoomId` rather than the route's, so the parent of a subgroup made here is the room the dialect actually lives in even if a route is ever built by hand. A missing dialect is `Error` and not an empty `Loaded`, because "this room has written nothing in Sesotho" and "there is no such dialect here" would otherwise look identical and only one of them is worth a retry button. The test that earns its place is the two-dialects-in-one-room case: if either query ever drifted to scoping by room, every single-dialect test would still pass and that one would not. Phase 2 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
153e92f558 |
feat(subgroups): read a subgroup out of the thing it is for
A dialect, an artifact, a translation and a project already carry the three facts a subgroup needs -- the room they live in, a name, and an id -- so `SubgroupOrigin.of` reads them and hands back a candidate. That is the whole of the convenience, and it is deliberately only that: no ceremony is opened, no refusal is re-decided, and the admins and the threshold are still settled in the picker and nowhere else. `SubgroupOriginEntity` is an interface rather than four overloads so that "which things can a subgroup be made for" is a question the compiler answers. A fifth entity gains the capability by implementing it. The three fields are the ones already on the rows; the only new one is the kind, as a computed property so Room never sees it -- a kind is a fact about the type, and a stored copy could disagree with the event the row came from. The kind is carried because an id alone would have to be looked up in four tables to be understood, and a reader without it cannot tell "a project this device does not hold" from "not a project at all". `Ref` holds both or neither for the same reason. Two things the tests are really for. The name comes back bare, because `MarmotGroupName.of` adds the `#` where the name is used -- into the certificate the parent signs and onto the room -- and a third place that has to agree is a third place that can disagree, most easily with the field the user is typing in. And a blank name stays blank: the picker already refuses to confirm without one, and inventing a name here would route around a refusal whose job is to be hit. Phase 1 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c17a5be894 |
docs(subgroups): a plan for making a subgroup from a dialect, an artifact, a translation or a project
Four entities, one shape: `id`, `name`, `chatRoomId` and a per-type kind. So the convenience function the title asks for is four field reads, and recording that is most of what this doc does -- the rest is the part that is actually missing. Nothing in the app can say which subgroup a project's room *is*. A name pre-filled into the picker gets a room related to its entity by a string somebody typed once, which is the same bargain a dialect already loses: nothing in the schema makes a dialect unique, so its identity is `(language, country)` and never its name. So the link goes on the birth certificate -- the one artefact in the relationship that is signed, travels, and is already read on both sides -- as an optional `subgroupFor` tag the parent's quorum signs along with the name it already signs. Eight phases, one commit each, one migration. Phases 1-3 are a shippable release on their own and are exactly the cheap version of this feature; 4-7 add the tag, the `DkgSession` columns that carry it past the ceremony, and the reverse view that makes it worth having. Two things Phase 0 found by doing what it says: A dialect has no detail screen, so it is Phase 2 and not a button on a card. The first draft said the opposite; the reversal is recorded in the doc rather than edited out, because the argument against the card is the argument for Phase 7 -- a `ListItem` in a `LazyColumn` has nowhere to put a list of the subgroups made for an entity, and a dialect is the entity a room is most likely to want several rooms for. And the schema number collided. 23, 24 and 25 are taken on an unmerged branch; this takes 23 anyway, because a number is free relative to the merge base and pre-allocating 26 moves the renumbering rather than avoiding it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
276608679f |
fix(nip30303): a translation reference parses as itself, not as a dialect
TranslationArtifactVersionIdTag.parse was declared to return DialectIdTag? and constructed one. The tag-name guard was right -- it compared against this class's own TAG_NAME -- so the event id came off the right slot of the right tag, and nothing behaved differently: all nine call sites reach straight through to .ref, and the two classes have identical shape. A caller that branched on the type, or collected parse results across the family, would have got a dialect reference where a translation reference belongs. The twenty-four tags in that package were written from one template, so the whole family was checked: this was the only mismatch. Every other parse returns and constructs its enclosing class. The wire names are a separate matter and stay as they are. Six tags carry a stray Tag suffix -- dialectIdTag, chapterIdTag, chunkIdTag, translationIdTag, translationChapterIdTag, translationChunkIdTag -- which ProjectIdTag already documents as a copy-paste slip that cannot be fixed, because those strings sit inside event ids the group has signed. Renaming one invalidates every signature over it. TagRoundTripTest covers all twenty-four, grouped by the four shapes they take, plus a matrix asserting no parse accepts any of the other twenty-three arrays -- the same template's other failure mode, a copied TAG_NAME. The mechanism is the type argument, and it has to be written out. The first draft of the test left T to inference and passed against the bug when it was reintroduced to check: T is read off the function reference, so a parse returning DialectIdTag simply makes T be DialectIdTag, every member the two classes share resolves, and assertIs<T> is a tautology. With the class named explicitly at each call site, a parse whose declared return type is a sibling no longer satisfies (Array<String>) -> T? and the file stops compiling on that tag's own line -- earlier and more precisely than any runtime check. Verified by re-breaking the file: the build fails naming the offending parse. The KDoc says so, because tidying the type argument away would silently defeat the test. One more test, on arrayOfNotNull rather than on the family: it strips only trailing nulls and writes "" for an interior one, so a reference assembled with an author but no relay hint is [name, id, "", author] and the author stays in slot 3. That is the shape most of these tags are assembled in, and if it ever changed every author would be read as a relay hint and dropped. 605 unit tests pass on --rerun-tasks, and m3Audit is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19543df263 |
Merge branch 'mantra' into claude/curated-kmp-ios-build-plan-ec280d
The african-dialect line, nine commits, against the four landed ios phases. No conflict: the only file both sides touched is docs/README.md, where each added its own table row, and mantra left the library pin alone. The merge is worth a note anyway, because it could have undone Phase 3. That phase took the commas out of 84 commonTest names to get linkDebugTestIosArm64 past Kotlin/Native's "Name contains illegal characters: ','", and mantra arrives with five new commonTest files. They happen to carry none, so the invariant holds -- but it holds by luck rather than by anything enforcing it, and the next branch to merge may not be so tidy. jvmTest 1221, testDebugUnitTest 599, the library's 159, and the m3 audit's ten counts all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4a11cc6ed9 |
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> |
||
|
|
46ccec3439 |
fix(scripts): stop m3-touch-targets.py reading comments as code
A KDoc line on the dialect picker's row composable, explaining that it used `Card(onClick = …)` rather than a `Modifier.clickable`, was counted as a bare clickable chain. The budget is zero, so a sentence about the rule broke `./gradlew check`. The comment was reworded to get around it, which was the wrong fix -- the sentence was right -- and it is restored here. Not a new rule. m3-spacing-positions.py skips `\s*(//|\*|/\*)` and m3-title-case.py skips the same three prefixes as a startswith tuple; only this checker was missing the two block-comment ones, having been written with `//` alone. m3-audit.sh explains the whole problem at NOT_A_COMMENT, where a note about a hardcoded colour once counted as a colour. The same function had the mirror bug, which is the dangerous direction. It looked for minimumInteractiveComponentSize in a two-line window without stripping comments, so a comment above a real bare `.clickable` that merely mentioned the modifier would have excused it. A false positive is noise; a silently excused offender is a miss. Both lines of the window are now read as code only. Checked against six cases: a bare clickable is still caught, a guard on the same line and a guard on the line above still excuse one, a starred KDoc no longer counts, and a real offender under a comment naming the modifier is now caught. Counts for the tree are unchanged -- 0 before and after -- which was worth establishing, since a broken detector would also report zero. A prefix test rather than block tracking, and the limitation is written on the function: a `/* ... */ `body line not starting with `*` still reads as code. There are 539 block comments under ui/ and not one such line, because every KDoc here stars its body, and three identical rules spelled three ways would be worse than one limitation on the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
55c8f92cd3 |
docs: record what the first four ios phases actually did
Phases 0 to 4 are on this branch and verified on linux; 5 needs two decisions and an asset, 6 and 7 need a mac. Each phase's text is left as it was planned, with a Landed note where what happened is worth knowing. Three findings the plan did not predict. The three library bumps touch no Kotlin source anywhere in the chain -- eleven build scripts in secp256k1-kmp and nothing else -- so gradle reports jvmTest UP-TO-DATE afterwards, which reads like a stale cache and is not one. That bump also carries four fixes beyond the stub archives, the only consequential one being compileSdk 35 -> 36 in secp256k1-kmp's jni/android, below Mantra's own 37. And two of the 84 test names needed rewording by hand, where the comma had been carrying real work rather than pausing. Also records that `-Xdisable-phases` is in but unproven -- no KotlinNativeLink task exists here for it to match -- and the team id Phase 5 is expected to use, which still wants confirming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e6c3c8a244 |
fix(ios): build ktor clients on Darwin so wss/https work on a device
CIO has no TLS implementation on kotlin/native, so every secure connection died at the handshake with "TLS sessions are not supported on Native platform". Pick the engine per platform -- CIO on android and the desktop jvm, Darwin on ios -- and pin both the relay socket client and coil's image fetcher to it. Coil is the half that is easy to miss. Nothing in the app configured an ImageLoader, so its default ktor fetcher built a bare `HttpClient()` and took whichever engine the classpath served first; every avatar and preview over https would have failed the same way the sockets did, with nothing in the app's own code to point at. `MantraApp` is the one place both call sites are reachable from, and the factory is installed above `MantraTheme` so it is in place before the first AsyncImage composes. The darwin dependency goes inside the existing isMacOsX gate, so nothing about resolution on a linux or windows host changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d024838bae |
build(ios): unbreak the ios link tasks
Two unrelated failures with one symptom from outside: neither
`linkDebugTestIosArm64` nor `linkReleaseFrameworkIosArm64` completes.
linkDebugTestIosArm64: Kotlin/Native rejects backticked test names
containing a comma ("Name contains illegal characters: ','"), so the
commas come out of the commonTest names -- 84 of them across 35 files.
Every one was a `, `, so the sweep is a deletion and nothing else; two
names where the comma was doing real work are reworded rather than
jammed together ("EOSE does not end a live subscription, CLOSED does"
became "a live subscription ends on CLOSED and not on EOSE"). jvmTest's
own 138 are left alone: Kotlin/Native never compiles that source set,
and 138 cosmetic edits do not belong in a commit about a link failure.
linkReleaseFrameworkIosArm64: Kotlin 2.4.10's release backend crashes in
StaticInitializersOptimization on this module's call graph, so
RemoveRedundantCallsToStaticInitializersPhase is disabled for the release
native links. The pass is an optimization only. It matches no task on
this host, where no ios target is declared at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fc19583225 |
feat(ios): declare publish cancellation to swift, and unshadow Platform
Seven `@Throws` on the publish path now name `kotlin.coroutines.cancellation.CancellationException` alongside `NostrPublishException`. Without it a publish cancelled by the screen navigating away crosses the ObjC boundary as an unexpected throw rather than as the cancellation it is, and swift has no way to tell the two apart. Six of the seven already existed; `EventPublishTransport` had none, so the interface the group-signed path publishes through said less about itself than the implementation behind it. `Platform.ios.kt` also imported `kotlin.native.Platform`, which shadows the app's own `Platform` interface the file is there to implement. It was unused and nothing on this host compiles iosMain, so the compiler never said so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
024e44c2c5 |
feat(groups): schema 25 -- two profiles on one device can hold one group
The refusal `679d5249` added is gone, and with it the last reason it existed. **The per-viewer field had to go first.** `ChatMessage.isUserMessage` was a boolean about one reader stored on a row that belongs to the group: a transcript line is deduped by the group event behind it, and `reconcileMarmotLine` lets the fresher row win, so whichever profile indexed the event last decided whose message it looked like. A member's own messages would draw as somebody else's and flip back when the other profile caught up -- on every line in the room. It is `senderIdentity` now, and `ChatMessage.isFrom(userPublicKey)` asks the question. The identity is the fact about the message; whether it is yours is a question only a reader can ask. Not derivable from `senderPublicKey`, which for a Marmot line is the kind:445's ephemeral signer -- the same trap the comment on `fromGroupEventResult` already records someone falling into. `answeredRequests` takes the asking profile for the same reason: the other profile's approval is not this one's to be credited with. The backfill is exact, and exact for the reason the bug could not happen yet: the boolean was only safe to store because a room belonged to one profile, so a line marked as the user's was sent by that room's one member, and `ChatRoomMembership` says who. `isUserMessage` is named by no index or constraint, so `DROP COLUMN` takes it and `ChatMessage`'s three cascading children are untouched. **Removing the refusal was not enough.** The Welcome branch keyed on the *room*, so a Welcome that found one fell through and did nothing -- the accidental silent refusal that `679d5249` was written to make legible in the first place. It asks about this profile's membership now, and the body it guards already does the right thing for a joiner: the room's upsert is idempotent, the bundle consumed is their own, the roster is the group's, and `adopt` files a statement the group already signed. `TYPE_WELCOME_REFUSED_OTHER_PROFILE` stays, written by nothing and still rendered. Devices that ran the older build hold lines of that type, and a transcript that cannot draw a line it is holding is worse than one drawing an obsolete notice. `WelcomeForOtherProfileJvmTest` becomes `SecondProfileJoinsGroupJvmTest` and asserts the opposite of what it used to: two memberships of one room, two distinct ratchet states, the holder's untouched, no refusal line, and the key package spent like any other join's. 1216 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
73b2c8ae9a |
feat(ios): give Room a way to construct the database without reflection
`MantraDatabaseConstructor` has been in commonMain all along -- `expect`, `@Suppress`ed for having no actual, and referenced by nothing. Room's KSP only generates the actual when `@ConstructedBy` names the object, so on a target with no reflection there was no way into the database at all. This is the one annotation in the ios work that changes every other target too: the generator now emits an `actual object` for android and jvm as well, each returning `MantraDatabase_Impl()`, where before it emitted none. jvmTest is unmoved at 1174, and MantraDatabaseJvmTest's comment about the no-Context builder overload being available "because MantraDatabaseConstructor gives Room a way to instantiate without reflection" is now true rather than aspirational. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dda2f95c1b |
build: register the stub archive task under a name Gradle 9 accepts
`db15e59` -> `e0d2b82`, carrying secp256k1-kmp `8adf691`. The stub-archive
task the previous two commits brought in was declared with
`val createLinuxStubStaticLibs = tasks.registering(...)`, whose delegate
provider Gradle 9 no longer takes outside a `by` delegation -- so the task
was never registered under a name and the cinterop tasks had nothing to
depend on. `tasks.register<Exec>("createLinuxStubStaticLibs")` instead.
With this the chain's five levels are where Curated's are: `e0d2b82` ->
`e5b22f5d` -> `9e3c4f8` -> `8adf691` -> `7a35fde1`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1b8df6b446 |
build: follow the chain onto the merged threshold tips
`2dcd4fb` -> `db15e59`. The previous commit's fix reached this tree as a side commit on each of the three nested repositories; this moves all of them onto the `threshold` branch tips that merged it -- secp256k1-kmp `9bcae25` -> `6c7b1e4`, and bitcoin-kmp and lightning-kmp with it. Bookkeeping in appearance only. The pins the previous commit named were rebased away upstream, so a later fetch would not have found them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8f0e26f2a |
build: carry secp256k1-kmp's fix for configuring off a linux host
`84cc44c` -> `2dcd4fb`, which carries secp256k1-kmp `9bcae25` down the chain. Its upstream subject says "linux cinterop sync fix" and reads backwards from here: the whole of it is behind `if (!currentOs.isLinux)`, so on this host it is a no-op, and what it actually fixes is configuring the chain on a mac. secp256k1-kmp's .def declares `staticLibraries` for every target and the cinterop tool refuses to emit a klib while the archive is absent from libraryPaths. Off a linux host the linux archives are never built -- buildSecp256k1LinuxArm64 is onlyIf(isLinux), and the linuxX64 cinterop points at native/build/linux, which only a linux host fills -- so the cinterop commonization an IDE sync resolves for linuxMain fails with "cinterop file ... does not exist". Cinterop only parses headers, so the fix leaves it enabled and satisfies the archive check with placeholder ones; compile, link and publication for the linux targets stay off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a998cf20a9 |
docs: the plan for an ios build, phased against the route Curated walked
Curated reached a device in eight commits. Five defects stand between this tree and the same place -- the native chain not configuring on a mac, Room with no way to construct the database on native, a cancellation Swift cannot see, two separate ios link failures, and CIO having no TLS on kotlin/native. Each is diagnosed upstream, so the work is porting a known answer. Three places this tree cannot simply cherry-pick: the test-name sweep has to run over Mantra's own 84 names in 35 files (ten of Curated's 38 files are from lines Mantra never pulled, and Mantra has eight Curated does not); the fork's "linux cinterop" fix is `if (!currentOs.isLinux)` and is really the fix that makes a mac work; and `@ConstructedBy` changes what Room's KSP generates for every target, not only the one it unblocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd405e90e5 |
test(dialects): guard the catalogue against a proposal's two limits
DialectCatalogueSizeTest, modelled on ChapterProposalSizeTest and measured the way the manager measures it -- the real encoding rather than an estimate. Neither limit is checked anywhere a reviewer would see it: proposeSigningBatch enforces both with a require, so the first sign of the catalogue outgrowing a batch would be a wave that split in two, or one that threw. Somebody adding a language has no reason to be thinking about FROST. The third assertion changed shape while being written. "A wave is small rather than just legal", as a fraction of MAX_PROPOSAL_BYTES, failed: the catalogue encodes to 16,832 bytes and a quarter of the budget is 16,384. The quarter was a guess. What actually wants guarding is the cost of one dialect -- 301 bytes, bounded here at twice that -- because that is the number that would drift if a dialect event grew something large, and unlike a fraction it does not move every time a language is added. The size test's failure message says not to trim the catalogue. The view model already chunks by MAX_BATCH_SIZE, so a larger catalogue is two sessions rather than a bug; what would be lost is a group adopting the whole list answering once, and that is a decision rather than a repair. Also records the plan as built, with a table of the seven places the build chose differently, and indexes it in docs/README.md. One of those entries is a finding rather than a choice: m3-touch-targets.py reads comments as code, so a KDoc explaining that a row used a clickable Card "rather than a Modifier.clickable" broke a zero budget and with it ./gradlew check. The comment was reworded, which is the wrong fix. m3-audit.sh already solves this for the colour check and says why, at NOT_A_COMMENT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
226653d282 |
fix(frost): one transcript line for a wave, not one per dialect
A signed event files its chat line unless the batch holds more than one event of its kind, in which case the session's completion line speaks for them. Needed by the previous commit. A wave of fifty dialects would otherwise post fifty "Added X to dialects" lines into the group's conversation, one after another, followed by a line saying the group signed fifty events -- a single act burying the conversation it was announced in. Every kind that could already appear many-per-batch returns null from applyInnerEvent for precisely this reason: a chunk is part of a chapter and the chapter's line speaks for it, and likewise a translation chapter, a contributor list and both project memberships. DialectEvent is the first kind where a batch holds many peers each of which wants a line. So checked against every batch the app builds today, this changes nothing: the leading kind of each is one of its kind and keeps its line. It is not a rule about batch size, which is the mistake available here. A chapter and its sixty-four chunks is one ChapterEvent, and gating on the batch being larger than one event would have taken the chapter's line away. Decided at the call site because that is the only place that can see the batch. applyInnerEvent is untouched -- it still writes the row, which is the part that matters -- and only the chatMessageDao upsert is skipped. ChronicleManager needed nothing: it already drops the line a payload returns, and says why. Three tests on SignedGroupKeyStateTest, which runs real two-device sessions: one dialect still files its line, three file none and still land three rows, and a dialect with the artifact that names it files both. The first two are the same mechanism asserted at 1 and at 0, so neither can pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
deaf36f688 |
Merge branch 'mantra' into claude/focused-brattain-433b3b
Takes the projects line, the chapter uploads, the tablet room selection and the adoption fixes -- thirty-eight commits, 204 tests. **Renumbered, for the third time in this window.** `mantra` reached 22 with the projects tables, having itself renumbered from 21 after the parentage table took that; this branch had already written 22 and 23. So the ritual keying becomes 22->23 and the membership split 23->24, by the rule in docs/curated-to-mantra-profiles.md that a version belongs to whichever tree lands first. Nothing had shipped on either. Schema 23 had to be regenerated rather than kept: the old 22.json described `mantra@21 + the ritual keying`, and the intermediate shape is now `mantra@22 + the ritual keying`, which includes the three project tables. Exported by putting the entities briefly back to that shape, since Room only ever emits the current version. **Projects added three more cascading children, so ChatRoom now has twenty-two.** All three of `MantraProject`, `MantraProjectArtifactVersion` and `MantraProjectTranslationArtifactVersion` reference the room `ON DELETE CASCADE`. The split's migration never enumerated them -- it depends on Room disabling foreign keys, which is asserted rather than assumed -- so it needed no change, but every count in its prose did. `ChatRoomSplitThroughRoomJvmTest` is better for it: starting from a v22 file now runs both of this branch's migrations in one upgrade, which is what a device coming off the projects release will actually do. Conflicts resolved toward mantra's shape with this branch's change applied inside it: `roomDerivedBy` keeps its new extracted helper and reads the ceremony's own threshold key through `getCeremonyThresholdPublicKey`, and `ChatRoomDetailViewModel` keeps the `parents` rename with the per-profile MLS read. The new projects and chapters fixtures needed the same membership seeding as the rest. 1212 tests, 0 failures. |
||
|
|
c03112fbbc |
feat(dialects): propose the ticked dialects as a wave
The FAB, the view model method behind it, and the navigation onto the session it opens. The picker can now do the thing it is for. One session per MAX_BATCH_SIZE entries, the way TranslationArtifactVersionDetailViewModel catches up a translation's missing chapters. The catalogue is smaller than one batch today so the chunking never fires; it is one line, it is the established idiom, and it is what stops the sixty-fifth catalogue entry from being a bug rather than a second session. The jvmTest exercises it anyway, with a synthetic selection of 65, because nothing else will. One timestamp for the lot, so a wave reads as the one act it is. The callback is invoked from Dispatchers.Main, and that is not decoration: it navigates, and navigating off Dispatchers.IO crashes. The handoff belongs in the view model rather than at the call site. A batch is all-or-nothing and a retry has to be a fresh proposal, since the items' nonce seeds have already been published against an aggregate. Both are answered by the rule the screen is built on -- it only ever proposes what is missing -- so asking again after a failed wave lands exactly the entries the failed one did not, which is what makes the signing screen right to say it is safe to ask. The new jvmTest runs the real proposal against a real database and a real key, because the half with the sharp edge is the one the caller cannot see: every event of a wave is re-authored under the group's key at the room's derivation path inside proposeSigningBatch, so the ids the group signs are not the ids dialectsOf produced. It also asserts the round trip the picker depends on -- a proposed wave parses back into the entries that were ticked, through the same fromDialectEvent arm applyInnerEvent will take -- and that missingFrom then stops offering them. Nothing was needed to make a wave readable: ProposedEvent already has its dialect arm, and WhatIsBeingSigned already leads a batch with "N events signed together" and refuses to let any of it be signed unless all of it parsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2efe320564 |
feat(dialects): a screen for picking dialects out of the catalogue
Route, state, view model, screen and the way in from the group's detail screen, beside "Add dialect" rather than instead of it. It shows and picks; proposing is the next commit. Three standings per row, not two. A language the room holds and a language it is already signing both show and neither can be ticked, with different words on each, because "not available" has two explanations and telling a member the wrong one is worse than telling them nothing: one is theirs already, and the other is a proposal somebody else opened that they can go and sign. The standing is derived from DialectScaffold.missingFrom rather than computed beside it. That function is what the proposal will be built from, so a row saying MISSING where it disagreed would offer a dialect the room already has -- and the duplicate would not even collide, since dialect ids differ by createdAt. One authority; held-versus-in-flight is only a label on what it already excluded. The view model holds its state in a StateFlow rather than the mutableStateOf its neighbours use, and not for consistency's sake: those write once, and this one is written from Dispatchers.IO on the way in *and* again from a collector on the room's signing sessions, which is where a mutableStateOf in a view model drops an off-main-thread write made during the first composition. Anything that stops being missing while the screen is open comes off the selection, the way SelectSubgroupAdminsViewModel unpicks a member who turns out to have no key package. Another admin proposing a language this member had ticked would otherwise propose it twice. "Select everything missing" means the catalogue and not the filtered rows: a member who has typed "so" into the search and taps it means all of it, and a selection that silently depended on a filter is the harder of the two to notice. Twelve strings, sentence case, and one of them replaces the literal AddDialectScreen had been carrying for the same sentence about a group with no shared key. A clickable Card rather than a bare clickable modifier, so a row is 48dp without anybody having to remember minimumInteractiveComponentSize. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f47db49454 |
feat(dialects): read the dialects an open session is already signing
DialectScaffold.pendingIn, the half of the identity rule that rows cannot answer. Without it the feature has a race it would hit on first use: two admins open the picker, both see the same forty missing, both propose, and the room gets eighty dialects -- forty of them duplicates nothing will ever merge. Readable because FrostSigningRepository.unsignedEvents gives a batch's events at proposal time, before any signature, which is exactly when this has to be known. The lambda is passed in rather than imported, as ProposedEvent.summarize takes a resolver: it keeps the function pure, testable without a database and callable from a composable. COMPLETE and FAILED sessions are both excluded, for opposite reasons. A complete session's dialects are rows by now, so counting them would hide them behind "waiting for the group" rather than "already yours". A failed one left nothing behind and has to be proposable again -- the signing screen says as much, and a retried batch must be a fresh one anyway. A session whose items will not parse contributes nothing rather than throwing, and the trade is named where it is made: its dialects look selectable and may be proposed twice, which is visible and recoverable, against a picker that will not open at all, which leaves an admin no way to act. unsignedEventJson is written by this device from a template it built, so that branch means a damaged database rather than an odd proposal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
08dc412594 |
feat(dialects): work out what a room is missing from the catalogue
DialectScaffold.missingFrom and .dialectsOf, built as a deliberate parallel to TranslationScaffold -- same package, same shape, same reason. "What is this room missing" gets asked by the picker, again when a wave comes back, and a third time by whatever reconciles the two; three copies would be three chances to disagree. Matching is on (code, country) and never the name, which is the rule the eleven tests exist to pin. A room that typed "Southern Sotho" where the catalogue says "Sesotho" still holds st-in-Lesotho, and proposing it again would make a duplicate that nothing can merge -- the two would not even collide, since their ids differ by createdAt. Normalisation is trim + lowercase(), the no-argument form on purpose: it is locale-invariant in Kotlin common, so a device set to Turkish does not decide that TI and ti are different languages. dialectsOf takes one createdAt for the whole wave rather than reading the clock per event, as TranslationScaffold.chaptersOf does, so a wave reads as the one act it is. That also makes its ids a function of its inputs, which is what lets the test assert the round trip back through fromDialectEventTemplate: what the picker chose is what the row says once the group has signed it. Pure and takes everything it reads, so it is testable without a database and a composable can ask it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9d34a2cc99 |
feat(dialects): a catalogue of the languages most spoken in Africa
Fifty-six entries -- fifty-three African languages and three European ones -- for populating a group's dialects without typing each into a three-field form. Nothing reads it yet; the plan it belongs to is docs/african-dialect-catalogue.md. The unit is a language *in a country* rather than a language, because that is what a dialect is here: `MantraDialect` is (name, country, code), so Kiswahili is three entries and that is the point rather than a duplication. (code, country) is the identity and the name is deliberately not part of it. Nothing anywhere makes a dialect unique -- the id is a content hash including createdAt, there is no index, and the rows are quorum-signed so they cannot be rewritten -- so duplication has to be prevented at proposal time, and of the three fields the name is the only one that is a matter of spelling. Codes are ISO 639-1 where the language has one and 639-3 otherwise, rather than 639-3 throughout: a uniform `sot` would disagree with the `st` that every room which used the form has already signed. Two decisions a reviewer should look at. `lua` names Tshiluba (Luba-Kasai); 639-1's `lu` is Luba-Katanga, a different language of the same country, and taking the two-letter code because it was there would have named the wrong one. And the three European languages are a list of their own rather than lines buried in the African one, so dropping them is one edit. It lives outside ui/ on purpose. m3-audit scans ui/ and strings.xml for string literals and title case, and "Northern Sotho" is title case for the same reason m3-title-case.py keeps a SAMPLE set of proper nouns -- reference data out of reach is cheaper and more honest than an m3-string-exempt marker on a file that is not UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5875655a32 | Merge branch 'mantra' into claude/recursing-hermann-e58bbb | ||
|
|
aff1e330a0 |
fix(repository): a no-op answers all sixty calls, and a test keeps it total
Sixty `TODO("Not yet implemented")` bodies sat across four no-op repositories -- 41 of
45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on `MarmotRepository`,
1 of 1 on `SearchRepository`.
|
||
|
|
86ff4a1cf0 | Merge branch 'mantra' into claude/parent-group-name-display-c8ae05 | ||
|
|
395bfa6b8b |
feat(subgroups): say which group the parent is, not only which id
The parent card on a room's detail screen read "Parent group" over sixteen hex characters. Every other card naming a room names it -- `SubgroupCard` has shown `room?.chatRoom?.subject` since subgroups landed -- and the one card pointing the other way up the lineage did not, although the room it points at is usually right there in the database. So: "Parent group" moves to the overline, the group's name becomes the headline, and the truncated id moves underneath, where it can still be read against what the lineage says. The label keeps its place rather than being replaced, because a card carrying a 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. **The name can only come from the room, and there is not always one.** A birth certificate names the child and never the certifier, so a member of the subgroup who is not also in the group above holds a verified lineage with nothing to call it by. That case keeps the card it already had: the id as the headline, no supporting line. It is not a corner -- it is the ordinary position of anyone welcomed into a subgroup rather than founding one -- so the room is a nullable field rather than something to wait for. `SubgroupManager.parentGroupsOf` is `parentsOf` with each id looked up, returning `Parent(chatRoomId, room)` the way `subgroupsOf` returns `Subgroup`. The verified set and its canonical order are untouched; a parent whose room is missing stays in the list. The id-only `parentsOf` stays as it is, because `offersTo` compares ids and should not pay for rooms it will not read. Three tests: a parent this device is in is named by its room, one it is not in is still listed with nothing to name it, and two parents keep the lineage's own order -- asserted against `parentsOf` rather than a written-out order, since canonical means sorted by id and nobody should have to know how two derived ids sort. 459 common tests, 985 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5105d8bd3c |
Merge mantra into the chapter uploads branch
Thirty-eight commits had landed on mantra since this branch left it. Six conflicts, all of them two sessions adding to the same place: the string catalogue, the docs index, and the four files the projects line and this one both touched. Only one needed a decision. Both sides added parameters to ProposedEvent.summarize -- chatRoomId and resolve for naming a project's owner, chunkCount for saying how many chunks travel with a chapter -- so the signature is the union, with chunkCount last so nothing positional on either side moved. FrostSigningScreen and ProposalListViewModel pass all three, and the signing screen's summary now names the thing, resolves its ids, and counts the batch. The submodule pin is identical on both sides, and mantra's schema v20 to v22 does not collide: this branch adds no entity and no migration. 1170 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4cba5b9a69 |
feat(groups): schema 23 -- a membership is a row, so a group is the group's
`ChatRoom` was two things in one row keyed by the group id: the group's own facts -- its id, name and description -- and the asking profile's membership of it, which is its MLS ratchet state, when it joined and left, whether it asked for a chronicle, and whether it has been archived. A second profile joining the same group wrote over the first's ratchet state, which breaks both members in the shape of "the other device never got it". `679d5249` refuses that Welcome; this removes the reason it had to. **Split rather than a wider key.** Keying the room by (`id`, `userPublicKey`) was the obvious reading and the worse schema. Of the nineteen tables referencing a room, fourteen hold the *group's* signed work -- `GroupSignedEvent`, `GroupKeyState` and the twelve `Mantra*` tables -- and two profiles in one group read the same chapters. Those fourteen would have had to carry a `userPublicKey` that misrepresents them or drop their foreign key and the cascade with it; `GroupSignedEvent` could not have carried one at all, its key being the event's hash. Splitting leaves all nineteen pointing at a room that is unambiguously the group's. `ChatRoomDao` follows from that: a listing joins the membership, because "mine" and "archived" are things one profile says about a group, while a lookup by id stays the group's and filters nobody's `deletedAt` -- a manager asking for the room it is about to act in wants the group, whether or not somebody tidied it away. **The migration turns on one fact, and the fact is now measured.** Rebuilding `ChatRoom` is unavoidable (a foreign key names two of the columns to remove, so `DROP COLUMN` will not), and with foreign keys enforced no rebuild survives: the drop fires all nineteen cascades, and `RENAME TO` rewrites the children's clauses to follow it -- `PRAGMA legacy_alter_table` does **not** prevent that, which I checked rather than assumed. A migration cannot disable foreign keys itself either, inside Room's transaction. So this depends on Room disabling them, exactly the kind of dependency `MIGRATION_9_10` warns against taking on trust -- and `ChatRoomSplitThroughRoomJvmTest` asserts it against a real v22 file through `getRoomDatabase`, with rows seeded in four of the nineteen. An earlier draft of this migration lost every child row; that test is what caught it. The replacement is also built *beside* the original and renamed into place after it is gone, so nothing is ever renamed that the children point at. The acting profile now comes from where it is actually known. `DatabaseChatRepository` and `DatabaseDkgRepository` read it off the `activeIdentity` they already hold, the inbound MLS path derives it from the `activeKeyPair` it is already given, and `ChatMessageListViewModel` takes it from the screen that opened it. Leaving a group and archiving one are a membership's acts now, so one profile doing either leaves the other's list alone. 1008 tests, 0 failures. Schema export committed beside its predecessors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2ba7633dfb | Merge branch 'mantra' into claude/tablet-chat-room-selection-c504d5 | ||
|
|
bba304fec0 | Merge branch 'mantra' into claude/project-feature-plan-b58ab2 | ||
|
|
f46a7c627d |
test(subgroups): stop a ceremony-queue test throwing into another test's name
`./gradlew :composeApp:jvmTest` failed `NavigationRoutingTest > an account already on
the device does not stay on the loading screen` with `UncaughtExceptionsBeforeTest:
There were uncaught exceptions before the test started`. **"Before the test started" is
the tell.** `runTest` reports exceptions that reached the default handler before it
began, so the name on the failure is whichever `runTest` ran next -- not the one that
threw. `NavigationRoutingTest` passes alone and the subgroups work never touched it.
The thrower is `CeremonyQueueRoutingJvmTest`, and the chain is five links with no
branch in it:
1. `awaiting()` builds a `ChatMessageListViewModel` on the bare
`NO_OP_NOSTR_REPOSITORY` and calls `initiate()`.
2. `initiate()`'s second line is `scheduleSynchronization()`.
3. That launches on `Dispatchers.IO` and asks `MemberProfileSync` who cannot be named.
4. The fixture's `room()` gives every `LocalParticipant` a `profile = null`, so the
answer is never empty and the next line always runs.
5. `queueNegentropySynchronizeRequest` is one of the **forty-one**
`TODO("Not yet implemented")` bodies on that no-op, out of forty-five members. It
throws in a coroutine nobody awaits, four times a run -- once per test in the class.
Fixed the way this file already fixes it for `DkgRepository`: an `object : X by NO_OP_X`
with the one member overridden. The comment above it carries the chain, because the next
person to see `UncaughtExceptionsBeforeTest` will be reading the name of the wrong test.
**Verified under the condition that reproduced it.** It is order-dependent, so a
filtered run is not evidence -- the culprit passes alone, and the pair passed too. The
full suite under `--rerun-tasks` failed reproducibly before and is 982 of 982 after.
**Not fixed here: the landmine itself.** Sixty `TODO()` bodies sit across four no-op
repositories -- 41 of 45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on
`MarmotRepository`, 1 of 1 on `SearchRepository` -- and seventeen jvmTest files build
view models on the Nostr one alone, so this will recur; the reporter had already hit it
through `getProfileWithPublicKey`. It is not a sweep, though: "no-op" means something
different for each member, the return types vary from `Unit` to `Flow<Int>`, and a
blanket empty body could make a test pass vacuously where it currently fails honestly.
Sixty judgement calls about shared code do not belong in a commit about one flake, so it
is flagged for its own change.
982 jvm tests, 0 failed, under `--rerun-tasks`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1a6034dadb | Merge branch 'mantra' into claude/subgroups-multiple-parents-053be4 | ||
|
|
fbf376fe20 |
feat(chat): the room list marks the room the pane is answering with
The commit before this one made a second tap change the transcript. It did not make the list say which room the transcript belongs to, and on a tablet those are two halves of one question: a reader choosing their next room is looking at the list, not at the app bar on the other side of the window that repeats the title. With nothing marked, the only evidence of a selection was in the half of the screen they had looked away from -- which is its own reason the pane read as unresponsive even once it worked. **`secondaryContainer`, which is M3's container for a selected list item**, set through `CardDefaults.cardColors` so the content colour comes with it: `cardColors`' `contentColor` defaults to `contentColorFor(containerColor)`, so the row's text moves to `onSecondaryContainer` without a second call site to keep in step. A row's title keeps its `ProfileColor`, which is a fixed-lightness HSL colour and already scheme-blind, so the swap does not change what that line is contrasted against in either scheme -- a pre-existing question this commit neither fixes nor worsens. **`isSelected` is a predicate, and nullable, because the two layouts disagree about whether selection exists at all** -- not merely about its value. Where the list is the whole screen, tapping a room navigates to it and the list is about to be gone: no room is "the selected one", and both a highlight left behind on the way out and a reader told "not selected" about every room would be describing a pane that is not on the screen. `null` says that. A predicate answering false for every room does not, because that is also exactly what a two-pane layout looks like before the first tap, and those two states are different things for the list to be saying. **The call site reads the layout rather than the selection**, `listPaneWidth == null` rather than `selectedChatRoomId == null`. The selection is `rememberSaveable` and outlives a window that narrows, so a tablet turned from landscape to portrait keeps it -- and deriving the mark from the selection would leave a highlighted row on a one-pane screen with no pane beside it. **The semantics property is set only where it means something.** A row in a selection-bearing list carries `selected`, true or false, so a screen reader can say which of them the pane is showing; a row in a list that navigates carries nothing, rather than announcing "not selected" about a pane that does not exist. `Modifier.then` of an empty `Modifier` is how the row opts out, which keeps the branch at the one place that knows. **Tests: 2 cases, both checked against the feature removed.** The first taps two rooms in a 1000dp window and asserts the mark lands on the right card, moves rather than accumulating, and is absent before the first tap while the rows are already selectable -- "asked nothing yet" and "not the one being shown" being the two states the nullable predicate exists to tell apart. The second asserts a 700dp list has no selectable row at all, in two halves on purpose: a list that never rendered also has no selectable rows, so it checks the room is on screen before checking it is not selectable. Both first drafts counted `isSelectable()` across the whole window and failed by exactly one, because `PrimaryScrollableTabRow`'s `Tab` is selectable too. The assertions are scoped to the rows through a matcher that requires both selectability and the room's subject -- which works because `Card(onClick = ...)` merges its descendants, so one node carries the subject and the selected state together. That merge is also why a selected room is two nodes saying its subject and a merely listed one is a single node, which is what the previous commit's case counts. **Not done.** Still nobody has looked at this on a tablet. The tests prove the mark follows the selection and that a one-pane list stays silent; they say nothing about whether the colour reads as a selection at arm's length on a real screen. 976 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9be3e01d8b |
fix(subgroups): hand an adoption's outcome back on the main thread
"Add a parent group", then tapping any group, crashed outright:
IllegalStateException: Method setCurrentState must be called on the main thread
at androidx.lifecycle.LifecycleRegistry.enforceMainThreadIfNeeded
...
at NavController.popBackStack
at SelectAdoptingGroupScreen.kt:153
at SelectAdoptingGroupViewModel$offerTo$1.invokeSuspend
`offerTo` does its work in `viewModelScope.launch(Dispatchers.IO)` and called
`onDone` from inside it. The screen's callback called `onNavigateBack()` straight
through, which is `navController.popBackStack()` -- and navigation moves a back stack
entry's lifecycle, which is main-thread-only. Nothing in the chain switched.
**The dispatcher is the view model's business, not every caller's**, which is the
shape the app already uses in a dozen places and in both of the files this one was
modelled on: `SelectSubgroupAdminsViewModel.confirm` wraps its navigation in
`withContext(Dispatchers.Main)` and `AddArtifactViewModel` launches its `onSuccess`
there. A `finish` helper does it here, with the crash written above it so the next
person to add a callback to this class sees why. `ChatRoomDetailViewModel.acceptParent`
gets the same treatment: its only caller shows a snackbar, which happens to be
main-safe already, but a callback contract that is "safe if you are careful" is the
same bug waiting for a second caller.
**And the screen no longer navigates away on success**, which fixes a second thing
found on the way to the first. The snackbar was launched in
`rememberCoroutineScope()` -- this screen's own -- and then the screen popped, so the
message was racing its own scope's cancellation; `rememberNotifier` exists for exactly
that hazard and was not being used. Staying put is the better answer anyway, because
the list is the confirmation: the row just asked leaves it, since a certificate now
stands for the pair and `refuseAdoption` says so. The way out is the back button, which
a pushed screen has to have regardless. `offerTo` reloads before handing back so that
is true by the time the message appears.
The outer `scope.launch` around `offerTo` goes too -- it never suspended, and wrapping
a main-thread call in a launch was part of what made the threading hard to see.
No test covers this and none of the 1,416 would have. It is a dispatcher boundary
between a view model and a navigation callback, which is to say it lives in the one
layer this work had never run: compile-and-test verified, never driven through the app.
That was named as the risk when the feature landed, and this is it.
454 common tests, 962 jvm tests, `m3Audit` meets every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3cd9001c27 |
Merge branch 'mantra' into claude/project-feature-plan-b58ab2
Eighteen commits of subgroup adoption and key-ceremony work, and one collision
that was the point of writing Phase 0 down.
**Schema.** `mantra` took 21 for `GroupKeyStateParent` -- a room's parentage as a
row, so it can have two -- as a manual `MIGRATION_20_21` that copies before it
drops. Projects had taken 21 on this branch. Neither had shipped, so projects
renumbered to **22**, an `AutoMigration(from = 21, to = 22)` on top of theirs;
`schemas/21.json` is now theirs and `22.json` is the two together, 58 tables.
docs/project-feature.md records it, including that Phase 0 told us to check for
exactly this and checked the sibling fork rather than the base branch.
Three conflicts. `MantraDatabase.kt` wanted both entity sets and both
migrations, and their v21 contributes only a comment there since the entry is
manual. `ProposedEventTest.kt` and the schema file were additive either way.
Nine files auto-merged, and `ChatMessage.kt` was the one to watch: they reshaped
the `SubgroupBirthCertificateEvent` arm while this branch added three project
arms to the same `when`. Git saw no conflict and the compiler agrees.
**One test fails, and it arrives with the merge rather than from it.**
`NavigationRoutingTest` reports "uncaught exceptions before the test started",
which is leakage, not its own failure: `CeremonyQueueRoutingJvmTest` (new in
|
||
|
|
b95d723edd |
fix(chat): a tablet's room list can pick a second room, not just the first
The home screen's detail pane answered with the first room it was ever given. Tap another and the list highlighted nothing, the transcript did not change, and the app bar kept the first room's title -- so from the expanded breakpoint up, which is a tablet in landscape and every desktop window, the split was a one-shot: the first conversation you opened was the only one you could read without leaving the screen. **`key(chatRoomId)` does not rebuild a view model, and the comment claiming it did was the bug.** `viewModel()` resolves against `LocalViewModelStoreOwner.current` under a key derived from the *class* -- `ViewModelProvider.get(modelClass)`, no key, straight to the default `DEFAULT_KEY:canonicalName`. A composition key replaces a group: `remember`s are forgotten and `LaunchedEffect`s restart, which is real but is not where a view model lives. The store belongs to `HomeRoute`'s `NavBackStackEntry` and does not change while the reader is on the home screen, so the second tap re-ran the composition, asked for a `ChatRoomMessagingViewModel`, and got back the one holding room one -- its `chatRoomId` a constructor val, its factory never invoked a second time. `initiateChatRoomDetail()` then re-read room one and the pane redrew what it already had. **This is a list-detail-only failure, which is why nothing caught it.** Every other screen in the app reaches the chat transcript by `navController.navigate(...)`, and a pushed route is its own `NavBackStackEntry` and so its own store -- one subject per owner, the class key never collides. The pane is the one place one screen is composed for a succession of subjects without the entry changing. Below expanded the home screen still pushes, so a phone never had this; the four existing cases in `ChatPaneLayoutJvmTest` assert the widths and the empty state, which were right. **`ViewModelStoreScopedTo` gives the pane a store of its own rather than keying the two `viewModel()` calls by room id.** Both put the right room on screen. Keying would also leave every room visited holding a live view model in the home entry's store until the reader left the screen, each with its relay subscriptions and database collectors still running -- a leak that grows with how much a reader browses, which on this layout is the point of the layout. A scope that clears on the way out is the same correctness and none of that. **It is hand-rolled because the stock API is not here.** `rememberViewModelStoreOwner` lands in `androidx.lifecycle:lifecycle-viewmodel-compose` 2.11.0 and does exactly this, including surviving an Android configuration change by consulting the parent's lifecycle; the build resolves `org.jetbrains.androidx.lifecycle`, and the class is in neither the 2.9.6 nor the 2.11.0 artifact in the cache. A bare `ViewModelStoreOwner` is the whole contract a scope needs here -- nothing in this app puts a `SavedStateHandle` in a view model, so there is no saved state to delegate, and it deliberately does not implement `HasDefaultViewModelProviderFactory` because every call inside passes its own factory. The cost of hand-rolling is that a rotation rebuilds the pane's view models instead of keeping them; the selection itself is `rememberSaveable` and survives, so what that costs is a re-read of one room. **Both wrappers stay, and they are not redundant.** The store scope alone would leave the composer's draft text in place as the reader moved to the next room -- which on this screen is the failure the composer is built around three separate signals to prevent, one person's words going to another -- and would not restart the effect that loads a room, so the pane would sit on `Loading`. `key` alone was the bug. One resets the composition, the other the view models. **Tests: a fifth case in `ChatPaneLayoutJvmTest`, checked against the bug reinstated.** Two rooms told apart by their subject, which both panes draw as a title, so two nodes saying it means "this is the room on the right" and one means "this room is merely listed" -- an assertion that needs no new test tag and no reach into the view model. Tap room one, wait for two; tap room two, wait for two of *it* and one of room one. With `ViewModelStoreScopedTo` reduced to `content()` the second wait fails with `ComposeTimeoutException` while the other four cases still pass, so the case pins the view model scope rather than the layout. The waits are generous, because what they wait for is a database read on another dispatcher and the cost of being wrong about a thread hop is a test that fails on a loaded machine. **Not done.** Nobody has looked at this running on a tablet; the test proves the pane follows the selection, which is not the same thing. The list pane also still draws no selected state -- M3's list-detail expects the selected row to carry one, and its absence is its own reason the pane read as unresponsive. Left out deliberately: it is a separate change to a different file, and this one is the part that was broken. 974 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |