Parsed width/height from the "dim" tag could be zero or negative,
crashing the decoder. Clamp to positive values and bail out early
in both decoders as a defense-in-depth backstop.
Bump version to 1.2.1 (versionCode 4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The bare `wallet_spark` string existed in all 10 translated locales but
not in the default locale and is not referenced in code (only
wallet_spark_title/_subtitle_recommended/_sdk are used). It tripped
lint's ExtraTranslation check, failing lintVitalRelease/Staging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Users can publish payment addresses for other cryptocurrencies and
payment apps (payto tags per draft NIP-A3) and pay other users
through them.
- NipA3.kt: parse/build payto tags, type validation, payto:// and
native wallet URIs, recognized-type stylization
- PaymentTargetRepository: LRU + SharedPrefs cache per pubkey;
stores empty lists so cleared targets propagate
- ZapDialog: "Other ways to pay" chip section; targets-only dialog
when lightning zapping isn't possible but targets exist
- PaymentTargetSheet: QR, copy, open-in-wallet bottom sheet
- Wallet settings: manage and publish own targets with network
read-back before editing to avoid clobbering the replaceable event
- Profile screen: targets shown under the lightning address
- On-demand fetch only (zap dialog / profile open), ingested via
EventRouter with created_at freshness guard
Add an embedded Tor daemon (kmp-tor) with an onion toggle in the sidebar
drawer and on the pre-login Splash/Auth screens. When enabled, every
connection — relay WebSockets, Coil images, ExoPlayer media, NIP-05/NIP-11,
LNURL/zaps, Blossom uploads, link previews, exchange rates, DM media, live
metrics — routes through Tor's SOCKS proxy.
Routing is fail-closed: from the moment the toggle flips, new clients point
at a dead loopback port until bootstrap completes, and existing clients have
their in-flight requests cancelled and keep-alive pools evicted so no
pre-toggle socket survives. OkHttp passes unresolved hostnames to SOCKS
proxies, so DNS also resolves inside Tor.
- TorManager: kmp-tor runtime in a foreground service; state machine
(Off/Starting %/On/Stopping/Error) observed by all toggles; start retries
around kmp-tor's fixed 1s service-bind timeout, which a busy main thread
misses during cold start
- TorPreferences: device-level pref so the toggle works pre-login and
survives logout
- HttpClientFactory: single proxy choke point; client registry +
setTorSocks() invalidation; new getRelayClient/getLoopbackClient/
getDmMediaClient
- Relay/RelayPool: clients resolved through providers on every (re)connect;
suspendForTorSwitch/resumeAfterTorSwitch tear down sockets and suppress
reconnects during bootstrap so the 5-min relay cooldown can't trip; local
relay stays direct (Tor refuses loopback/LAN targets)
- Captured-client fixes so a mid-session toggle can't leak: Coil delegating
Call.Factory (it caches its lambda forever), ZapSender, DmBubble,
NwcRepository, SplashViewModel raw client, ExchangeRateRepository refetch
- Cold start with Tor on: relay init gated on bootstrap
- kmp-tor pinned to 2.4.x: 2.5+ needs Kotlin 2.2 metadata, unreadable by
this project's Kotlin 2.0.21
Verified on-device by socket-inode audit: app process held 43 sockets, all
to 127.0.0.1:<socks>; only the tor child process held external connections.
Known gaps: Breez/Spark SDK (own Rust networking) and ML Kit model
downloads bypass OkHttp and are not routed.
Generalizes the private-event marker (markPrivateReply/isPrivateReply →
markPrivate/isPrivate) and re-enables React and Zap on private replies.
Reactions on a private reply are kind 7 rumors gift-wrapped to every
thread participant (the k tag distinguishes k=1 reply reactions from
k=14 DM reactions); PrivateReactionPublisher mirrors the reply publisher
with per-recipient relay resolution and a self-copy. Zapping a private
reply locks the ZapDialog to DIP-03 private mode (forcePrivate) so a
public zap can never attach an e-tag to the rumor id, and sendZap forces
the flag server-side as a defensive guard. Repost/Quote stay hidden.
Dark Wisp addition over the upstream PR: PrivateRumorHandler gains
handlePrivateReaction and the remote-signer pending-decrypt paths route
k=1 reaction rumors through it, matching the EventRouter behavior.
Replaces the relays-tag heuristic with DIP-03: the real sender signs an
inner kind 9733 event, NIP-04-encrypted into the outer kind 9734's anon
tag using an ephemeral key derived as sha256(privkey + eventId +
createdAt). Relays and the LNURL provider only see the unlinkable
ephemeral pubkey; receipts route through both parties' DM relays.
EventRepository.resolveZapSender() decrypts incoming private zaps (as
recipient) and re-derives the ephemeral key to self-attribute our own
outgoing ones, so notifications, zap lists, and wallet history show the
real counterparty. Private zaps require a local keypair, so the UI gate
adds hasLocalKeypair; live-stream (addressable) zaps can't derive the
ephemeral key and stay public.
Kind 1 rumors inside kind 1059 gift wraps, delivered to the recipient's
kind 10050 DM relays (NIP-65 read relays as fallback). PoW is mined on
the rumor so the badge survives unwrapping; a self-copy wrap keeps other
devices in sync; optimistic local insert updates reply counts at once.
Compose gains a private-reply toggle that auto-enables and locks when
replying to a private reply; thread and notifications show a lock icon
and hide repost/quote/react/zap on private replies.
Dark Wisp addition over the upstream PR: private reply rumor handling is
extracted into PrivateRumorHandler and wired into the remote-signer
pending-decrypt paths (DmListViewModel/DmConversationViewModel), which
upstream dropped along with NIP-55 support — without this, gift-wrapped
replies would be misfiled as DM messages for remote-signer accounts.
Fully revert f68b40f, which shipped in 1.0.2 alongside reports of severe
slowdown and freezes on GrapheneOS:
- jniLibs.useLegacyPackaging back to true: compressed native libs cut the
download from 82MB to 37MB, and uncompressed 16KB-aligned packaging is
only needed for Google Play, which this build no longer targets
- secp256k1-kmp pinned back to known-good 0.16.0 (0.19.0 = libsecp256k1
0.7.0 rebuilt with NDK 28; runs once per relay event on the verify path)
Also fix adjacent issues found while auditing every shipped release APK
(all were clean: release-signed, R8-minified, correctly aligned — ruling
out a malformed artifact as the cause):
- baseline-prof.txt still targeted com/wisp/app after the rebrand; the
next release would have shipped zero app AOT rules. Fixed paths now
yield 12,888 app rules; verified on-device (cold start 2048ms -> 830ms
once compiled, ProfileVerifier reports compiledWithProfile=true)
- release signing moved into Gradle (RELEASE_STORE_* via local.properties
or env) so the published APK is exactly what assembleRelease emits
- tools/audit-apk.sh: pre-release gate checking debuggable flag, lib
packaging consistency, alignment, R8, baseline profile, signing cert
- ProfileVerifier status logged at startup for field diagnostics
- <profileable android:shell="true"/> so testers can capture simpleperf/
Perfetto traces from release builds
- docs/grapheneos-perf-investigation.md: full findings plus the A/B
attribution matrix (incl. GrapheneOS MTE toggle test) for confirming
which half of f68b40f was guilty
- Rename package com.wisp.app -> com.darkwisp.app
- Set applicationId/namespace to com.darkwisp.app
- Reset version to 1.0.0 (versionCode 1) as a new app
- Update display name to Dark Wisp / Dark Wisp Debug
- Update NIP-89 client tag to "Dark Wisp"
- Rebrand user-facing strings across all locales and README
- rootProject.name -> dark-wisp-android
PR #514 changed the bottom-nav handler to use popUpTo with the graph's
start destination plus saveState/restoreState. Two regressions resulted:
- Tapping a tab restored the tab's saved back stack, returning the user
to a previously-open thread instead of the tab's main screen.
- The graph's start destination (LOADING) is popped inclusive on first
successful load, so popUpTo never matched. The resulting back-stack
state could surface the splash/auth screen on system back from
Notifications.
Revert that block to popUpTo(FEED) { inclusive = false } + launchSingleTop,
which always lands the user on the tab's main screen with a shallow stack.
The refreshDmsAndNotifications() throttle introduced alongside the broken
nav block is preserved — that part addresses real REQ churn jank and is
independent of how the back stack is structured.
- give getNip05Client a dedicated 5/10s client (was aliasing the 10/15s
general client); slow .well-known/nostr.json endpoints no longer tie
up verification threads
- add getDownloadClient (30/60s) for MediaDownloader; the shared media
client (10/30s) is fine for ExoPlayer streaming but its read timeout
is too tight for full-file downloads on flaky networks
- tighten notification rebuild coalesce window from 50ms to one frame
(16ms) so single-arrival updates feel responsive
- add NotificationRepository.shutdown() and call it from
FeedViewModel.onCleared() so rebuildScope doesn't outlive the VM
Restore top-level tab destinations instead of recreating them on each tap, and avoid immediately resubscribing inbox streams when users bounce between tabs. This reduces navigation jank and repeated hangs on slower devices.
NIP-17 gift wraps are re-fetched on every cold start (the kind-1059
subscription has no since cursor because gift-wrap timestamps are
randomized up to 2 days in the past). Without a decrypted-DM cache,
remote-signer mode (Amber) re-runs two NIP-44 IPC decrypts per wrap on
every launch.
Adds an ObjectBox-backed cache keyed by ownerPubkey|giftWrapId. On
construction, DmRepository hydrates conversations, seenGiftWraps,
rumorIdIndex and the DM notification list from disk off the main thread.
addPendingGiftWrap now short-circuits on seenGiftWraps before queuing,
so relay-redelivered wraps never reach the signer; only newly arrived
wraps are decrypted. addMessage / addReaction / addZap re-persist the
parent message so reactions and zaps survive restarts. clear() and
purgeUser() propagate to the persistence layer.
Drops the embedded kmp-tor client, all .onion relay support, the Tor
toggle UI on splash/auth/drawer screens, the SOCKS proxy plumbing in
HttpClientFactory, all Tor-related strings across 11 locales, the
TorManager singleton, and the kmp-tor dependencies and ProGuard rules.
Relay URL validation now accepts only wss:// (with hostname, no port,
non-localhost, non-IP). The cleartextTrafficPermitted network security
flag stays — still needed for local relays on RFC1918 / loopback.
OkHttpClient.newWebSocket() can block on its shared TaskRunner lock
for several seconds under contention. UI callbacks that invoke
RelayPool.sendToRelayOrEphemeral were calling Relay.connect() inline
on the main thread, producing 5s+ ANRs. Dispatch connect() through a
small dedicated thread pool so callers never wait on WebSocket setup.
Inline video players in RichContent now use REPEAT_MODE_ONE so MP4s
auto-repeat when rendered inside a note card. Applied to both freshly
created and PiP-reclaimed players. Fullscreen and audio paths are
unchanged.
Adds a Scan tab alongside Nostr/Lightning in the drawer's QR sheet that
opens the camera and decodes Nostr entities (npub, note, nprofile, nevent,
naddr) — with or without the nostr: prefix — and navigates directly to
the matching profile, thread, or article.
Extracts the camera scanner from WalletScreen into a reusable QrScanner
component, and centralizes NostrUriData→route mapping as toRoute() in
Navigation.kt so the deep-link handler and scanner share it.
Disable legacy jniLibs packaging so native libraries are stored
uncompressed and page-aligned in the APK. Bump secp256k1-kmp to 0.19.0
and kmp-tor-resource to 408.16.4 since the prior versions shipped
ELF binaries with 4 KB LOAD alignment. All 13 .so files in the release
APK now report 2**14 alignment.
Replace items(...) + notifications.indexOf(item) with itemsIndexed(...)
in NotificationsScreen. The indexOf call ran during composition for
every visible item, causing a linear scan per item and quadratic total
work as the list grew. Reported as a UI freeze when tapping the
notifications tab on slower devices (GrapheneOS).
Quoted notes nested inside another note were using the default unicode
emoji fallback instead of the user's sorted-by-frequency picker with
their custom emojis. Plumb resolvedEmojis, unicodeEmojis, and the
onOpenEmojiLibrary callback through NoteActions so nested PostCard /
GalleryCard renders in RichContent.QuotedNote pick up the same emoji
state as their parent.
Replaces the basic inline audio widget with a global mini-player that persists
across feed scrolling and tab navigation. Playback continues with system
notification / lock-screen transport controls via MediaSessionService.
The dock has two states: a collapsed row showing the author's avatar and name
with rewind-15 / play-pause / forward-15; swipe up to expand for a scrub slider,
speed cycling, and close. Single ExoPlayer is owned by AudioPlayerController
and shared with the inline tap-to-play widget, which now reflects global
playback state.
Two repos called clear() during resetForAccountSwitch() were doing
`prefs.edit().clear().apply()` on their SharedPreferences files while
those files were still pointing at the outgoing account:
- CustomEmojiRepository wipes its own per-pubkey file, erasing the
"most used" emoji frequency map. No relay mirror, so the data was
lost for good.
- BlossomRepository shares `wisp_prefs_{pubkey}` with KeyRepository, so
its full-file wipe also took out local_relay, relays, dm_relays, etc.
Most of those keys came back via replaceable-event refetches, but
local_relay has no network source and stayed null.
Drop the disk wipe from both clear() methods. The subsequent
reload(newPubkey) already repoints prefs to the incoming account's file.
Mentions used to be stored in the text field as raw nostr:nprofile URIs
rendered as @Name via an OutputTransformation. The visual/underlying
offset mismatch made the cursor jump, the IME desync on rapid typing,
forced a trailing space after every mention (so '@name's' was impossible),
and turned a single backspace into a silent delete of the whole mention.
Store '@Name' directly in the text field and track mention ranges
out-of-band in the ViewModel; splice nostr:nprofile URIs back in at
publish time. Editing is now plain text — identity offset mapping, no
forced space, and breaking a mention drops only that mention, not the
surrounding text.