feat(settings): NIP-78 cross-device sync of Interface preferences#564
feat(settings): NIP-78 cross-device sync of Interface preferences#564dmnyc wants to merge 16 commits into
Conversation
Ports iOS commit #1 from feat/one-tap-zap. Publishes a single NIP-44-encrypted kind 30078 event addressed by the `wisp-app-settings:v1` d-tag whenever any synced setting changes (debounced 4s — matches iOS). On launch, fetches and applies the remote backup non-destructively. Synced fields: zapIconStyle, largeText, themeName, accentColorARGB, autoLoadMedia, videoAutoplay, mediaLayoutStyle, clientTagEnabled, postUndoTimerEnabled, postUndoTimerSeconds, postUndoTimerForReplies, fiatModeEnabled, fiatCurrency, zapPresetsCSV. (Quick-zap fields will plug in via the same payload in commit barrydeen#2 — schema is already forward-compatible.) Wiring: • Nip78.kt — adds AppSettingsPayload (matches iOS keys), plus create/decrypt/filter helpers. • AppSettingsRepository — owns the signer/relay-pool refs, debouncer, build/publish/restore. Restore temporarily detaches its own sync callbacks so applying a remote backup doesn't kick off an immediate re-publish loop. • InterfacePreferences / FiatPreferences / ZapPreferences — gain an `onSyncedFieldChanged` hook fired only on synced-field setters. Non-synced setters (language, newNotesButtonHidden, liveStreamsHidden, autoTranslate) deliberately stay quiet. • ZapPreferences — gains CSV round-trip helpers (toCSV / applyCSV). CSV format matches the iOS spec: `<sats>` or `<sats>:<message>`, comma-separated. `applyCSV` suppresses its own sync callback to avoid a restore-publish loop. • FeedViewModel — constructs AppSettingsRepository, plumbs it into setSigner / clearSigner. • StartupCoordinator — calls `restoreSettingsBackup()` after relay-list fetch on both cold and warm starts (best-effort, fire-and-forget). • InterfaceScreen — new "Cross-device sync" section with a toggle bound to `isSyncSettingsToRelays()` (default on). Flipping on immediately fires a sync via the new onSyncRequested callback plumbed through Navigation.kt. Also drops the "GIF picker keyboard race" item from ANDROID_PORT_one_tap_zap.md — that race doesn't reproduce in Compose.
Ports iOS commit barrydeen#2 from feat/one-tap-zap. Adds the four AppSettings properties iOS persists, plumbed through the NIP-78 payload introduced in the previous commit: • quickZapEnabled (Bool, default false) — master toggle • quickZapAmountSats (Long, default 100, hard-clamped to 1..10000) • quickZapAmountFiat (Double, default 0.10) • quickZapMessage (String, default "") — optional default note Hard cap at 10,000 sats so an instant zap never bypasses the soft 10K-sats confirmation dialog in the ZapSheet (task barrydeen#4). Fiat clamp happens at fire time against the cached exchange rate, not in the setter — the live rate fluctuates and we don't want a stale rate to silently re-clamp the saved amount. Interface settings — new section titled "Zaps" (or "Payments" in fiat mode, label flips the same way iOS does). Toggle row + gated amount + message fields. Amount field shows fiat or sats based on fiat mode; sats variant has a "Max 10000" supporting caption. Wiring: • InterfacePreferences — 4 setters fire the same sync callback as the other synced fields, so any edit lands on relays within the 4s debounce window. • AppSettingsRepository.buildPayload — emits all 4 quick-zap fields. AppSettingsRepository.applyPayload — restores them non-destructively from a remote backup. • InterfaceScreen — new "Zaps" / "Payments" section between Fiat Mode and the Zap Icon toggle. No UI wiring yet for the long-press behavior or the in-sheet toggle — those land in tasks barrydeen#4 (ZapSheet redesign) and barrydeen#5 (post-card gesture).
Ports iOS commit barrydeen#3 from feat/one-tap-zap. Two-screen reshape that makes the obvious next step visually obvious. Mode picker (Screen 1): • Spark row is now a full-bleed orange card with white text + icon, layered shadow glow (two stacked shadows in `WispThemeColors.zapColor` — wide outer + tighter inner — for the iOS bloom). • NWC keeps its dark surface-variant peer treatment. Not buried under "More options" per spec — it's a genuine alternative. Spark sub-screen (Screen 2): • "Use my default wallet" gets the same primary treatment (orange fill + glow stack) so the nsec-derived recovery path reads as the obvious first choice. • Create new / Restore from seed phrase / Restore from relays collapse under a "More options" disclosure with a chevron that rotates 0→180° via animateFloatAsState. Expand defaults to open if there's no default-wallet option visible (so users who can't derive still see all paths immediately). • AnimatedVisibility wraps the inner Column for the expand/collapse transition (expandVertically + fadeIn / shrinkVertically + fadeOut), matching the iOS "fade rows in/out" cue. Extracted as `WalletPrimaryRow`, shared by both screens. Vertical-centering of the pick stack (iOS spec calls out a GeometryReader-backed ScrollView) isn't implemented here — the existing Spark sub-screen already uses weighted spacers within a non-scrolling Column, which centers naturally when content fits the viewport; smaller phones fall back to scroll via the parent's verticalScroll.
…tion, friendly errors Ports the behavioral half of iOS commit barrydeen#4 from feat/one-tap-zap. The full layout rewrite (hero amount, recipient row, FlowLayout preset strip, privacy dropdown, hidden TextField with 450ms focus deferral, register-style cents on every keystroke, EditPresetsSheet, scroll-dismisses-keyboard) is deferred — the existing ZapDialog layout still works and the new behaviors are the user-facing material change. What landed: • **Instant-amount seed on open.** First mount with no initialSatsHint pre-fills `customAmount` from `interfacePrefs.getQuickZapAmountSats()` (or the fiat equivalent in cents), and `message` from `interfacePrefs.getQuickZapMessage()`. Treats the configured instant-zap amount as the "preferred opening amount" even when quick zaps are disabled — matches iOS. • **In-sheet Instant-zaps toggle.** New row above the action buttons, bound directly to `InterfacePreferences.isQuickZapEnabled` so flipping it from the sheet propagates to the post-card long-press behavior (and the NIP-78 backup) without navigating to settings. Label flips with fiat mode. • **1,000,000-sat hard cap.** Zap button disables and a red "Max 1,000,000 sats per zap" caption surfaces above the action row when the effective amount crosses the cap. Hard cap, not a confirmation. • **10,000-sat soft confirmation.** Below 10K the Zap button fires immediately. At/above 10K it routes through an AlertDialog ("Zap N sats? — This is a large amount, double-check before sending") with Send / Cancel. Below the cap so users can recover from a stray preset tap. • **`friendlyZapErrorMessage()` utility.** Mirrors iOS's `ZapAnimationStore.friendlyMessage(for:)` substring-match table plus the Swift-enum description fallback (extracts `("…")` when present). Internal so post-card error pills + future layouts can both call it.
…abled Ports iOS commit barrydeen#5 from feat/one-tap-zap. Splits the zap-glyph gesture into two paths and renders the button as disabled for the user's own posts. ActionBar: • New optional `onZapLongPress: (() -> Unit)?` parameter — null means "no long-press behavior, tap-only" (existing call sites keep working without changes). • Zap glyph switches from IconButton to a Box with `combinedClickable`, supporting onClick (open composer) AND onLongClick (fire instant zap). When `onZapLongPress` is null, the long-press handler is omitted entirely so the glyph behaves exactly as before. • `longPressFired` flag pinned in remember{} — Compose, like SwiftUI, fires both onClick AND onLongClick on release of a long-press, so the tap handler short-circuits the second fire when the flag is set. • Disabled tint moved from 0.4f to 0.35f opacity to match the iOS self-zap rendering. PostCard: • Plumbs `onZapLongPress` through to ActionBar. • Self-zap disabled: `zapEnabled = zapEnabled && !isOwnEvent` so the user's own posts render the glyph at low opacity AND both tap + long-press are short-circuited (the long-press handler returns null when zapEnabled is false in ActionBar). What's NOT in this commit: • The actual "instant zap fires the configured amount" wiring at call sites (RichContent's WispActions etc.). The gesture infrastructure is in place; plugging it in requires reaching into ZapSender / WalletViewModel and is a separate, larger commit that touches every call site that constructs WispActions.
Ports iOS commit barrydeen#6 from feat/one-tap-zap. Replaces the multi-layer Canvas bolt animation that was smearing the silhouette at scale peaks. New approach: always-white silhouette + three stacked zap-color shadows underneath, driven by a single sin-eased oscillator. Math (period 0.9s): sine ∈ [-1, 1] phase ∈ [0, 1] = (sine + 1) / 2 iconScale = 1.0 + 0.10 * sine (0.90 → 1.10) verticalOffset = -0.5 * sine (±0.5dp centered on baseline) Shadow layers (Canvas strokes — drawn outer → inner so the white core sits on top): outer — radius 8 + 6*phase dp, α = 0.30 + 0.50*phase medium — radius 4 + 3*phase dp, α = 0.55 + 0.45*phase inner — radius 1.5dp constant, α = 0.95 core — solid white silhouette, untinted Vertical motion held to ±0.5dp so the icon doesn't lift off the action-bar baseline and misalign with neighbouring glyphs. The white IS the luminous core; the warm halos do the heat work. LinearEasing on the sineAngle (not FastOutSlowInEasing) — the sine function itself supplies the easing curve. Wrapping with another easing would double-stack and visibly stutter.
Ports iOS commit barrydeen#7 from feat/one-tap-zap. Empty placeholder so future throwaway experiments have somewhere to land instead of sprouting one-off entry points across production code. • New `Routes.DEVELOPER_TOOLS` route. • New `DeveloperToolsScreen` composable — top-app-bar with a back arrow and a single muted "No tools yet" caption. Drop test buttons / log dumps / force-state toggles directly into the Box. • `InterfaceScreen` gains an optional `onOpenDeveloperTools` callback. When `BuildConfig.DEBUG == true` AND the callback is non-null, a "Developer" section + "Developer tools" row renders at the bottom of the settings list (above the Wisp version line). Release builds skip the section entirely — the wrapping `if (BuildConfig.DEBUG)` gate compiles out in R8. Nothing inside the screen yet. That's the point.
…ad of 1 The dashboard footer was capped at one transaction. iOS shows up to five rows inline before the "View all" affordance — matching that here. Tapping any row (or "View all") still expands to the full transactions screen.
…dismiss
Previous pass only added behaviors (instant-amount seed, in-sheet
toggle, caps, confirmation). The layout still used a centered
Dialog that filled the screen and offered no dismiss gesture —
"too tall and impossible to dismiss". This commit rebuilds the
composer from scratch to match the iOS reference screenshot.
Container: switch from `Dialog` to `ModalBottomSheet`. Gives you:
• Drag handle at the top (Material3 supplies it).
• Swipe-down dismiss + scrim-tap dismiss.
• Partial-height presentation so the sheet doesn't take over
the whole viewport.
Layout (top to bottom, mirrors iOS spec §2.6 of the port doc):
1. **Toolbar** — "Close" pill on the left, orange-tinted
"Presets" pill on the right (opens the Save-preset dialog).
2. **Recipient row** (when `recipientPubkey` + `profileLookup`
are provided) — 32dp avatar, display name + lud16 stacked,
trailing copy-icon button that pushes the lud16 to the
clipboard. Hidden gracefully when no profile data is wired.
3. **Hero amount** — 56sp orange rounded-bold number with a
muted-orange unit caption ("sats" or fiat code) underneath.
4. **Preset strip** — wrapping FlowRow of pills. Last chip is
`Custom` with an inline + badge that saves the current
amount as a new preset (disabled at 8-preset max or when
the amount already exists).
5. **Custom amount field** — inline OutlinedTextField, only
visible when the Custom chip is selected. Digit-only.
6. **Message field** — single-line OutlinedTextField with
"Message (optional)" placeholder. Preset taps auto-fill
their default message only when the field is blank, so a
mid-type tap doesn't clobber what the user wrote.
7. **Privacy dropdown** — single-row pill with eye / eye-slash
/ lock icons and helper subtext. Material3 DropdownMenu
opens on tap. Hidden when `forcePrivate` is on.
8. **Instant zaps toggle** — bound to the existing
`interfacePrefs.isQuickZapEnabled` setting (and therefore
to the NIP-78 sync). Flipping it here propagates without
re-opening Interface settings.
9. **Zap button** — full-width, accent fill, white bolt + sats
copy. Disabled when amount is 0 or over the 1M hard cap. At
>10K it routes through the existing soft-confirmation alert.
Stripped:
• LightningBackground (decorative animated dots).
• AnimatedBoltHeader (centered pulsing bolt).
• drawMiniBolt + bespoke ZapPresetChip / ZapChipButton scaffolding.
None of these matched the iOS reference; the new layout is simpler
and reads cleaner at a glance.
Signature kept compatible — added two optional params
(`recipientPubkey`, `profileLookup`); existing callers keep
working, the recipient row just hides. Wired the FeedScreen and
thread Navigation.kt call sites to pass profile data; remaining
call sites (groups, DM, profile, hashtag, set-feed, article,
notifications) still compile but won't show the recipient row
until they're updated.
…ry call site
Two follow-ups to the ZapSheet rewrite:
1. **Zap button no longer hides behind the keyboard.** The previous
layout put the Zap button at the end of a single Column inside
the bottom sheet, so when the amount field focused, the
keyboard pushed the whole content up and the button went
off-screen with no scroll to reach it.
Restructured the sheet body into two rows: a scrollable upper
region (toolbar, recipient, hero, presets, message, privacy,
instant-zaps toggle) holding `weight(1f, fill = false)` and a
pinned lower region with the cap warning + Zap button. The
outer Column gets `imePadding()` so the whole stack floats
above the IME — button stays visible, upper region scrolls if
the keyboard cuts into it.
2. **Recipient row now renders on every call site.** Only 3 of
~12 ZapDialog call sites were passing `recipientPubkey` +
`profileLookup` in the rewrite commit — the row hid silently
on the other 9. Wired the rest:
• Navigation.kt — search, hashtag feed, set feed, article,
live stream (uses streamer override pubkey when set),
notifications (post + DM target).
• FeedScreen — zap-poll target.
• UserProfileScreen — post zap (eventRepo lookup) AND
profile-direct zap (embedded profile shortcut).
• DmConversationScreen — uses the peerProfile already in
scope.
Two bugs preventing the NIP-78 app-settings backup from restoring
from a relay-side payload published by iOS:
1. **Collector dropped every relay reply.** restoreSettingsBackup
was launching its `relayEvents` + `eoseSignals` collectors on
the repo's own `Dispatchers.Default` scope, then calling
`yield()` on the suspending function before firing the REQ. The
yield only dispatches the calling coroutine — the collectors on
the separate scope had no guarantee of being subscribed yet, so
the first batch of relay replies (which arrive ~3-4s later, way
after the collector "should" be live) reached a
`MutableSharedFlow(extraBufferCapacity = 4096, replay = 0)` with
no subscribers and got dropped on the floor. Result: events = 0,
"no matching d-tag event found", even though SUBLOG showed
1-2 events arriving per relay.
Wrap the body in `coroutineScope { ... }` so the collector
launches are children of the calling coroutine. yield now
actually dispatches them before sendToAll fires the REQ. iOS
payloads start arriving correctly.
2. **`accentColorARGB` overflowed Kotlin Int.** iOS encodes the
ARGB color as Swift `Int` (64-bit), so `0xFFFF9800`
(4_294_940_672 unsigned) ships as a JSON number that
kotlinx.serialization can't deserialize into the Kotlin `Int?`
we declared — `Int.MAX_VALUE` is 2_147_483_647. Decryption
succeeded, JSON parsing died.
Declare the field as `Long?`; in `applyPayload`, narrow with
`it.toInt()` (the lower 32 bits round-trip correctly even when
Int reads the value as negative). When building the payload to
publish, widen with `getAccentColor().toLong() and 0xFFFFFFFFL`
so iOS reads it as a non-negative number on the round-trip.
Diagnostic logging on the restore path stays in — useful next
time something goes sideways. Bumped the silent swallow in
`Nip78.decryptAppSettings` to a Log.w so future
serialization-class mismatches surface immediately.
Match iOS PR 159's AppSettingsPayload byte-for-byte so a kind-30078 backup round-trips across platforms without losing iOS-only fields. New AppSettingsPayload fields: defaultReaction, defaultReactionEnabled, quickReactions, frequency, colorScheme, animateAvatars. Fields with no Android UI yet are stored opaquely via InterfacePreferences setters so the next Android publish doesn't strip them out. CustomEmojiRepository now exposes onSyncedFieldChanged + an applyQuickReactions helper. add/remove/setUnicodeEmojis and recordEmojiUsage fire the sync callback so emoji-list mutations participate in the same 4s debounced publish as the other prefs sources. AppSettingsRepository takes customEmojiRepo as a constructor arg and wires the callback alongside interfacePrefs / fiatPrefs / zapPrefs.
…ts, long-press instant zap Ports the iOS one-tap-zap composer + preset model to Android. ZapDialog redesign - Hero amount is the editable input (BasicTextField with a thousands- separator VisualTransformation). First-keystroke-replaces-seed is preserved via TextRange(0, n); LocalTextSelectionColors overrides the selection background to transparent so the seeded select-all doesn't paint an ugly box over the orange number. - Removed the redundant inline "Custom (sats)" OutlinedTextField — the hero IS the input now, matching iOS. - Sheet locks to fillMaxSize so it opens at full height immediately; prevents the stagger where the sheet jumped taller 450ms in when auto-focus raised the keyboard. - EditPresetsSheet replaces SaveZapPresetDialog. Modal bottom sheet titled "Edit Presets" with a Done pill, per-row swipe-to-delete via SwipeToDismissBox (iOS-red #FF3B30 panel, trailing edge only), and a "+ Add preset" row that disables itself while a blank entry exists. Per-account preset routing - ZapDialog now requires zapPrefsRepo: ZapPreferences. The previous in-dialog ZapPreferences(context) wrote to the un-scoped "zap_prefs" file, while AppSettingsRepository read from "zap_prefs_<pubkey>" — preset writes from the "+" chip never reached the NIP-78 publish path and restored presets never showed up in the dialog. All 14 callsites (Navigation x9, FeedScreen x2, UserProfileScreen x2, DmConversationScreen x1) now pass feedViewModel.zapPrefs. - One-shot migration in ZapPreferences copies any pre-existing global zap_prefs entries into the per-account file on first read, marked with migrated_from_global_v1 so it never repeats. Long-press instant zap - ActionBar's zap glyph uses combinedClickable: onClick opens the composer, onLongClick fires the instant zap. LocalHapticFeedback.performHapticFeedback(LongPress) on the long-press latch so the user feels confirmation before the network round-trip. - NoteActions gains onZapInstant (defaults to onZap so existing callers fall through to the composer). Plumbed through PostCard's onZapLongPress at every callsite — Navigation, FeedScreen (FeedItem also picked up onZapLongPress), ThreadScreen, NotificationsScreen, ArticleScreen, SearchScreen, UserProfileScreen. Each instant callback reads interfacePrefs and fires sendZap immediately when isQuickZapEnabled, else falls through to opening the composer.
…eanup Post-connect navigation - When isConnected flips true while currentPage is NwcSetup or SparkSetup, clear the back stack and set currentPage = Home. Fixes the "Wallet" TopAppBar showing over the connected NWC dashboard because the setup screen lingered until the user navigated away. Switch / Disconnect entry on Wallet Settings - iOS-style Card row with SwapHoriz icon + "Switch to a different wallet" in #FF3B30 for both recoverable cases (default Spark and NWC). Section header "Disconnect Wallet" above, explanatory caption below. The destructive non-default Spark delete keeps the filled-red Button because the seed can't be re-derived from nsec — losing it is irreversible. - Confirmation page unified on #FF3B30 across all three flows (icon halo, title, CTA, and "back up first" warning) instead of branching between Material primary orange and Material darker red #D32F2F. - Switching from a default Spark wallet routes back to the wallet-mode picker (Home + NotConnected → renders WalletModeSelectionContent) rather than dropping the user on the Spark sub-screen. NWC entry - QR scan dialog reachable from the connection-string field's trailing icon. Reuses the existing QrScanner component; success populates onConnectionStringChange so paste + scan are both available. NWC dashboard - Lightning-address pill rendered for any wallet mode that carries a lud16 (Spark via Breez SDK, NWC via parsed URI). Removed the redundant "Nostr Wallet Connect" footer below the balance — the in-page top row already brands the connection (NWC logo + node alias). - Adaptive recent-tx count via LocalConfiguration.screenHeightDp (5 / 4 / 3 / 2 rows by tier) so smaller phones don't crowd out the balance + Send/Receive controls. Wallet Settings polish - WalletInfoRow uses a fixed-width label column (widthIn min 110dp, trailing padding) and right-aligned values with maxLines = 1 + TextOverflow.Ellipsis so long relay URLs / lightning addresses truncate cleanly and labels align across rows. - Backup-to-relay now offered for default Spark wallets too — matches iOS, gives users belt-and-braces durability beyond the nsec. Strings + docs - wallet_switch_wallet copy expanded to "Switch to a different wallet"; new wallet_disconnect_section + wallet_switch_wallet_caption. - WALLET_PARITY.md §11.2 checklist items marked done for the parity work landed here and in the preceding two commits.
The connection-string OutlinedTextField + Connect button used to live under the 5-row Recommended list, which on smaller phones put them below the fold — paste/Connect required scrolling past the wallet suggestions every time. Reordered so the most-common action (paste an existing nostr+walletconnect:// URI) sits in the upper half of the viewport, with the wallet suggestions kept as a secondary "if you don't have one yet" affordance below.
Follow-up to barrydeen#556. iOS hides the cross-device-sync toggle and exposes the sync only through a manual "Restore from relays" affordance — this PR brings Android to that same layout. Repo side - Drop the `interfacePrefs.isSyncSettingsToRelays()` gates in `scheduleSettingsSync()` and `restoreSettingsBackup()` — sync is now always on while a signer is bound. - `restoreSettingsBackup()` returns `Boolean` (true = snapshot was found and applied, false = nothing on relays / repo not ready) so the UI can report the outcome. UI side - `InterfaceScreen`: replace the "Cross-device sync / Sync settings to relays" `Switch` block with a description + `Restore from relays` `Button` matching the iOS design. Section is positioned just above the Developer row (last section before the developer tools entry). - New `onRestoreRequested: (suspend () -> Boolean)?` callback wired by `Navigation` to `feedViewModel.appSettingsRepo.restoreSettingsBackup()`. The unused `InterfacePreferences.isSyncSettingsToRelays()` / `setSyncSettingsToRelays()` accessors are left in place for now — they default to true, so nothing reads them, and keeping them avoids a schema migration when this lands.
23bb75f to
b3be2a2
Compare
|
Rebased the branch onto
PR now merges cleanly into the integration branch. Once #556 lands, this becomes a small standalone PR against |
|
same thing here this is a massive pr touching the whole app, and not sure why it's needed? |
The NIP78 settings feature gives the user the same experience across iOS and Android, but it may be overkill for this phase. We can defer these updates and create smaller targeted PRs for the UI edits. |
|
Deferring this work to a future phase per discussion above. The NIP-78 cross-device sync of Interface / Wallet / Zap preferences is a substantial cross-platform contract (Android + iOS together) and is more scope than we need for this milestone. Will be tracked in a dedicated issue along with the iOS counterpart (barrydeen/wisp-ios#173) and the dropped NIP-78 commits from #556. Closing this PR once the issue is filed. In the meantime, quick-zap settings will land as plain local SharedPreferences via the slim PRs carved out of #556 (see #570 and the queue described there). |
|
Closing — the UI pieces from this branch were carved into the slim-PR series #570 – #576. The actual NIP-78 cross-device sync infrastructure (kind 30078 + NIP-44 publisher/subscriber/restore-from-relays) is deferred to a future phase tracked in #579. Per discussion in #564 (comment) context (the "massive PR touching the whole app" feedback), separating the UI from the sync transport turned out to be the right call — the UI shipped without blocking on the cross-platform schema coordination that NIP-78 sync needs. iOS companion: barrydeen/wisp-ios#173 is being closed in parallel for the same reason. |
Mirrors iOS barrydeen/wisp-ios#173 to give Wisp the second half of the cross-device settings sync. With both PRs in place, an encrypted snapshot of the user's Interface-screen preferences round-trips iOS → Android and Android → iOS through the same NIP-78 addressable event under d-tag
wisp-app-settings:v1.Refs barrydeen/wisp-ios#70 — the issue closes when both this and the iOS PR ship.
Summary
Nip78.AppSettingsPayload— Kotlinx-serializable data class with the locked schema fromNIP78_SETTINGS_SYNC_PARITY.md. All-optional fields,versionfor forward-compat, no schema bump on additions. Companion helpers:createAppSettingsEvent(JSON encode → NIP-44 self-encrypt → sign kind-30078),decryptAppSettings,appSettingsFilter,APP_SETTINGS_D_TAGconstant.AppSettingsRepository(new) — orchestrates the publish + restore lifecycle.scheduleSettingsSync()— 4 s debounced publish; cancels prior in-flight, so the most recent change wins.publishSettingsNow()— immediate publish, used by tests / future "sync now" affordances.restoreSettingsBackup()— first-launch-per-pubkey-per-device, gated on a per-pubkeywisp_settings_restored_{pubkey}flag inSharedPreferences(separate file from the regular interface prefs so the flag survives prefs resets).restoreFromRelays()— manual restore, used by the "Restore from relays" button; always runs.applyRestored()+isApplyingRestoredPayloadsuppression flag — prevents the apply from echo-looping back out as a publish of the same payload.CoroutineScope(SupervisorJob() + Dispatchers.Default)for the debounce job.InterfacePreferences— no master toggle; sync is always-on. The data is NIP-44 self-encrypted to the user's own pubkey, so an opt-out wasn't earning its keep.InterfaceScreen— new "Cross-device sync" section with a full-width primary Material Button ("Restore from relays") and a status line below it.onCheckedChange/onClick:largeText,theme(matches iOSthemeName),accentColor(matches iOSaccentColorARGB).autoLoadMedia,videoAutoPlay,mediaLayoutStyle.clientTagEnabled,postUndoTimerEnabled,postUndoTimerSeconds,postUndoTimerForReplies.fiatMode,currency,zapBoltIcon(matches iOSzapIconStyle).FeedViewModel— constructs the repository at init withinterfacePrefs,FiatPreferences.get(app), andzapPrefs; assignsrelayPool; binds / clearssigneralongside the rest of the signer plumbing.StartupCoordinator— callsappSettingsRepo?.restoreSettingsBackup()once relays are warm (both on initial connect and after an account resume); callsreload(pubkey)on account switch.Navigation— wiresonSyncRequestedandonRestoreRequestedintoInterfaceScreen; collects fromappSettingsRepo.prefsRefreshedand re-firesonInterfaceChangedso MainActivity's cachedmutableStateOf(interfacePrefs.getX())values repaint when the repo writes prefs out-of-band.Account-change handling
AppSettingsRepository.signer— on any post-first-bind change (account switch viaclearSigner+setSigner, or sign-out viaclearSigner), resets the synced fields to defaults so the prior account's prefs don't leak, then force-pulls the new account's snapshot. Force-pull bypasses the per-pubkey flag since that flag's "trust local prefs" invariant only applies for the same account.restoreSettingsBackup()'s per-pubkey flag. Bypassing it would burn relay traffic for no benefit.prefsRefreshedSharedFlow — emits afterresetSyncedPrefsToDefaults()andapplyRestored().WispNavHostcollects from it viaLaunchedEffectand callsonInterfaceChanged()so MainActivity rebinds its Compose state caches. Without this signal, the cachedthemeName/accentColorvalues stayed at the prior account's values even though the underlying SharedPreferences had been reset.WispTheme()with all-defaults so the sign-in surface always renders with the orange Wisp accent, regardless of what the prior account had configured.Wire-format mapping
The wire schema is locked across iOS + Android (see
NIP78_SETTINGS_SYNC_PARITY.md). Android-internal forms that diverge from the wire format get converted at the JSON boundary so existing on-device pref names stay stable:MediaLayoutStyle.GALLERY(key"gallery")"grid"MediaLayoutStyle.STACK(key"stack")"stack"isZapBoltIcon = false"bitcoin"isZapBoltIcon = true"bolt"theme(string)themeName(string)accentColor(int)accentColorARGB(int)iOS-only fields (
colorScheme,animateAvatars) are accepted on the wire but skipped on apply since Android has no equivalents yet — missing fields leave local defaults in place, so a future Android version can add them without a schema bump.Out of scope (device-local, intentionally not synced)
newNotesButtonHidden,liveStreamsHidden,autoTranslate,language— stay device-local.Test plan (verified end-to-end on physical device + iOS sim)
AppSettingsRepository: publish: sent to 5 relay(s)~4 s later.