Replaces `ac.cord.auxiliary.frost.dkg.chill.ChillDkg` with
`fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG` throughout ChillDkgRitualManager.
The implementation being dropped says what it is in its own header:
WARNING: This code is slow and not hardened against side channel attacks. Do
not use for anything but tests.
It is a 1090-line "Reference port of ChillDKG (chilldkg_ref/chilldkg.py)" doing
BigInteger arithmetic through ac.cord.auxiliary.cryptography's Scalar and
GroupElement, with no call into libsecp256k1 anywhere in the file.
secp256k1-frost-kmp's own KNOWN_ISSUES.md confirms the constant-time
libsecp256k1 backing is used only for hashing. Real key material was being
generated by it.
The replacement is 438 lines in which every operation delegates to
`Secp256k1.chilldkg*` -- the constant-time C from the secp256k1-zkp fork the
submodule chain compiles. This is an improvement, not a clean bill of health: that
module carries its own "experimental and must not be used in production" warning.
It is constant-time C instead of variable-time Kotlin, which is the part that
mattered.
Three things changed shape rather than just types.
SessionParams no longer exists. ChillDKG takes the host public keys and the
threshold at every call and hashes them into the session identity itself, so
`sessionParams()` becomes `hostPublicKeys()` returning List<PublicKey>, and the
threshold rides along on the session row. That removed a parameter from four
signatures.
Faults are values now, not exceptions. ChillDKG reports a faulty participant as a
ChilldkgFault field on each result because it is a normal outcome of a DKG rather
than a bug. This ritual has exactly one response to all of them -- the key is
unusable, so the session dies and the group is told -- so `raiseIfFaulty` turns
them into an exception and lets them join advance()'s existing single failure
path. The gain is in the failure text: the reason shown to the group goes from
whatever `e.message` happened to hold to "ChillDKG round 2 failed: a participant
is faulty (participant 3)". On a failed DKG, which participant to blame is the
only actionable thing there is.
Two recomputes got names. The old code inlined a second participantStep1 call to
rebuild state1 for participantStep2, and rebuilt coordinator state separately in
aggregateRound2. Those are now `participantState1()` and `coordinatorStep1()`, the
latter carrying the fault check so both of its callers get it. The
recompute-rather-than-store design is kept deliberately: ChillDKG's states are
serializable and could be persisted, but they are pure functions of inputs the
DkgSession row already holds, so storing them would mean a schema change and a
Room migration for no behavioural gain. That reasoning is now in the kdoc.
Also drops an unused hostSeckey parameter from aggregateRound2, whose signature
was changing anyway, and updates doc comments in DkgRitualEvents, DkgSession and
DkgThresholdTag that named types which no longer exist -- ChillDkg.ParticipantMsg1
and friends -- to the protocol's own names: pmsg1, cmsg1, CertEq signature,
certificate.
Compiles clean, but no ritual has been run on a device. The natives are in the APK
as of the previous commit; the first real ritual is the actual test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The android app had no android secp256k1 natives at all, and had not had any for
as long as lightning-kmp-core has been a dependency. `dependencyInsight` on
debugRuntimeClasspath resolved every secp256k1 coordinate to
`:secp256k1-kmp:jni:jvm:{darwin,linux,mingw}` -- desktop .so/.dylib/.dll files,
loaded by extracting them from a jar, which cannot work on a device.
The cause is a chain of individually reasonable decisions. lightning-kmp-core
publishes no android variant, so an android consumer resolves it to the jvm one;
the jvm variant asks for `secp256k1-kmp-jni-jvm`; and nothing anywhere asks for
`secp256k1-kmp-jni-android`. lightning-kmp-app names it only in androidDeviceTest,
so consumers of the published library do not get it. It has to be named here.
Naming it alone would not have been enough. bitcoin-kmp's settings.gradle.kts
substituted five of secp256k1-kmp's coordinates to the fork's projects but not
-jni-android, so it would have resolved from Maven Central to stock 0.24.0 --
built from upstream libsecp256k1, with no ChillDKG module. Because the kotlin API
comes from the substituted root project, that combination type-checks and links
and then fails with UnsatisfiedLinkError at the first native call. The rule is
added in the submodule commits this carries.
Submodule commits carried here:
lightning-kmp-app ce1ed01 -> 6434282 build: carry the jni-android substitution
down from bitcoin-kmp
experimental/lightning-kmp 77be7b78 -> 5103b79e
experimental/bitcoin-kmp 196a479 -> 65c4aab build: substitute
secp256k1-kmp-jni-android to the
included build too
Verified through the artifact rather than the graph: composeApp-debug.apk now
carries lib/{arm64-v8a,armeabi-v7a,x86,x86_64}/libsecp256k1-jni.so, and `nm -D` on
the arm64 one exports all twelve Java_..._chilldkg_... JNI entry points --
hostpubkey_gen, params_hash, participant_step1/step2/finalize,
coordinator_step1/finalize, and the recover and investigate calls.
The three builds in the chain sit on `build/substitute-jni-android` branches
(lightning-kmp-app on `build/agp-9.4.0`, which now carries two commits) and none
are pushed, so a fresh clone cannot resolve these pointers yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android Studio refused to sync the project after the lightning-kmp-app bump in 4e939c4:
Using multiple versions of the Android Gradle Plugin [9.1.1, 9.4.0] across Gradle builds is
not allowed.
Affected builds: [:, :lightning-kmp-app:lightning-kmp:bitcoin-kmp:secp256k1-kmp]
A composite build has to settle on a single AGP version across every build that applies the
plugin. Four builds in this tree do:
: 9.1.1 version catalog
:lightning-kmp-app 9.1.1 version catalog
:secp256k1-frost-kmp 9.1.1 version catalog
:lightning-kmp-app:lightning-kmp:bitcoin-kmp:secp256k1-kmp 9.4.0 hardcoded
lightning-kmp and bitcoin-kmp apply no android plugin at all, which is why the message names
only the two ends of the chain rather than everything between them.
secp256k1-kmp pins 9.4.0 twice -- a buildscript classpath entry, and a pluginManagement
resolutionStrategy that rewrites every requested com.android plugin to that version -- so it
cannot be talked out of it from here. The three catalogs move up to meet it. Pinning it down to
9.1.1 instead would mean editing a build four submodules deep and then committing in bitcoin-kmp
and lightning-kmp purely to carry the pointer, for no gain. Moving up is also the direction the
chain already set: bbba08b's f22c30a moved every build in it onto Gradle 9.7.1 for the same
reason, and 4e939c4 followed.
Submodule commits carried here:
lightning-kmp-app bbba08b -> ce1ed01 build: move to AGP 9.4.0, matching the version the
submodule chain pins
secp256k1-frost-kmp d3b294d -> 80a55b7 build: move to AGP 9.4.0 to match the rest of
mantra-kmp's composite
Both sit on branches (build/agp-9.4.0 and build/gate-ios-targets-on-macos) rather than their
masters, and neither is pushed yet, so a fresh clone cannot resolve these two pointers until
they are.
9.4.0 is a minor bump inside the AGP 9.x line, so the DSL is unchanged and every android.* flag
in gradle.properties -- newDsl, builtInKotlin, the r8 settings -- keeps its meaning.
`:composeApp:compileDebugKotlinAndroid` passes clean.
Note that this is only what the IDE needs to sync, not everything worth knowing about the
chain. bitcoin-kmp's settings.gradle.kts substitutes secp256k1-kmp, -jni-jvm and the three
-jni-jvm-{linux,darwin,mingw} coordinates, but not -jni-android. So the android app still
resolves secp256k1-kmp-jni-android from maven central at stock 0.24.0, without the
FROST/prefractal native modules the threshold branch exists to provide, and a call into those
from android would fail with UnsatisfiedLinkError. Fixing that needs a substitution rule in
bitcoin-kmp. Untouched here because it is a runtime concern, not a sync one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`fr.acinq.bitcoin.crypto` is a package, not a declaration -- it is where bitcoin-kmp keeps
Digest, Pack and hmac. Kotlin has no import-a-package form, so this line was never valid:
e: ChillDkgRitualManager.kt:21:25 Packages cannot be imported.
It was also unused. Nothing in the file references anything under that package; the crypto
the file actually uses is fr.acinq.bitcoin.Crypto on the line above, plus quartz's EventHasher.
Worth recording why this only surfaced now, since the line has been there since 3995cde and
every build since has been green. Kotlin's incremental compiler had not revisited this file:
it was last compiled when the classpath still resolved lightning-kmp and bitcoin-kmp from maven
central, and the switch to building them from the experimental submodule chain did not
invalidate its entry. Changing AGP invalidated the incremental caches, forced a full recompile,
and the compiler read the file for the first time in a while.
So this is not fallout from either the submodule update or the AGP bump. It is a latent error
that any clean build would have hit -- including CI, or the first build on a fresh clone.
The import is left in place commented out rather than deleted, as a marker of where the
package's contents were expected to be needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lightning-kmp-app moves 6745d88 -> bbba08b, four commits:
57353cf Minor updates
9f3cd72 Build lightning-kmp from the experimental submodule via a composite build
f22c30a Follow the submodule chain onto Gradle 9.7.1
bbba08b Declare the ios targets only on a mac, so the IDE can import the project
9f3cd72 is the substantive one. lightning-kmp no longer comes from maven central: the
submodule now carries its own experimental/lightning-kmp submodule on branch `threshold`
-- the branch with the FROST/prefractal signers -- and substitutes
fr.acinq.lightning:lightning-kmp-core for that build's project. That build includes
bitcoin-kmp, which includes secp256k1-kmp, which compiles the C library from a secp256k1-zkp
fork. So this repo's build tree is now four levels deep and compiles native code.
Cloning therefore needs `git submodule update --init --recursive`, and because each included
build resolves the android SDK from its own local.properties rather than inheriting the
root's, the three new nested builds each need a (gitignored) local.properties with sdk.dir.
57353cf changed two signatures that MantraApplication implements -- LightningApplication
gained getApplicationContext(), and BusinessManager.initialize now takes the application
rather than a Context. Neither needed a source change: MantraApplication extends
android.app.Application, which already supplies getApplicationContext(), and it was already
passing `this`, which satisfies the narrowed parameter type.
The rest of this commit is what the update forces on the outer build.
gradle-wrapper.properties, 9.3.1 -> 9.7.1: f22c30a moved every build in the chain onto
9.7.1. An included build does not use its own wrapper -- the root build's gradle version runs
the whole tree -- so this repo has to follow for the chain to build at all.
gradle.properties, configuration cache off: secp256k1-kmp's `:jni:generateHeaders` and
`:native:buildSecp256k1<target>` both hold gradle script object references and cannot be
serialized. The configuration cache covers a whole build tree and has no per-build opt-out,
so an included build's incompatibility is this build's problem. The comment records how to
undo this once those tasks are fixed upstream.
composeApp/build.gradle.kts, ios targets gated on the host: secp256k1-kmp declares a
libsecp256k1 cinterop, which makes gradle switch off klib cross compilation for apple
targets. On linux nothing in the tree then offers an ios variant of
fr.acinq.phoenix:lightning-kmp-app, and the ios compilations failed with "No matching variant
of project ':lightning-kmp-app:library'" -- not a warning, a build failure. The gate covers
the target declarations, the iosMain dependencies (the source set only exists when the
targets do) and the kspIos* configurations (likewise). This mirrors bbba08b, which applied
the same gate inside the submodule for the same reason.
secp256k1-frost-kmp d3b294d applies that gate there too. Substitution rules apply across a
whole build tree, so that project's own lightning-kmp-core coordinate started resolving to
the source project without it asking, and every apple source set stopped resolving. The
android build masked it -- ios compilations are not in its task graph -- but
kmpPartiallyResolvedDependenciesChecker reported it and compileAppleMainKotlinMetadata failed
outright, which would have broken IDE import.
Note that `:composeApp:compileCommonMainKotlinMetadata` no longer exists on a linux host.
With ios gated off, androidTarget is the only declared target (jvm() is still commented out),
and KMP does not generate a commonMain metadata compilation for a single-target project.
`:composeApp:compileDebugKotlinAndroid` is the check now; it passes clean, with none of the
resolution errors above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relays were answering with "too many concurrent REQs" and the emulator
log was full of it. Two independent defects, compounding.
## The subscription flow never completed
RelayPool.queryAsFlow returned a filtered view of the socket's hot
`incomingMessages`, with the terminating operator commented out:
return this.incomingMessages
.filterBySubscriptionId(id = subscriptionId)
// .transformWhileEventsAreIncoming()
A filter over a hot flow has no terminal event, so every collector
started for a sync request stayed alive for the life of the app --
accumulating one per request ever made, long after EOSE and CLOSE had
been sent. Neither pump's EOSE branch ended collection either;
`return@collect` only ends handling of the message in hand, as the
CLOSED branch's own comment already noted.
That is also why the log *flooded* rather than merely warning.
`filterBySubscriptionId` admits NoticeMessage on every subscription id
(a NOTICE carries none), so a single "too many concurrent REQs" notice
was delivered to every accumulated collector and logged once per
collector. Volume grew as notices x live collectors.
## Nothing bounded how many were open
Both pumps mark the request "sent" and then launch the subscription
detached:
nostrRepository.negentropySynchronizeRequestProcessed(request)
launch(Dispatchers.IO) { relaysSocketManager.negentropySync(...).collect { ... } }
The DAO query is `WHERE status = :status ... LIMIT 1`, so flipping the
row changes the head row, Room re-emits, and the collector body runs for
the next request while the previous subscription is still open. The
mutex covers only the setup block and publishSlots guards publishes, not
REQs, so the number of simultaneously open REQ/NEG-OPEN subscriptions
was bounded only by backlog depth.
## And back-pressure amplified itself
The negentropy ClosedMessage branch -- CLOSED being exactly what a relay
sends when refusing for too many concurrent REQs -- answered by queuing
the request again as a plain REQ. Each refusal therefore produced
another subscription. That branch also skipped the close its EOSE and
NEG-MSG siblings performed, leaking a slot precisely when the slot was
most needed.
## The fix, in the order it has to be applied
1. sockets/NostrIncomingMessageExt.kt gains isTerminalFor() and
completeOnSubscriptionEnd(), which emits the terminal message and
then completes. NOTICE is deliberately not terminal: with no
subscription id it reaches every collector on the socket, so treating
it as terminal would tear down every unrelated subscription at once.
2. RelayPool.queryAsFlow applies it, replacing the commented-out call.
3. SynchronizationViewModel gains subscriptionSlots =
Semaphore(MAX_CONCURRENT_SUBSCRIPTIONS = 4), acquired inside each
pump's launch before the socket call, so the backlog still drains but
queues on the semaphore rather than opening all at once.
4. Both pumps close in a `finally` under NonCancellable, replacing the
hand-rolled closes in the EOSE and NEG-MSG branches, so CLOSED and
NEG-ERR exits close too.
5. The negentropy CLOSED branch consults isBackPressure() -- NIP-01's
`rate-limited:` prefix plus the free-text forms relays actually send
-- and declines to retry, instead of answering back-pressure by
opening another subscription.
Order is load-bearing: capping slots before the flow could complete
would have deadlocked the pump on permits that never came back.
## A negentropy assumption that would have deadlocked it anyway
Capping slots nearly stalled the queue on a wrong assumption about what
ends a negentropy exchange. EOSE does not: the NegentropyMessage branch
reconciles ONCE, queues the ids it needs as a plain REQ, schedules what
the relay is missing, and stops -- this client does single-round
reconciliation. Waiting on an EOSE the exchange need not send would have
held all four slots forever. NegentropyMessage is therefore terminal
too, with the reasoning recorded at isTerminalFor().
Worth knowing separately, and left alone here: single-round
reconciliation may not converge on large sets, since negentropy is
normally iterative. A large divergence is closed by the plain-REQ
fallback rather than by negentropy itself.
Live collectors go from one per sync request ever made to at most four.
MAX_CONCURRENT_SUBSCRIPTIONS is the dial if sync feels slow -- relays
commonly allow around 20 per connection, so there is headroom.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Robust groups can now generate a FROST threshold key together. The
group's members are the participants, the room's creator is the
coordinator, and the whole protocol travels as gift-wrapped rumors on
the chat the group already has -- so there is no second transport to
build, operate or debug.
This is what the quorum has been reaching for since it was introduced.
Until now "t of n must approve" had no key to approve anything with;
ChillDKG produces one that no single member holds.
## Transport: seven rumor kinds (nostr/dkg/)
coordinator --[ 30310 proposal ]-> everyone
participant --[ 30311 host key ]-> everyone
participant --[ 30312 round 1 ]-> everyone ParticipantMsg1
coordinator --[ 30313 coord round 1 ]-> everyone CoordinatorMsg1
participant --[ 30314 round 2 ]-> everyone ParticipantMsg2
coordinator --[ 30315 certificate ]-> everyone CoordinatorMsg2
anyone --[ 30316 failure ]-> everyone abort + reason
These only ever exist inside a NIP-17 gift wrap, so no relay sees them
unencrypted and the replaceable semantics normally implied by the 3xxxx
range never apply -- which is why they can sit next to the app's other
private kinds (30300-30309) without meaning anything different.
Every message is addressed to the whole group, even the two the protocol
only needs the coordinator to read. NIP-17 wraps per recipient anyway,
ChillDKG treats the coordinator as untrusted by construction, and having
every member observe the ritual is what makes a progress UI possible
without a side channel.
`DkgSessionIdTag` is on every message: a group may abandon an attempt and
start another, and a straggler from the dead one must be dropped rather
than mixed into the live session. `DkgThresholdTag` rides the proposal so
every participant validates the same SessionParams -- disagreement on `t`
fails the session instead of quietly producing a weaker key.
## Persistence: inputs, not state (database/model/Dkg*, schema v2)
DkgSession deliberately stores no protocol state. Reading EncPedPop
confirms randomness enters the participant steps only through the passed
`random`/`auxRand` arguments (`simplSeed = taggedHash("encpedpop seed",
seed + random + encContext)`), so every ChillDkg step is a pure function
of inputs. Keeping the two 32-byte randoms plus the received messages is
therefore enough to recompute any intermediate state on demand, and the
opaque ParticipantState/CoordinatorState objects -- which have no
serialization API -- never need to be persisted at all.
That is not a micro-optimisation. A DKG cannot finish unless all n
members take part, and chat users close apps mid-round; recomputation is
what lets a ritual resume instead of forcing the group to start over.
DkgParticipantMessage is keyed (sessionId, participantPublicKey, kind) so
a redelivered message overwrites rather than accumulates -- relays
redeliver, and a duplicated round-1 message would hand the coordinator a
participant list of the wrong length.
Database goes to version 2 with an AutoMigration: v2 only adds tables, so
Room generates it. Schema 2.json is exported alongside.
## Driving it (managers/ChillDkgRitualManager.kt)
State machine driven entirely by arriving messages: persist, then ask
whether the ritual can move. Because every step is recomputable there is
no long-lived session in memory to lose, and processing is idempotent --
a redelivered message re-runs a step that has already been taken and
changes nothing.
The coordinator is a participant too, so it records its own outbound
messages locally: its round-1 message has to be in its own aggregation
alongside everyone else's. Being the room's creator buys it no authority
here -- ChillDKG's coordinator relays but cannot learn secrets or bias
the key -- only the job of aggregating.
Two decisions worth knowing:
* Host keys are DERIVED, not reused. `sha256("mantra/chilldkg/host-key/v1"
|| nostrSeckey)`. Reusing the nostr identity key directly was the
simpler option, but one secret serving two protocols means a flaw in
either reaches the other. Deriving from the same seed keeps it
recoverable from the wallet backup, which matters because ChillDKG
needs the host secret key to recover a session's outputs and asking
chat users to back up a second secret is how keys get lost.
* Participant order is a bytewise sort of the host public keys. ChillDKG
fails outright if participants disagree on ordering, and a sort is the
only order every device can derive independently from the same set.
Any ChillDkg exception ends the session for this device and is broadcast
as a 30316 so the rest of the group stops waiting, rather than leaving
every member on a spinner that will never resolve.
## Inbound (database/dao/NostrDao.kt)
One branch on the existing decrypted-gift-wrap dispatch, beside the
kind-14 and WelcomeEvent branches, handing ritual kinds to the manager.
## UI (ui/.../DkgRitualScreen.kt + view model, state, route)
Reached from chat room detail via "Shared Key", shown only for rooms with
no MLS state -- i.e. the NIP-17/robust ones. An MLS room has a single
admin and no group key to share, so the entry point would be a lie there.
The screen is a ladder of rounds with real counts ("3 of 5") rather than
a spinner. The unusual thing about a DKG, and the thing the UI has to get
across, is that it needs *everyone* at once; a count says who it is
waiting on, an indeterminate spinner says nothing. The coordinator gets
the start button, everyone else is told who they are waiting for, and a
failed ritual states plainly that no key was created and it is safe to
run again.
DkgSession.threshold finally gives the quorum somewhere to live. The
value chosen during group creation is still not persisted on ChatRoom,
so this screen re-asks with the same majority default rather than
inventing a different one; there is a TODO where that gap closes.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Not runtime-verified: exercising a DKG needs several devices exchanging
live messages, and the library's own vector suite needs JDK 21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fast-forwards the submodule 8 commits, 2478403 -> e67fe17. The pinned
commit is an ancestor of upstream master -- the local "align with AGP
9.1.1 / Kotlin 2.4.10" commit had already been pushed there -- so this
is a clean advance with nothing to rebase or re-apply.
What it brings, all of it PR #1 (ac-cord-ac/latest-greatest):
6775d51 Fix stale trusted-dealer path in KNOWN_ISSUES.md
880d57a Document the ChillDKG module in the READMEs
ed5f7f4 Add ChillDKG test vectors and ported test suite
4345dc0 Add ChillDKG port: public API (chilldkg.py)
94686be Add ChillDKG port: SimplPedPop and EncPedPop sub-protocols
cf13e22 Add ChillDKG port: crypto primitives, error hierarchy, and VSS
0b7223f Move trusted-dealer keygen into the dkg package
+7,817 lines, almost all of it a Kotlin port of ChillDKG -- distributed
key generation for FROST, ported from BlockstreamResearch/bip-frost-dkg
@ a918968 (BIP draft 0.3.0-dev) -- plus its sub-protocols (SimplPedPop,
EncPedPop), Feldman VSS, the BIP DKG tagged-hash primitives, and all 10
official vector files exercised by 249 vector cases.
This is the piece the group work has been blocked on. A threshold that
actually means something needs a FROST key no single member holds, and
until now the library only offered FrostTrustedDealer, which requires
one party to know the whole secret -- fine for tests, wrong for a group
whose entire premise is that nobody is privileged. ChillDKG generates
that key among the participants with no dealer at all.
One breaking change rides along: frost/trusted_dealer_keygen/ moved to
frost/dkg/trusted/. It costs nothing here -- the app declares the
library as a dependency but does not yet call any of its API, so there
is nothing to update. (The ac.cord.auxiliary.compose packages under
jvmMain are the app's own leftover naming, unrelated to the library.)
Verified against the bumped submodule, with the composite build
recompiling the library from source:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Robust rooms are now plain NIP-17 group chats: no MLS group, no key
packages, no invites, no admin. Convenient rooms are unchanged and stay
Marmot/MLS.
Everything needed to *run* a NIP-17 group was already here; what was
missing was any way to start one.
* Inbound already worked for N participants, not just a pair.
GiftWrapSeal derives the room id from the author plus every p-tag via
ChatRoom.deriveChatRoomId (a musig2 aggregate over the member set),
and NostrDao stands the room up with mlsGroupState = null and a
Participant row per p-tag, inserting placeholder profiles and queueing
a profile sync for anyone unknown.
* Outbound already worked too. sendChatMessage branches on
mlsGroupState == null into gift wraps p-tagged to every participant,
sealGiftWrapPayload wraps the payload once per participant, and
NotaryViewModel drives that loop at runtime -- so the path is live,
not merely present.
* The gap was creation. NostrNip17Dao.getOrCreateChatRoom only ever
inserts the active user as a participant (the peer is literally
commented out of its hexKeys set) and expects the room id to be handed
to it, which suits an inbound message and nothing else.
database/dao/NostrNip17Dao.kt
* New createNip17ChatRoom(): derives the id with the SAME
deriveChatRoomId the inbound path uses, so the creator and every
recipient independently arrive at the same room, and building the same
group twice is idempotent rather than duplicative. Then upserts the
room with mlsGroupState = null -- which is precisely the flag
sendChatMessage reads to choose gift wraps -- and a Participant row
for the creator plus every picked member.
repository/ChatRepository.kt, database/repository/DatabaseChatRepository.kt
* Expose it, with the same try/catch-and-log-null shape its
getOrCreateChatRoom sibling uses, plus the NO_OP stub for previews.
ui/view/model/SelectChatRoomTypeViewModel.kt
* createChatRoom() splits on the chosen type into createMarmotChatRoom()
and createNip17ChatRoom(). The convenient path is the previous body
verbatim. The robust path skips key package resolution, the 20-second
relay budget and the whole sequential invite loop, because NIP-17
membership IS the p-tag set -- there is nothing to invite anybody to,
and so no partial-failure case either.
* Removes the .copy(adminPubkeys = ...) added in bf94ecb. Robust was the
only thing that ever set a multi-admin list; with convenient now the
only MLS path, MarmotGroupData.bootstrap() already stamps
creator-only, so the override had become a branch that could not be
taken.
Trade-offs this bakes in, recorded here and in a TODO on the new path:
* The quorum has LESS meaning under NIP-17, not more. There is no group
state to change and so nothing to approve: membership is whatever a
message is addressed to, and a different member set is a different
musig aggregate, i.e. simply a different room. Under MLS there was at
least an admin_pubkeys list to hang FROST off later; here there is no
object for t-of-n to govern at all. The picker is still shown and
still has nowhere to persist to.
* Members do not learn the room exists until the first message is sent.
NIP-17 has no invite event -- the first gift wrap is the invitation.
* Robust rooms give up MLS forward secrecy and the sender ratchet. What
they gain is that there is no privileged member and no shared group
state to desync.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four places restored an MlsGroup from a chat room's persisted state, each
spelling out the same MlsGroup.restore(MlsGroupState.decodeTls(hex))
chain by hand. Three of them could not have called the shared helper
even if they wanted to: until ea8b2b2, ChatRoom.toMlsGroup() demanded a
pastGenerations count so it could replay the sender ratchet, which is a
question none of these callers had any business answering. Dropping that
parameter left the helper callable everywhere, so call it everywhere.
database/repository/DatabaseChatRepository.kt
* sendChatMessage() restores via toMlsGroup(). It only wants to know
whether the room is MLS-backed or gift-wrapped, which is exactly what
a null return says.
ui/view/model/AddArtifactViewModel.kt
* Same collapse: the ?.let { restore(...) }?.let { mlsGroup -> ... }
double-let becomes toMlsGroup()?.let { mlsGroup -> ... }.
database/dao/NostrDao.kt
* The fourth copy, and the one easiest to miss: it decoded the hex into
a mlsGroupStateByteArray temp and then restored under an explicit null
check on the bytes, rather than the ?.let the others used. Same
operation wearing different clothes. Now toMlsGroup() with a null
check on the group itself, which is what the branch actually meant.
None of these three encrypt, so none of them ever needed the generation
replay the old signature forced on them -- NostrDao is the inbound path,
AddArtifactViewModel only gates on the group existing, and
sendChatMessage writes a rumor for the outbound pipeline to encrypt
later. The only caller that does encrypt, encryptAndSendMarmotInnerEvent,
persists mlsGroup.saveState() immediately afterwards, so the ratchet
position now round-trips through storage on its own.
Unused imports go with them: MlsGroup and MlsGroupState from the first
two files, MlsGroupState from NostrDao (which still needs MlsGroup for
processWelcome).
MlsGroup.restore now appears exactly once in the codebase, inside
toMlsGroup() itself.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking Robust now asks how many of the admins have to approve a change
instead of silently assuming a simple majority. The majority is still
where the answer starts; it is just no longer the only one available.
database/model/types/ChatRoomType.kt
* approvalThreshold(adminCount) becomes defaultQuorum(adminCount): same
simple majority, but named for what it now is -- an opening position
rather than the rule.
* MINIMUM_QUORUM = 2. One approval is not a quorum, it is one person
acting alone, which is what CONVENIENT already offers.
* quorumRange(adminCount) = MINIMUM_QUORUM..adminCount -- never fewer
than two, never more admins than exist to approve. Because ROBUST is
gated at MINIMUM_ROBUST_GROUP_SIZE = 3, the range always holds at
least two choices, so the picker is never a control with nothing to
pick.
ui/view/model/SelectChatRoomTypeViewModel.kt
* The fixed approvalThreshold field becomes quorum: MutableState<Int>,
seeded from defaultQuorum(adminCount), alongside the quorumRange the
UI clamps against.
* setQuorum() coerces into quorumRange, so the value cannot escape the
bounds even if the buttons' own enablement is wrong, and freezes once
createdChatRoomId is set -- past creation the governance is already
stamped into the epoch-0 group context, exactly as selectChatRoomType
does.
* quorumExplanation() states what the choice costs day to day: "Any 3 of
you can approve a change -- the other 2 don't have to be around", and
at the top of the range "Every admin has to agree. If one of you goes
quiet, nothing about the group can change." Unanimity is a real
liveness risk and the user should read that before choosing it, not
after.
ui/composable/SelectChatRoomTypeScreen.kt
* ChatRoomTypeCard gains a trailing content slot, and the robust card
fills it with the new QuorumPicker -- but only while robust is the
selected type. Before that there is no decision to make and the
question would be noise.
* QuorumPicker is a stepper, not a text field: the range is small, both
ends are bounded, and a stepper cannot produce a value that has to be
rejected. The -/+ buttons disable at quorumRange.first/last and the
caption re-reads on every step.
* The robust card's own prose drops the hard number -- "approved by a
quorum of you" rather than "approved by 3 of the 5 admins" -- because
the number is now a choice rather than a fact, and the picker is the
thing that states it.
Not done, and called out in the TODO next to the admin list: the chosen
quorum is not persisted. It cannot ride in MarmotGroupData -- MIP-01's
wire format is fixed and an extra field would break byte-compatibility
with mdk/whitenoise -- so it needs a ChatRoom column and the Room
migration off schema version 1 that comes with it. Until then the quorum
is a stated intent sitting beside the admin list, in the same way the
t-of-n enforcement itself is still waiting on FROST signing over admin
changes.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a third and final group-creation step, after the member picker, that
asks whether the new group should be convenient (the creator is its only
admin) or robust (every member is an admin and a change needs a
threshold of them to approve it).
The flow is now:
ChatRoomCreationRoute (name + description)
-> SelectChatRoomMembersRoute (pick people)
-> SelectChatRoomTypeRoute (how it is run, then build it)
-> ChatRoomMessagingRoute
New: database/model/types/ChatRoomType.kt
* CONVENIENT / ROBUST, plus the two rules that go with them.
* approvalThreshold(adminCount) is a simple majority, so no half of the
group can move without the other.
* MINIMUM_ROBUST_GROUP_SIZE = 3 and isRobustAvailable(memberCount).
Below three a majority is not a meaningful check: at two admins every
change needs both of them, and at one the creator is deciding alone,
which is CONVENIENT under another name.
New: ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt
* Carries activeUserPublicKey, name, description and the picked
memberPublicKeys. The governance choice feeds the epoch-0 group
context, so nothing can be persisted until it has been made and every
earlier answer has to ride along to this step.
* memberPublicKeys is a List<String>. Navigation 2.9.2 resolves that
through NavType.StringListType (NavTypeConverter maps
InternalType.STRING inside a collection onto it), so it needs no
hand-rolled encoding.
New: ui/view/state/SelectChatRoomTypeUIState.kt
* Loading/Loaded/Error, with Loaded carrying the picked members so the
screen can name them rather than echo hex keys.
New: ui/view/model/SelectChatRoomTypeViewModel.kt
* Owns the choice (selectedChatRoomType, convenient by default because
it is the option that always works) and the group creation and invite
round, both moved here wholesale from SelectChatRoomMembersViewModel.
* The choice is not cosmetic: it decides adminPubkeys on the epoch-0
MarmotGroupData. CONVENIENT stamps just the creator; ROBUST stamps the
creator plus every picked member, deduplicated (MIP-01 rejects
duplicates). MarmotInboundManager.processGroupMembershipChanges
already derives Participant.adminAt from exactly this list, so admin
status propagates to every member's device without further work.
* MarmotGroupData.bootstrap() still stamps the base metadata -- the
admin list is layered on with copy() -- so UI and CLI stay
byte-identical on everything else.
* selectChatRoomType() refuses ROBUST when the group is too small, and
freezes once the room exists: by then the choice is baked into the
epoch-0 group context and re-picking would change nothing.
* robustUnavailableReason spells out the shortfall ("Go back and add 1
more person"), pluralised here rather than in the composable.
* Known gap, left as a TODO next to the admin list: robust rooms get the
admin set but not the t-of-n approval itself. That needs FROST signing
over admin changes -- the same thing the existing "generate GID
through frost" TODO is waiting on. Until then every admin of a robust
room can still commit on their own.
New: ui/composable/SelectChatRoomTypeScreen.kt
* Two radio cards. Convenient explains that the creator acts alone and
that nobody can carry the group on without them; robust quotes the
real numbers -- "approved by 2 of 3 admins" -- computed from the
actual selection instead of leaving t-of-n abstract.
* Below MINIMUM_ROBUST_GROUP_SIZE the robust card renders disabled
(Card(enabled = false), disabled RadioButton, no click) and shows the
reason in the error colour. It stays on screen rather than vanishing,
so the option is discoverable and the fix -- go back, tick one more
person -- is obvious.
* Carries the bottom bar the members step used to own: the
create/progress/"Open chat" action and the partial-invite warning,
which now name members via displayNameFor() since this step only
receives keys.
SelectChatRoomMembersViewModel / SelectChatRoomMembersScreen
* Reduced to what their names say. Group creation, the invite round, the
key package lookup, the wallet flow and ChatRepository all move to the
type step; what stays is listing local profiles, ticking them, and
handing the keys on through SelectChatRoomTypeRoute.
* The up-front key package sync stays here, which is the point of doing
it early: it now has the whole type-selection step to land in before
anybody is invited.
* The action becomes "Next with N" and the empty-store copy no longer
offers to create the chat, because this step no longer can.
MantraNavHost
* Register SelectChatRoomTypeRoute. Opening the finished chat still pops
back through ChatRoomCreationRoute inclusive, so backing out of a new
chat lands where the user started rather than part-way through the
three creation steps.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Group creation was a single screen: name + description, then "Create
chat" minted the MLS group and dropped the user straight into an empty
room, with no way to bring anyone along except the existing
one-at-a-time invite path (chat room detail -> search member ->
confirm). Add a second step that lists the profiles already in the
local store and lets the user tick everyone the group is for, so a
group is created together with its members.
The flow is now:
ChatRoomCreationRoute (name + description)
-> SelectChatRoomMembersRoute (pick people, create, invite)
-> ChatRoomMessagingRoute
New: ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt
* Carries activeUserPublicKey plus the name/description gathered by step
one, so nothing is persisted until the user confirms who is in.
* `description` is nullable with a default, and ChatRoomCreationViewModel
maps blank text onto null: an empty string is not something navigation
round-trips reliably as a path argument.
New: ui/view/state/SelectChatRoomMembersUIState.kt
* The Loading/Loaded/Error triple the other chat screens use. Loaded
carries the pickable profiles.
New: ui/view/model/SelectChatRoomMembersViewModel.kt
* initiate() lists nostrRepository.searchableProfiles() excluding the
active user -- purely local rows, no directory lookup. It also queues a
negentropy sync for every listed profile's KeyPackageEvent up front, so
the packages needed to actually add anybody have usually landed by the
time the user has finished ticking names.
* createChatRoom() moves here from ChatRoomCreationViewModel, unchanged
in substance: bootstrap MarmotGroupData into the epoch-0 GroupContext,
MlsGroup.create, then getOrCreateChatRoom keyed on the Marmot
nostr_group_id (not MlsGroup's own groupId). Minting the group here
rather than in step one is the point of the split -- backing out of the
picker no longer strands a member-less room in the chat list.
* inviteSelectedMembers() resolves the selected members' key packages
concurrently under one shared 20s budget (a single relay round trip for
the batch instead of one timeout per member) by observing
observeMarmotKeyPackageForPublicKey, then invites sequentially. The
room is re-read from the repository before every invite: inviteMember
advances the MLS epoch and persists the new state, so reusing the
snapshot taken before the previous invite would build the next commit
on top of state the group has already left.
* A member whose key package never shows up does not sink the group. It
is created without them and the screen names who was left out;
createdChatRoomId then turns the action into "Open chat" against the
room that already exists rather than minting a second one, and
toggleMember is frozen once the room exists so further ticks cannot
look like they will still be honoured.
New: ui/composable/SelectChatRoomMembersScreen.kt
* Checkbox list over Loaded.profiles, reusing the row shape of
SearchMemberToAddToChatRoomScreen (ProfileAvatar + name + about); both
the row and the checkbox toggle selection.
* BottomAppBar carries the running selection count and an
ExtendedFloatingActionButton labelled "Create chat with N", which
swaps to a progress indicator while the group is being built.
* An empty local store gets an explanatory state that still allows
creating the chat and inviting people later.
ChatRoomCreationViewModel
* Reduced to the details form. Group creation, the wallet keypair and
both repositories move to the picker, so factory() now takes no
arguments at all.
* Gains validateInput() (mirroring CreateProfileViewModel) so an unnamed
chat cannot advance, and selectMembers() to hand the collected
name/description to the next route.
ChatRoomCreationScreen
* Takes activeUserPublicKey from the route instead of the wallet flow
and the two repositories; the button becomes "Choose who to chat
with".
* Fix the "groupd" typo in the name placeholder.
MantraNavHost
* Register SelectChatRoomMembersRoute. Opening the finished chat pops
back through ChatRoomCreationRoute inclusive, so backing out of a
brand new chat lands where the user started rather than in the
half-filled creation form.
Known limits: the picker inherits ProfileDao's default LIMIT 21, so only
the first 21 local profiles are offered (the same cap the existing
add-member search already lives with), and a selected profile can only
join if their KeyPackageEvent is reachable -- contacts who have never
published one always land in the "couldn't be added" list.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add both libraries as git submodules and resolve them through Gradle
composite builds instead of remote publications, so local changes to
either library are picked up by the app build directly.
Submodules:
* secp256k1-frost-kmp (github.com/ac-cord-ac/secp256k1-frost-kmp) at
2478403 -- BIP-327 FROTH/FROST signing over secp256k1, KMP.
* lightning-kmp-app (github.com/kngako/lightning-kmp-app) at 6745d88 --
phoenix business logic on top of lightning-kmp-core. Both submodules
carry a local commit aligning them with this build's AGP 9.1.1 /
Kotlin 2.4.10 (AGP version must match across a composite build or the
android variants fail attribute matching).
settings.gradle.kts:
* includeBuild() both submodule checkouts.
* lightning-kmp-app's project stays named ':library' (compose-resources
derives its Res package from the project name), so an explicit
dependencySubstitution maps the coordinate the app declares,
fr.acinq.phoenix:lightning-kmp-app, onto that project.
* Drop the jitpack.io repository: it only served com.github.kngako,
which is no longer consumed as a binary.
composeApp/build.gradle.kts:
* commonMain gains ac.cord.auxiliary:library:1.0.0 (secp256k1-frost-kmp,
substituted by the composite build).
* commonMain replaces com.github.kngako.lightning-kmp-app:library (via
JitPack) with fr.acinq.phoenix:lightning-kmp-app:1.0.0 (substituted by
the composite build). lightning-kmp-core still comes from Maven
Central.
* Remove the RestoreJitPackClassifier component-metadata rule and the
javax.inject import: it only existed to repair the artifact
classifiers JitPack drops from the apple metadata variants, which no
longer applies.
gradle/libs.versions.toml:
* Remove the now-unused lightningKmpApp version and the
com.github.kngako lightning-kmp-app catalog entry.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata :composeApp:compileDebugKotlinAndroid
The shared lightning/phoenix logic now lives in kngako/lightning-kmp-app,
which publishes a single `:library` module. JitPack serves a repository's
submodules under <GROUP>.<ARTIFACT>, hence the
com.github.kngako.lightning-kmp-app:library coordinate. The repository is
content-filtered to com.github.kngako so it is not consulted for anything
else -- an unfiltered jitpack.io entry gets asked about every dependency
that misses in mavenCentral, and each miss is a remote round trip.
The version is a commit rather than master-SNAPSHOT. For a -SNAPSHOT
version JitPack advertises a unique-snapshot maven-metadata.xml
(timestamp=<sha>, buildNumber=1) while serving the files under their
literal -SNAPSHOT names, so gradle derives library-<target>-master-<sha>-1
and gets a 404 on every artifact. Pinning a commit sidesteps the snapshot
machinery entirely and is reproducible; it needs bumping when the fork
moves.
That leaves the classifier. JitPack rewrites the version inside a
published .module file and drops the classifier while doing so, so both
the sources and the host-specific metadata variants of each apple target
come back naming library-<target>-<ver>.jar -- a file that does not
exist, next to the -sources.jar and -metadata.jar that do. The klib and
the aar are named without a classifier and so survive the rewrite, which
is why the android compilation resolves this dependency perfectly well
and only the metadata compilations fail. Every shared ios source set
resolves through those, so a component metadata rule puts the -metadata
classifier back. It is scoped to the two apple modules and to their
metadata variant by name, so it cannot disturb the klib artifacts.
Verified: :composeApp:compileCommonMainKotlinMetadata and
:composeApp:compileDebugKotlinAndroid both pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A profile created in the app could never start a chat with another
profile created in the app: the peer always looked like it had no
metadata, no DM relay list and no MLS key package.
Root cause was in neither the chat code nor the relay list — nothing a
new profile signed ever reached a relay. Three queue observers used
`createdAt > :createdAt` with a `Clock.System.now()` default argument.
Kotlin evaluates that default once, at the call site, and Room binds it
for the life of the Flow; Instants persist at second resolution, so
every request enqueued in the observer's own start second (the whole
profile-creation burst) and everything left pending by a previous
session was permanently invisible. Nothing else drains those tables.
The failure was silent because `publishNostrEvent` stamps `signedAt`
and indexes the Profile in one transaction, satisfying the
ProfileLoaded branch before the UnannouncedProfile gate could be
reached — so a device-only profile looked fully announced.
Dropping the cutoff needs no schema change, so existing installs
self-heal on next launch: the stranded rows are still pending.
Also fixed, since they gate the same flow once events start moving:
- Broadcasts now always reach a terminal status (outer timeout plus
try/catch — `.catch` cannot see the suspend call that builds the
flow), interrupted ones are requeued once at startup, `OK: false` is
a failure rather than a recorded success, fan-out is bounded, and an
uncorrelated NOTICE no longer fails whatever publish shares the
socket. `take(1)` keeps the publish timeout from firing after a
success on a SharedFlow that never completes.
- CLOSED is parsed and handled, so a relay refusing a NEG subscription
falls back to REQ instead of waiting forever; NOTICE is parsed as
the two-element frame it is; negentropy timestamps use seconds, the
unit relays use.
- Both chat gates observe the peer's key package instead of reading it
once and latching a terminal error, and queue the sync they claimed
to be doing. Same-minute retries are no longer swallowed by IGNORE.
- Group rooms were keyed by the MLS group id instead of the Marmot
nostrGroupId (unrelated randoms, so neither side saw the other's
events); inviting a member wrote no Participant row, so the Welcome
produced no gift wraps, and discarded the post-addMember group state;
the invite reported success unconditionally.
- An inverted `containsKey` made the "missing peer DM relay list"
recovery a no-op, and the wrong RelayTag class wrote "r" tags where
NIP-51 relay lists expect "relay".
Verified with `:composeApp:compileDebugKotlinAndroid`, including that
Room's KSP regenerated the DAO impls without the frozen cutoff. Not yet
exercised against live relays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Observers now run as children of collectLatest keyed on the derived
nostr private key: previously every active-wallet emission spawned
four more eternal collectors on the app scope, and after a wallet
switch stale collectors kept signing with the old key (duplicate
signatures, gift wraps and key package bundles).
- Follow the keyManager StateFlow instead of snapshotting .value, so
the notary still starts when the key loads after the wallet emits.
- Guard per-item processing so one failing row logs instead of killing
the collector (and the queue) for the rest of the session.
- Derive the real nsecPassword for self-healed key package bundles via
a new PrivateKey.nsecPassword() extension instead of passing "".
- Fix a copy-pasted log tag in observeUnprocessedMarmotInnerEvents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coroutine scopes, AuxDatabaseManager, and repositories were
created as plain vals in the composable body, so every recomposition
recreated them — leaking the old scopes' jobs and duplicating
repository instances. Wrap them in remember so they are created once
per composition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- platformWriteSeed (Android + iOS) invoked onSeedWritten twice on
success, doubling wallet switch/navigation and navigating from the
IO dispatcher; keep only the main-thread invocation.
- Gate profile event creation on the seed actually being written to
disk: writeSeed now reports success/error, so a failed seed write no
longer leaves orphaned unsigned events the notary can never sign.
- Stop rethrowing from createAccount's CoroutineExceptionHandler
(crashed the app); failures now show the Error state and reset the
pending flag instead of spinning forever. Add a re-entry guard
against double taps.
- Reset WritingSeedState after a completed attempt so retries are not
silently skipped, and record WrittenToDisk on success.
- Derive the nostr key with NodeParamsManager.chain instead of a
hardcoded Chain.Mainnet.
- Build the SearchRelayListEvent from DefaultSearchRelayList instead
of DM relays, and drop its empty privateTags array that caused a
pointless NIP-44 encrypted empty list in content.
- Compare pubKey, privateTags and signedAt in UnsignedNostrEvent
equals/hashCode so distinctUntilChanged cannot conflate rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a successful add-artifact, navigate to the artifact detail for the newly
created artifact instead of the implementation-pending screen. The ViewModel
now passes the created artifact's id (the returned inner event's id) to
onSuccess, and the screen opens ArtifactDetailRoute via the pop-inclusive
callback so the add form leaves the back stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Chapters section above the Details section on the
TranslationArtifactVersionDetailScreen so the actionable content leads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Chapters and Translations sections to the top of the artifact
detail screen and the descriptive Details and Versions sections to the
bottom, so the actionable content leads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the screen and its ViewModel, UI state, and route to reflect that
they describe a MantraTranslationArtifactVersion:
- TranslationDetailScreen -> TranslationArtifactVersionDetailScreen
- TranslationDetailViewModel -> TranslationArtifactVersionDetailViewModel
- TranslationDetailUIState -> TranslationArtifactVersionDetailUIState
- TranslationDetailRoute -> TranslationArtifactVersionDetailRoute
Files moved with git mv to preserve history; all references (nav host,
artifact detail screen) updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The translation column of the chapter table is now a TextButton (with a
ChevronRight) per chunk; tapping it opens an editor for that chunk.
- TranslateChunkScreen (route + screen/ViewModel/UIState) shows the full
original chunk and a text input prefilled with any existing translation.
Saving persists the translation and returns to a freshly-loaded chapter
table (popUpTo<TranslationChapterRoute>) so the update shows.
- MantraRepository.saveTranslationChunk builds a MantraTranslationChunk from
the entered text (content-hash id, index mirrored from the source chunk),
upserts it plus its MarmotInnerEvent rumor, and replaces any prior
translation chunk for the same source chunk (deleting the stale row and its
rumor) so there is exactly one per source chunk. New DAO queries getChunkById,
MantraTranslationChunkDao.deleteById, MarmotInnerEventDao.deleteById, and
repository getChunk.
- TranslationChapterScreen renders the translation cell as the button and
navigates to TranslateChunkRoute with the source chunkId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chapter cards in the translation detail now open a two-column chapter
translation screen.
- The screen loads the source MantraChapter's chunks paired with their
MantraTranslationChunks (by chunkId). Column one is the original chunks,
column two the translated chunks; the header row shows the original dialect
name and the translation dialect name. When a chunk has no translation
(missing or blank text), the original text is shown greyed out as a
placeholder in the second column.
- The ViewModel resolves the original dialect by walking chapter -> version
-> artifact -> dialect and the translation dialect via the translation
version. New DAO queries getArtifactVersionById and getTranslationChapterById
plus repository accessors getArtifactVersion/getTranslationChapter/getDialect.
- New TranslationChapterRoute + screen/ViewModel/UIState; TranslationDetail
chapter cards navigate to it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>