Phase 2 of docs/multiple-profiles.md. The transition, made correct, with
nothing yet calling it from a screen: the view model, one manager, and three
one-line actuals.
switchToWallet becomes switchToIdentity, and does what a switch is: remember
which one, set startWalletImmediately back to true -- a switch is the user
saying which one, and the flag was only ever the user saying "show me the
list"; nothing set it back before because nothing could switch -- clear the
active identity, and stop the node of the profile being left, if it had one.
The identity is cleared before the node is stopped, so that every collector
that could reach the business is cancelled before the stop runs. The test is
business != null, not the kind: whether the profile being left has a node
behind it is the fact, and the kind is how it currently comes to be true.
stopPlatformBusiness is an expect beside updateBusinessActiveInUI, for the
reason that one is: BusinessManager is a per-platform object. Its three
actuals call stopBusiness, which existed on every platform and nothing
called. The manager is injected into the view model as a function so that
the branch can be pinned without a node.
RelaysSocketManager.observeActiveUserId becomes a child of collectLatest --
the shape the pumps and the notary already had. It used to keep one job per
pubkey on its own scope and cancel only the job for the pubkey being
started, which meant a switch left the previous identity's observer running:
two observers feeding updateRelayPools, and the one pool following whichever
relay list emitted last. The map goes; a null identity closes nothing, since
the pool is shared by pumps a null identity has already cancelled, and the
next identity's list replaces it through changeRelays as it always did. The
scope is injectable and relayUrls is exposed, both for the test.
The sign-in and create tails keep their own navigate beside the observer's,
now with a comment saying why: the navigation state is a StateFlow, an
update to a state equal to the current one emits nothing, and the explicit
navigation is what guarantees the stack moves even on the day the state
does not.
Tests: IdentitySwitchJvmTest, through the real SovereignWalletViewModel with
a real notary and navigation machine on an in-memory database -- A open and
a kind 1 queued for A is signed; switch to B, the identity clears, the
machine goes to startup, B is activated the way startup does and routed from
its account; a kind 1 queued for A now waits three seconds unsigned, and
goes out when A is back. Leaving a profile with a PhoenixBusiness behind it
-- constructible without a node, since everything in it is lazy -- stops
that node once; leaving nothing, or a bare key, stops nothing.
RelaysSocketManagerSwitchJvmTest over a fake relay repository and fake
sockets: after the switch the pool holds B's relays, a re-emission of A's
list changes nothing -- the line that fails against the old observer -- and
an identity whose list is still empty leaves the pool as it was, which is
the behaviour updateRelayPools has always had for an empty list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@9b3a19278f
Phase 3 of docs/nsec-sign-in.md, and the submodule bump that brings Phase 2's
NostrKeyManager in (lightning-kmp-app 01489b8 -> 59c11ed, branch
claude/nostr-key-store). Nothing can create an nsec identity yet -- that is
Phase 4 -- but from here one that exists is listed, selected, started and
routed like any wallet.
StoredIdentity is what the startup screen now reads: a sealed type over
Mnemonic(userWallet) and NostrSecret(id, privateKey), both carrying the nostr
public key, because that is the one thing the two kinds share and the one
thing a duplicate check has to compare. StoredIdentity.merge folds seed.dat
and nostr-keys.dat into one map keyed by WalletId; a wallet's pubkey is one
more LocalKeyManager over words that SeedManager has already derived once.
SovereignWalletViewModel.listAvailableWallets becomes listIdentities. It
reads both files and surfaces a failure in either rather than skipping it: a
corrupt nostr-keys.dat would otherwise drop every imported identity from the
list without a word, and the seed file has always been handled this way.
Metadata registration is unchanged -- it keys on WalletId and does not care
what is behind one.
SovereignWalletStartupScreen picks a StoredIdentity, and LoadWallet, the
screen-lock gate, takes the identity rather than the wallet: it only ever read
the id, to look up the lock preferences, and it now sits outside the branch on
kind so a future lock is not something to remember to add twice. The Mnemonic
branch is the old path -- startupNode, setActiveWallet. The NostrSecret branch
builds the Identity directly with business = null and sets it; no
platformStartupLogic, no schedulePlatformLogic, no StartupViewState. Setting
it recomposes into the existing `activeIdentity != null` branch, which is what
reports the startup, so the nsec path does not double-report as the mnemonic
path does. WalletsSelector shows the npub on the second line for both kinds;
it used to show the node id, which a bare key does not have.
The other entrance. NavigationViewModel.loadNostrProfile(startupRoute) read
getLocalAccounts().firstOrNull()?.profile?.publicKey
which is wrong twice for this plan. It takes the first kind-0 account on the
device whichever identity is active -- harmless with one wallet, wrong half
the time with two, and an imported key is how a device gets its second. And
it keys off the Profile row, which only the notary's *signing* writes, so a
placeholder account planted by signInToProfile (or a profile created moments
ago and not yet signed) read as null and was answered with Landing, while
observeProfile, reading the same rows, answered the sync screen: two writers
to one state in whichever order the coroutines ran, and Landing pops the back
stack. It now selects the account by the active identity's pubkey, matched on
the unsigned event's pubKey, and passes it straight to processLocalAccount
rather than fetching it a second time.
The seed writer, hoisted. platformWriteSeed was an expect with three
byte-identical actuals -- android, jvm, ios -- using nothing but commonMain.
It is one function now, IdentityWriter.writeMnemonic, and the second writer,
IdentityWriter.writeNostrKey, sits next to it rather than being triplicated
in turn. Both refuse a duplicate by nostr public key as well as by id: a
wallet and an imported key can be the same npub under different ids, and the
database is keyed by pubkey. The three platform files lose the copy and the
imports that only it used; the jvm file's header, which described the copy,
now describes what is left.
Tests. NavigationRoutingTest gains the placeholder fixture -- kind 0 at
GENESIS_AT, no Profile, no sync request -- and asserts it routes to
UnqueuedProfileSynchronization and never Landing. The jvm routing test gains
a device with two accounts and asserts the startup entrance reads the active
identity's. StoredIdentityJvmTest pins the merge against NIP-06's own vector:
the words "leader monkey parrot ..." derive 17162c92...cd917, the same key an
nsec import of that secret produces, which is the whole basis of the
duplicate check; that a wallet and its own nostr key as a bare secret land
under different ids (why the writers dedupe by pubkey); and that a key filed
under a pubkey it does not derive is dropped.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (870
tests) and :composeApp:m3Audit. The submodule commit is local to the branch
named above and has to be pushed with this one.
Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 59c11ed9, the pin this commit compiles against (git had fast-forwarded it to the newer one).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@366b017787
Phase 7, the first half. All 43 routes took navigation-compose's default, which
turns out not to be the hard cut the plan expected: on android and desktop it is
`fadeIn(tween(700))` / `fadeOut(tween(700))`, written into the library's own
internals. Both halves of that are worth changing. 700ms is roughly three times
M3's duration for a full-screen change, and a literal inside a dependency is not
a decision this app made -- phase 1 wired a `MotionScheme` into the theme
precisely so that there would be one place to make it.
**The plan named an API that an app cannot reach.** It says every spec should
come from `MotionSchemeKeyTokens`; that enum is `internal` to material3, so the
tokens are not addressable by name from outside. `MaterialTheme.motionScheme` is
the public surface and offers the same six specs. Two private helpers name which
of them this app uses for what -- `defaultSpatialSpec` for the slide,
`defaultEffectsSpec` for the fade -- which is the distinction the scheme draws:
spatial motion is springy because it moves something, effects motion is not
because a fading colour that overshoots looks like a fault.
**The shape is M3's shared axis.** The arriving screen slides in from the
trailing edge while the leaving one slides out toward the leading edge, both
fading; going back mirrors it, so the direction of travel is legible rather than
a dissolve that looks the same either way. `slideIntoContainer` is
layout-direction aware, so an RTL locale gets the mirror for free.
**Reduced motion, on the three platforms, in the shape phase 1 established.**
`platformReducedMotion()` is an expect/actual beside `platformThemeContrast()`,
observed rather than read once, because somebody who turns it on because motion
makes them ill should not have to restart the app.
Android has no "reduce motion" switch -- it has **Remove animations**, which sets
the animation duration scales to zero. The platform applies that scale to
`ValueAnimator` and **not to Compose**, which runs on its own clock and ignores
it entirely, so an app that draws its own transitions has to read the setting
itself. A `ContentObserver` on `ANIMATOR_DURATION_SCALE` catches the change
without a restart.
iOS is the one platform where it is a single documented call,
`UIAccessibilityIsReduceMotionEnabled`, with the same notification shape as the
darker-system-colours one already observed there.
Desktop answers `false`, and says at the site why that is honest rather than a
stub: Windows, macos and the freedesktop desktops each have the setting and none
of the three reaches AWT. That is the same wall `platformThemeContrast` hits on
linux and macos, and the same eventual answer -- a preference with the platform
as its default.
Reduced motion does not mean *no* transition. The screen still fades; what goes
is the movement, which is what M3 and WCAG 2.3.3 are both about.
**Two tests, and the first one found a design flaw in the second.** The claim
"this transition slides and that one does not" cannot be asserted on the values --
`EnterTransition` has no public shape to inspect -- so it is measured: hold the
clock, navigate, advance a third of the way, and read where the arriving screen
is. Sliding, it is 54dp from home; reduced, it is already there.
The reduced case read 54dp at first, because the test provided
`LocalReducedMotion` *around* `TorchTheme` and the theme overwrote it. The fix is
not in the test: `reducedMotion` is now a `TorchTheme` parameter defaulted to the
platform, exactly as `contrast` is, because a value nothing can override is a
value nothing can test -- and because the desktop actual is a hardcoded `false`
that a settings screen will eventually need to override anyway.
637 jvm tests green; android and desktop compile. The audit's motion count goes
2 -> 11 and navigation transitions 0 -> 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1, step 2 of docs/material-design-conformance.md. `Color.kt` has carried medium-
and high-contrast variants of both themes since it was generated -- 156 colour values,
wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and never selected.
`TorchTheme` chose between `darkScheme` and `lightScheme` and nothing else, so a user who
turned contrast up in Accessibility settings got no change at all.
M3's accessibility foundation leads with *honour individuals*: "supporting varying
preferences and choices that allow individuals to address how their changing conditions,
individual knowledge, and varying needs are met." The work to do that was already done and
disconnected.
**The expect/actual boundary moved, because it was in the wrong place.** `themeColorScheme`
took four arguments and did two unrelated jobs -- decide the contrast-free light/dark
scheme, and decide whether to prefer a wallpaper palette. Adding contrast to it would have
meant passing six schemes across the boundary and repeating the selection table in three
actuals. It splits instead into `platformThemeContrast()` and `dynamicColorScheme()`, each
answering one narrow platform question, with the six-way table as a plain function
`appColorScheme(darkTheme, contrast)` in common code. `dynamicColorScheme` returns null
rather than falling back internally so the fallback stays in one place.
**Android reads the setting and listens for changes.** `UiModeManager.getContrast()` is
API 34; the app's minSdk is 26, so below that the answer is Standard. The float is snapped
to the nearest of the platform's three documented positions rather than matched exactly, so
a future finer-grained slider degrades to the closest scheme this app has instead of
falling back to Standard.
The `ContrastChangeListener` is the part that is easy to leave out and matters most. A
contrast change does not restart the activity and does not arrive as a `Configuration`
update, so without it the new setting would take effect on the next cold start -- which is
precisely the case the setting exists for. `context.mainExecutor` rather than
`ContextCompat.getMainExecutor`: it needs API 28, this branch is already gated on 34, and
composeApp does not declare androidx.core -- it only arrives transitively through
activity-compose, which is not a dependency to lean on.
**iOS observes the notification for the same reason** --
`UIAccessibilityDarkerSystemColorsEnabled` plus
`UIAccessibilityDarkerSystemColorsStatusDidChangeNotification`. It is a boolean, not a
slider, so iOS reports High or Standard and never Medium.
**Desktop is honest rather than complete.** Windows publishes high contrast as the
`win.highContrast.on` AWT desktop property and fires a property change when it is toggled,
so that path is real and live. macos "Increase contrast" and the linux desktop equivalents
do not reach AWT, and reading them means a native call per platform, so on those two the
answer is Standard and the file says so. This is the right place for a user-overridable
preference later; a desktop app cannot always see what the desktop was told.
**Verified on an emulator, at the pixel.** API 36, dynamic colour temporarily switched off
(see below for why that is necessary), sampling the `onPrimaryContainer` pixel of the "Skip
for now" label as `settings put secure contrast_level` moved:
standard (0.0) #848484 onPrimaryContainerLight
medium (0.5) #A7A7A7 onPrimaryContainerLightMediumContrast
high (1.0) #D0D0D0 onPrimaryContainerLightHighContrast
The three declared values exactly, and **the app was not restarted between them** -- only
the setting changed, four seconds apart. That is the listener working end to end. The probe
that switched dynamic colour off is reverted in this commit; the emulator's contrast_level
is back at 0.0.
**A finding that came out of the verification, and is not fixed here.** `TorchTheme`
defaults `dynamicColor = true`, and on Android 12+ dynamic colour wins unconditionally --
so on essentially every current Android device **none of the six schemes is used at all**
and the app renders in whatever the user's wallpaper produced. The first screenshot of this
session shows the onboarding screen in Material lavender; switching dynamic colour off
reveals the black-and-gold brand for the first time. Nobody on a modern Android has been
seeing this app's palette.
That is a product decision, not a conformance one, so it is recorded in the plan's "What
this plan does not cover" rather than changed. It does bound what this commit buys: on
Android 14+ with dynamic colour on, contrast is honoured by the platform anyway (the
`system_*` resources shift with it, confirmed on the same emulator -- buttons went
slate-blue to near-black navy). What this commit reaches is Android below 12, Android 12-13,
iOS, and desktop.
**Three new assertions.** `AppColorSchemeSelectionTest` covers the table itself, because its
failure mode is silent and specific: a scheme wired to the wrong cell still renders a
complete, plausible UI, and somebody who turns contrast up and gets the medium scheme back
cannot tell it apart from a high-contrast scheme that is not very high. It asserts each of
the six cells by identity, that all six are distinct objects (a copy-paste leaving two cells
on the same scheme would pass the first test only if it also mislabelled one), and that
`onSurface` on `surface` never *falls* as contrast rises -- the one direction that must
hold, and deliberately not the full monotonicity assertion that ColorSchemeContrastTest
explains is false.
**Not compiled: the iOS actual.** The ios targets are declared only on macos (see
docs/jvm-target.md), so `Theme.ios.kt` is written against the UIKit and Foundation bindings
rather than checked by a compiler. Its file comment says so. The android and jvm actuals of
the same two functions are compiled, and the android one is verified on a device.
**Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, up from 920/583/337.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build,
`m3-audit.sh --check` exits 0. The 54 existing `TorchTheme { }` call sites are untouched --
the new parameter is defaulted.
Co-Authored-By: Claude Opus 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>