- Kotlin 96.7%
- Shell 2.5%
- Python 0.6%
- Nix 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .claude/skills/releasing | ||
| .forgejo/workflows | ||
| app | ||
| docs | ||
| gradle | ||
| scripts | ||
| THIRD_PARTY_LICENSES | ||
| .envrc | ||
| .gitignore | ||
| build.gradle.kts | ||
| CHANGELOG.md | ||
| emulator.nix | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| README.md | ||
| settings.gradle.kts | ||
| shell.nix | ||
| zapstore.yaml | ||
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:39701events — thedtag is the URL with thehttps://stripped. - Lists are NIP-51:
kind:30003named bookmark sets and the globalkind:10003list. Bookmarks join a list as anacoordinate39701:<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
- Sign in with Amber. Stash asks for
sign_eventpermission 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. - Share a link to Stash from your browser's share sheet, or use the + button.
- Add a title, notes and hashtags, tick the lists it belongs to, save.
- 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 namese(notes) anda(kind:30023 articles). Referencing an addressable bookmark byais 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.