A kind-1 note embedding a nostr:naddr reference to a music track (kind
36787) previously fell through to the generic "Unsupported event kind"
placeholder. This adds a dedicated, playable card.
- RichContent.kt: dispatch kind 36787 to a new MusicTrackCard composable
that resolves the addressable event via the existing kind-generic
findAddressableEvent/requestAddressableEvent path and renders artwork,
a MUSIC chip, title, artist, and an author row.
- The play/pause control is wired into the global AudioPlayerController,
so playback survives row recycling and drives the floating mini-player
plus lock-screen/notification controls. Shows a live scrubber while it
owns the player, the static duration tag otherwise.
- EventPersistence.kt: persist kind 36787 so seen tracks resolve
instantly on warm restart.
Ports wisp-ios #378 to Android. Single tracks only; the music-tracks
collection kind (30817) stays on the generic placeholder.
Ports two performance fixes from the dark-wisp-android fork (PRs #34, #23).
EventRepository: maintain a parallel filteredFeed incrementally instead of
re-filtering the entire feedList (up to 5,000 events) on every 50ms publish
window. Membership is decided once at insert time via a single passesFilter()
predicate; the consumer loops now just snapshot the maintained list. The full
O(n) pass survives only in rebuildFilteredFeed() for the rare filter change.
Inserts that don't change the filtered view no longer trigger an emission,
avoiding redundant Compose recompositions during inbound bursts.
NotificationRepository: hoist regex-heavy spam classification out of
synchronized(lock). addEvent now calls warmSpamScore() before the lock, which
applies the same gating as mergeReply and populates SpamAuthorCache; inside the
lock mergeReply only consults the cache. Prevents the main thread from blocking
past the 5s input-dispatch ANR deadline during notification bursts.
Opening a group room or DM whose visible messages contained a media
carousel crashed with:
IllegalStateException: Asking for intrinsic measurements of
SubcomposeLayout layouts is not supported.
The message-bubble content Column used Modifier.width(IntrinsicSize.Max),
which forces an intrinsic-width measurement of every child. One child is
RichContent, which can render MediaCarousel (a BoxWithConstraints +
HorizontalPager). Those are SubcomposeLayout-based and cannot answer
intrinsic queries, so the measure pass threw.
Remove IntrinsicSize.Max from the group and DM bubble columns and let
the bubble hug its content. The received-message header row no longer
fills width (which would otherwise balloon every bubble to max width
without the intrinsic sizing); the sender name uses widthIn(max) so long
names still ellipsize.
eventRelays stored per-event relay URLs in plain LinkedHashSet instances
(mutableSetOf). addEventRelay mutates these live sets from relay IO threads
as events stream in, while getEventRelays returns the same live set that UI
coroutines and getRelayHintsForEvents iterate on the Main dispatcher. A
concurrent add mid-iteration threw ConcurrentModificationException
(LinkedHashMap$LinkedKeyIterator.next).
Use ConcurrentHashMap.newKeySet(), whose iterator is weakly consistent, so
concurrent adds during iteration no longer throw. This matches the existing
pattern already used for the sibling repostAuthors cache.
The "Uploading…" label was crammed into the compose toolbar's icon row
alongside six icon buttons, leaving no horizontal space — so the text
wrapped to three lines ("Uplo / ading / …"). This pulls the spinner +
label out of the icon row onto a dedicated line below it, wrapped in a
rounded surfaceVariant capsule with maxLines = 1 so it can never wrap.
It animates in/out with the upload lifecycle.
Mirrors barrydeen/dark-wisp-android#38.
InlineVideoPlayer and InlineVideoPlayerWithFullscreen created a fully
prepared ExoPlayer per video URL the moment it entered composition,
with no cap or visibility gating. A note carrying many video URLs
(such as a 155-video note seen in the wild) instantly spawned a
player per URL -- 156 players / 487 player threads measured upstream,
exhausting hardware codec instances (~16-32 device-wide) and memory
until input dispatch timed out (ANR).
The player now only exists while its video is near the viewport:
created when >50% visible with autoplay on, or on tap (which starts
playback immediately); released with its position remembered once the
video scrolls fully off-screen. Until then the slot renders the
uploader-provided NIP-92 imeta "image" preview frame, falling back
to the existing thumbhash/blurhash painter. parseImetaTags now parses
the "image" entry, and MediaCarousel video tiles use it too instead
of a blank box when no thumbhash/blurhash is present.
Port of barrydeen/dark-wisp-android#31.
NotificationRepository.addEvent ran NSpamClassifier.score() inside
synchronized(lock) via mergeKind1 -> mergeReply. Feature extraction
runs several regex passes over up to ten notes per author; doing it
under the lock stalls every caller -- including main-thread callers
like getAllPostCardEventIds() and markRead -- past the 5s
input-dispatch deadline.
Fix: warmSpamScore(event) runs before the lock, computes the score
under the same spamFilterEnabled/isFollowing/safelist/reply-target
guards, and caches it in SpamAuthorCache (LruCache-backed, thread-
safe). mergeReply now only consults the cache.
Port of barrydeen/dark-wisp-android#23.
Non-debuggable + R8-minified like release (initWith(release)), but
debug-signed so anyone can install it without the release keystore.
applicationIdSuffix ".staging" and app name "Wisp Staging" let it
install alongside debug and release builds.
debuggable=true makes ART ignore the baseline profile and run
JIT-only with deoptimization support, which cripples the per-event
hot path (JSON parse, hex decode, SHA-256, JNI Schnorr verify per
relay event) -- so debug builds can't be used to judge real
performance.
Port of barrydeen/dark-wisp-android#24.
A self-payment surfaces as two transaction records sharing one payment
hash — one outgoing, one incoming. Port the iOS WalletStore.dedupTransactions
hardening so they render correctly:
- Add dedupTransactions(): drop exact (paymentHash, type) repeats the
backend returns, and sort newest-first with the incoming "received" leg
above its outgoing "sent" leg on a timestamp tie. Apply on initial load,
re-enrich, and load-more.
- Key the transaction LazyColumn by "paymentHash|type" so the two legs of a
self-send keep distinct identities and both render (mirrors iOS
WalletTransaction.id).
Six wallet string keys (wallet_connect_wallet, wallet_choose_how,
wallet_create_new, wallet_create_description, wallet_nwc_description,
wallet_spark) were renamed in the default locale but left behind in
10 translation files, causing 60 ExtraTranslation lint errors that
failed lintVitalRelease.
The WoT filter was already silently dropping events at EventRepository
ingestion and in NotificationRepository, but thread replies bypassed
both paths — ThreadViewModel keeps its own threadEvents map seeded via
cacheEvent() and getCachedThreadEvents(), neither of which checks WoT.
Apply isWotFiltered in rebuildTree() alongside the existing block /
mute / spam filters. The root note is always shown (user explicitly
navigated to it); replies from authors outside the qualified network
are silently dropped, matching how WoT behaves in the feed and
notifications.
Replaces the orange numpad-style Receive screen with a form layout matching
iOS ReceiveInvoiceSheet:
- AMOUNT label + single text field (numeric keyboard, "0" placeholder,
"sats" or fiat suffix). In fiat mode shows the live sats conversion
below the field.
- NOTE (OPTIONAL) label + single-line text field with "For coffee, etc."
placeholder — plumbed through generateInvoice(amountSats, description)
so the note is embedded in the BOLT11 invoice description.
- Create invoice full-width button; disabled (gray) until the amount
parses to > 0 sats.
- Invoice / Lightning Address segmented control at the top, shown only
when the user has a Spark lightning address. Switching to the address
tab renders the address QR + Copy / Share row inline (no nav churn).
Touches:
- WalletScreen.kt: rewrites ReceiveAmountContent; adds ReceiveAddressBlock.
Uses BasicTextField for the hero amount field (custom placeholder
inside a rounded surface) and a second BasicTextField for the note.
- WalletViewModel.kt: generateInvoice now takes (amountSats, description);
adds setReceiveAmount(value) for the new text-input path. The existing
digit-by-digit updateReceiveAmount / receiveAmountBackspace stay
untouched (still used by Send numpad).
- strings.xml: new English strings for the labels / placeholders / CTA.
Translations to follow.
Tested on device.
Ports iOS commit #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.
iOS doesn't surface a per-relay backup status display in the wallet
settings screen, and the section is overkill for the Android side too —
the majority of users start with the default Spark wallet (which never
shows the relay-backup affordance at all, because the nsec is the
canonical backup), and the remaining minority on non-default Spark
wallets get the "Backup to Nostr Relays" button alongside the Recovery
Phrase row without needing a per-relay status card to interpret.
Removes the entire status block: the "Relay Backup Status" header +
refresh icon, the relay-URL + green/grey dot card, the "Delete Relay
Backup" TextButton + status messages, and the confirmation dialog.
The "Backup to Nostr Relays" button stays — non-default wallets can
still push the encrypted backup to relays, we just don't surface the
fine-grained per-relay state inline.
WalletViewModel plumbing (`relayBackupStatuses`, `relayBackupCheckLoading`,
`checkRelayBackupStatuses()`, `deleteRelayBackup()`) is left in place
deliberately. The status check + delete actions are still wired through
the WalletScreen parameter list so other entry points (or a future
debug-only view) can use them, and ripping the ViewModel state out
would balloon this diff into a refactor.
The default Spark wallet derives from the user's nsec, so the nsec
already serves as the cross-device backup — the "Backup to Nostr
Relays" affordance is redundant and noisy alongside the equivalent
nsec-based recovery path. Recovery Phrase view is unchanged.
Hides the relay backup button + status section for default wallets;
non-default wallets keep both, since they have no nsec-derived
fallback.
Mirrors iOS — title shortens from "Your default wallet is secured
by your key" (wrapped to two lines on most phones) to "Secured by
your Nostr key", and the body drops the redundant "Derived from
your Nostr key —" prefix now that the title carries the same idea.
Net effect: same information, one-line title, less wall-of-text
under it.
Two related issues on the Spark wallet banner state:
1. After restoring the nsec-derived default wallet via NIP-78,
no banner showed at all. The "Your default wallet is secured
by your key" welcome banner is gated on
`isDefaultWallet && !seedBackupAcked`, and `seedBackupAcked`
was sticky from a prior generateDefaultFromPrivkey call (or
prior tap-through) — so a returning user saw nothing.
2. restoreSparkWallet hard-coded `_isDefaultWallet.value = false`
regardless of what was restored. Users restoring their default
nsec-derived seed silently lost the "default wallet" surface
(banner, non-nag delete confirmation, NIP-78 publish gate).
Fixes:
- SparkRepository.saveMnemonic now clears the `seed_backup_acked`
prefs key. Mirrors iOS SparkWallet.saveMnemonic which clears the
equivalent UserDefaults key — every mnemonic save is treated as
a fresh wallet that needs its own acknowledgement.
- SparkRepository.generateDefaultFromPrivkey no longer auto-acks
the seed backup. Routes through saveMnemonic for consistency.
Matches iOS's behaviour of leaving the welcome banner visible
until the user taps through.
- WalletViewModel.restoreSparkWallet now sets `_isDefaultWallet`
via `computeIsDefaultWallet()` instead of hard-coding false —
restoring the nsec-derived seed correctly resolves to true.
- WalletViewModel.startDefaultWallet drops its `_seedBackupAcked
= true` auto-set. Banner now shows after default generation
like iOS.
- clearWalletDisplayState also resets `_seedBackupAcked` to false
so the ViewModel-side StateFlow doesn't carry the previous
wallet's acked value across a mnemonic swap.
The transactions list, lightning address, and pagination state were
cached on the WalletViewModel and never reset when the underlying
Spark mnemonic was replaced (restore from backup, paste a new seed,
swap to default, disconnect, delete). The user saw the previous
wallet's transactions briefly — or persistently if the new wallet's
first fetch hadn't completed yet — sitting above the new wallet's
balance / address.
Add a private `clearWalletDisplayState()` helper that wipes the
per-wallet ViewModel-side display state (transactions, transactions
error, hasMoreTransactions, lightningAddress + its error/loading
flags). Mirrors iOS `WalletStore.clearDisplayState`, called from
every mnemonic-replace and disconnect path:
- generateSparkWallet (new mnemonic)
- restoreSparkWallet (paste / NIP-78 restore)
- useDefaultWallet (switch to nsec-derived default)
- disconnectWallet (logout-style disconnect)
- deleteWallet (the destructive path)
- suspendForAccountSwitch (account swap mid-session)
The repo-side balance flow already clears on clearMnemonic; only the
ViewModel-side display state needed explicit clearing.
Mirrors iOS barrydeen/wisp-ios#175. The Wallet screen kept showing
the "default wallet is secured by your key" banner even after the
user restored a non-default Spark wallet from a NIP-78 backup.
That banner's claim ("derives from your Nostr key — restores on
any device") is false for any restored wallet whose seed isn't the
deterministic nsec-derived mnemonic, so the user saw a misleading
"you're backed up" affordance over a wallet that actually needs a
manual seed-phrase backup.
Root cause: SparkRepository.isDefaultWallet() returned a sticky
encPrefs flag (`spark_is_default`) that was set by
generateDefaultFromPrivkey when the initial nsec-derived wallet
was created and never cleared on the restore path. saveMnemonic
overwrote the keychain mnemonic without touching the flag, so the
banner kept rendering against the new (non-default) seed.
Replace the flag with a computed check: compare the currently-saved
mnemonic against entropyToMnemonic(Keys.deriveSparkEntropy(privkey))
for the active account. The flag becomes unnecessary and is dropped
— generateDefaultFromPrivkey no longer writes it, clearMnemonic no
longer removes it. The `spark_is_default` encPrefs entry becomes
orphaned data; safe to leave as-is and ignore on future loads.
Call sites:
- WalletViewModel gains a `computeIsDefaultWallet()` private helper
that resolves the active keypair via keyRepo and delegates to the
new SparkRepository.isDefaultWallet(privkey:). Used everywhere the
ViewModel needed the answer (StateFlow init, settings-page backup
status check, delete-confirmation gate, post-connect backup check,
refreshState).
- OnboardingViewModel's non-default-backup-publish gate decodes
keyRepo.getKeypair()?.privkey and passes it through.
Watch-only accounts (no privkey) fall through to false naturally.
Previous attempt computed total chrome height as
`Modifier.height(content + insetReadViaPaddingValues)`. The inset is
read at composition time and arrives as 0 on the very first frame
before the system-bar inset connection delivers its value — the bar
laid out at the shorter (no-inset) height, then re-measured once the
inset arrived. Visible as a one-frame snap on app cold start.
Switch to a layout-time pattern that subscribes to inset changes
correctly:
Modifier
.windowInsetsPadding(insets) // reserves the inset via padding
.height(contentHeight) // content area only
`windowInsetsPadding` is a Modifier.Node that re-layouts (not re-
composes) on inset arrival, so the bar measures at the right total
height on the first frame. The bar's own `windowInsets` is set to
`WindowInsets(0)` so it doesn't double-pad.
Applied to:
- `BottomBar` NavigationBar — content height 56dp + navigation-bars inset
- `FeedScreen` CenterAlignedTopAppBar — content height 48dp + status-bars
inset
Two more iOS-parity tweaks:
- `WispTheme` sets `error = #FF3B30` (and `onError = white`) explicitly
on every color-scheme variant (custom dark/light + preset dark/light).
Material 3's defaults for `error` render pinkish in dark mode and a
muted brick red in light mode — neither matches the iOS systemRed used
by the rest of the destructive UI in this app. With this override,
every `MaterialTheme.colorScheme.error` consumer (logout button,
destructive labels, error text) now matches the iOS counterpart and
the existing #FF3B30 used directly on Disconnect/Switch wallet flows.
- `UserProfileScreen` sticky-header tab strip + the sort-pill row below
it use `background` (#0A0A0B) instead of `surface` (#1C1C1E). The two
grey tiers stacked above each other read as visually noisy on the
profile; the iOS profile uses one near-black across both. Body posts
below still render with the elevated tier where they need to.
Two post-card refinements that move the feed toward the iOS look:
- `PostCard` now wraps content + the inter-post `HorizontalDivider`
in an outer Column. The content Column keeps its 16dp horizontal
padding (so post body / action bar / metadata stay inset), but the
divider sits outside that padding and runs edge-to-edge. Matches
iOS where the separator spans the full viewport width.
- `ActionBar` gates each of the four counters (`replyCount`,
`likeCount`, `repostCount`, `zapSats`) on `> 0`. Empty engagement
no longer shows "0" beside the icon — matches iOS where the
count text only appears when there's something to show. As soon
as the count crosses 1, the number reappears.
iOS-style cleanup on the home screen's top + bottom chrome:
- `FeedScreen` `CenterAlignedTopAppBar` clamps to 48dp content +
status-bar inset (was Material's default ~64dp + inset). Drops the
gap below the icon row that pushed the feed down.
- `BottomBar` `NavigationBar` clamps to 56dp content + gesture inset
(was Material's default 80dp). The 80dp slot reserves space for the
label text we don't render — pure waste on small phones.
- Tab indicator pill is suppressed (`indicatorColor = Color.Transparent`).
The selected-icon orange tint is enough signal; matches iOS where
the tab bar has no rounded background on the active tab.
- Notification dot uses iOS systemRed (#FF3B30) instead of the app's
primary accent so it reads as "alert" rather than "branded highlight"
— same red iOS shows on the bell.
- Filter icon for "All" content types switches from
`Icons.Outlined.Dashboard` (1 large + 3 small panels) to
`Icons.Outlined.GridView` (2x2 of equal squares) to match the iOS
toolbar icon.
Default `TopAppBarDefaults.topAppBarColors` uses `MaterialTheme.color
Scheme.surface`, which sat noticeably lighter than the body after the
preceding dark-mode background darken. iOS uses one near-black across
body + chrome and reserves the lighter "surface" tone for elevated
controls (pills, cards). Switch every screen's TopAppBar container
to `background` so chrome reads as part of the page, not as a raised
layer above it.
30 screens touched; only `containerColor` lines inside
`TopAppBarDefaults.topAppBarColors(...)` blocks are changed, so other
surface usages (cards, dialogs, sheets, the elevated pills the home
top bar overlays) keep their existing tone.
The default ("custom") Android dark theme rendered noticeably lighter
than iOS, which uses near-black backgrounds. Align with iOS HIG dark
system colors (slight off-black for the base, iOS secondary/tertiary
greys for elevated surfaces) so the two platforms feel like the same
app in dark mode.
- background: #131215 → #0A0A0B (slight off-black, OLED-friendly
without the harsh #000 step on LCD)
- surface: #1F1E21 → #1C1C1E (iOS secondarySystemBackground)
- surfaceVariant: #2B2A2E → #2C2C2E (iOS tertiarySystemBackground)
- outline: #343338 → #38383A (iOS separator on dark)
Named presets (Nord, Dracula, Gruvbox, …) are left untouched — their
distinctive backgrounds are part of each preset's identity.