No description
  • Kotlin 96.7%
  • Shell 2.5%
  • Python 0.6%
  • Nix 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Steffen Rörtgen 5bc02f8675 0.13.0: version-prep
versionCode 34 — above the published 0.12.1 (26) and every RC sideload
(27-33), so everything anyone holds updates cleanly. The changelog's
0.13.0 section is what zsp ships as the release notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:49:26 +02:00
.claude/skills/releasing Add the releasing skill: the zapstore/ngit release runbook 2026-08-11 16:48:40 +02:00
.forgejo/workflows Park the Forgejo CI workflow: manual trigger only 2026-08-06 12:35:37 +02:00
app 0.13.0: version-prep 2026-08-19 13:49:26 +02:00
docs fix: keep tab scroll position across screen switches 2026-08-11 10:06:00 +02:00
gradle
scripts Merge #5cb01f3e: Keep both halves of a NIP-65 relay list, and publish only to the write half 2026-08-13 14:37:02 +02:00
THIRD_PARTY_LICENSES Paint the Paper palette instead of the wallpaper 2026-08-07 10:46:50 +02:00
.envrc
.gitignore
build.gradle.kts
CHANGELOG.md 0.13.0: version-prep 2026-08-19 13:49:26 +02:00
emulator.nix
gradle.properties
gradlew
gradlew.bat
README.md
settings.gradle.kts
shell.nix
zapstore.yaml Add CHANGELOG.md and point zsp release_notes at it 2026-08-08 11:50:54 +02:00

Stash — Nostr social bookmarks for Android

Share a link from any app, tag it, file it into your Nostr lists. Signing goes through Amber over NIP-55, so no private key ever enters this app.

  • Bookmarks are NIP-B0 kind:39701 events — the d tag is the URL with the https:// stripped.
  • Lists are NIP-51: kind:30003 named bookmark sets and the global kind:10003 list. Bookmarks join a list as an a coordinate 39701:<pubkey>:<d>.
  • Login and signing are NIP-55.

Everything is public Nostr data, so other clients can read your bookmarks and lists, and you can read bookmarks you saved elsewhere.

Status

v0.1.0 — unproven on a real device. See What is not verified.

Build

There is no system JDK or Android SDK required — shell.nix pins the whole toolchain (JDK 21, Gradle 9.5.1, Android platform 37, build-tools 37.0.0).

nix-shell --run 'gradle :app:assembleDebug'
# app/build/outputs/apk/debug/app-debug.apk

Run the tests:

nix-shell --run 'gradle :app:testDebugUnitTest'

Without Nix, any JDK 21 + Android SDK with platform 37 and build-tools 37.0.0 works; drop the nix-shell --run wrapper. On NixOS the aapt2FromMavenOverride in shell.nix is required — AGP downloads its own aapt2, which is dynamically linked and will not run here.

Install

adb install -r app/build/outputs/apk/debug/app-debug.apk

The debug build's application id is xyz.laoc.stash.debug, so it installs alongside a release build rather than replacing it.

You also need a NIP-55 signer. Amber is the reference one. Without a signer installed the login screen says so and offers a link rather than showing a button that does nothing.

Using it

  1. Sign in with Amber. Stash asks for sign_event permission on kinds 39701, 30003, 10003 and 5. Tick "remember" in Amber for these — that is what lets later saves go through the content resolver in the background instead of bouncing you into Amber for every single event.
  2. Share a link to Stash from your browser's share sheet, or use the + button.
  3. Add a title, notes and hashtags, tick the lists it belongs to, save.
  4. Browse and filter by hashtag or by list on the main screen.

On login Stash also reads your NIP-65 relay list (kind:10002) and adds those relays to its defaults, so your bookmarks land where you already publish. Relays are editable in Settings.

Architecture

Layer Where
Nostr protocol, relay pool org.rust-nostr:nostr-sdk:0.44.6 (pinned)
SDK isolation nostr/NostrRepository.kt — the only file importing rust.nostr.sdk
Event shape (NIP-B0, NIP-51) nostr/WebBookmark.kt, nostr/BookmarkList.kt, nostr/BookmarkUri.kt
Signing signer/NostrSigner.kt + signer/AmberSigner.kt + signer/Nip55.kt
Share intake share/SharedLink.kt
UI Jetpack Compose + Material 3, ui/

Two deliberate choices:

Event shape never touches SDK types. Tags are List<List<String>> and keys are hex strings all the way down to NostrRepository. The SDK's Kotlin bindings are a JNI/UniFFI layer whose native library cannot load in a JVM unit test, so this is what makes the NIP logic testable for real instead of mocked.

The SDK version is pinned, not floated. The bindings are ALPHA and change in breaking ways between minor versions. NostrRepository is the blast radius of a bump.

What is verified

74 unit tests, 0 skipped — app/build/test-results/testDebugUnitTest/.

The load-bearing ones were mutation-checked: the rule was deliberately broken in the source and the matching test confirmed to go red. A suite that has only ever been green is not yet evidence.

Mutation Result
Strip the scheme prefix for every scheme, not only https 2 failures, incl. non-https scheme is kept
Report a user rejection as a signer failure 1 failure: user rejection is RESULT_OK plus the rejected extra
Drop unknown tags when re-publishing a list 1 failure: republishing preserves tags this app does not understand

The NIP-55 tests run under Robolectric with real Intent and Uri objects, so they check the request we build and the reply we parse against the spec.

What is not verified

Built and tested on a machine with no emulator and no device. Not exercised at all:

  • the Amber handshake itself — that we build a spec-shaped intent is tested; that Amber accepts it is not;
  • the content-resolver fast path (needs a real signer with a remembered permission);
  • the browser share sheet end to end;
  • publishing to real relays, and whether other clients render these events as expected;
  • anything about how the UI actually looks or behaves.

Treat v0.1.0 as "compiles, logic tested, unproven in the hand".

Known limitations

  • List edits can race. Adding a bookmark to a list is a read-modify-write on a replaceable event. Lists are re-fetched immediately before editing and a list we fail to fetch is left alone rather than overwritten, but NIP-51 defines no merge rule, so a genuinely concurrent edit from another client can still lose.
  • Lists are public. NIP-51 private items are NIP-44-encrypted in content. Stash preserves that ciphertext untouched on every write but does not read or write it — decrypting would cost an Amber round-trip per list. Your private items are safe, just invisible here.
  • Each list is a separate signature. Saving into three lists means three events. With a remembered permission in Amber this is invisible; without one it is three prompts.
  • a-coordinates for NIP-B0 in lists is an interop assumption. NIP-51's table predates NIP-B0 and names e (notes) and a (kind:30023 articles). Referencing an addressable bookmark by a is the natural extension, but the NIP does not state it.
  • The debug APK is ~46 MB because the rust-nostr native library ships for four ABIs. ABI splits would cut it to roughly a quarter.

Licence

MIT.