The embedded surface forwarding taps but cancelling drags was a known bug in
androidx.privacysandbox.ui alpha10: with the provider surface z-ordered above,
"the gesture is exclusively received by the provider window and not transferred
to the client window" (alpha13 release notes). alpha15 then "set the default
Z-ordering to below" and "added support for the UI provider to receive
MotionEvents in this mode after being received by the client window" — i.e. the
drag-input path we needed.
Bump alpha10 → alpha17 and adapt the changed API: openSession takes SessionData
instead of a windowInputToken IBinder, Session adds notifySessionRendered, and
the session-state listener became setEventListener(SandboxedSdkViewEventListener)
(ready now flips on onUiDisplayed). The direct-WebView browser is unaffected
(it doesn't use this library).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Adds a "Browser" navigation destination (drawer + pinnable bottom-nav item,
API 30+) that opens any URL. The page renders in the sandboxed, keyless
`:napplet` process and is streamed into the main activity as a cross-process
surface via androidx.privacysandbox.ui (SurfaceControlViewHost) — only pixels
and input cross the boundary, never the WebView's JS context or the NIP-07
bridge. The trusted address bar is drawn by the main process around the
embedded surface, so the sandbox can never spoof the URL.
NIP-07 `window.nostr` is injected the same way nSite website mode does it, but
scoped per visited origin: each origin gets its own broker-minted launch token
(keyed by the trusted source origin), so a grant to one site never leaks to
another.
- NappletBrowserService (`:napplet`): hosts the live-URL WebView, exposes it as
a SandboxedUiAdapter, and relays the per-origin NIP-07 bridge to the broker.
- NappletBrowserUiAdapter: wraps the WebView session for privacysandbox.ui.
- NappletBrokerService: mints a per-origin synthetic identity so NIP-07 consent
is scoped per host.
- EmbeddedBrowserController + BrowserScreen: bind the service, render the
SandboxedSdkView, and drive the trusted address bar (navigate/reload/back/Tor).
- shim.js: a direct-bridge transport so the injected shim works in a top-level
page that has no trusted shell parent.
- Browser nav item hidden below API 30 (SurfaceControlViewHost requirement).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The `:napplet` sandbox runtime (NappletHostActivity, NappletContentServer,
NappletIpc, NappletKeyActions) now lives in a new :nappletHost Android library
that depends only on :commons + :quartz — NEVER :amethyst. So the sandbox code
is compile-time incapable of importing Amethyst.instance / LocalCache / Account,
turning the "two-process, no secrets in the sandbox" rule from a convention into
a build-graph guarantee.
- New module + NappletHostContract (Intent-extra keys + broker service FQN), so
the launcher (amethyst) and activity (module) share the launch contract with no
dependency cycle. The activity binds the broker by class name.
- Capability labels for the "what it can access" sheet are resolved by the
launcher (which has app resources) and passed in, so the module needs no
capability string resources. Host-only strings moved into the module.
- amethyst depends on :nappletHost; the broker-side (NappletBrokerService,
gateways, NappletLaunchRegistry) stays in :amethyst. Manifest declares the
activity by FQN (keeps @style/Theme.Amethyst resolvable).
- Docs updated (CLAUDE.md + security plan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Implements the Android side of the napplet/nsite trust boundary on top of the
commons core. The applet runs in a separate OS process holding no keys; every
dangerous operation is brokered to the main process and gated by user consent.
:napplet process (no secrets):
- NappletHostActivity: hardened WebView (no file/content access, no DOM storage,
mixed-content blocked, SafeBrowsing on), applet served into an opaque-origin
sandboxed iframe, manifest blobs served already-verified via
shouldInterceptRequest with a default-deny CSP (connect-src 'none' = no direct
network), and a window.napplet.* shim bridged over an origin-restricted
WebMessageListener.
Main process (holds the signer):
- NappletBrokerService: bound Messenger service running the commons NappletBroker
against the live account; exported=false + UID check. Builds the broker per
request so account switches are honored; relay publish via the account's
computed broadcast relays.
- NappletConsentActivity + NappletConsentCoordinator: capability-consent dialog
with a suspend bridge; fails closed on dismissal.
- DataStoreNappletPermissionStore: persistent grant store.
Shared edge: NappletProtocolJson (JSON codec), NappletIpc (Messenger contract),
NappletLauncher (packs a verified manifest into the host Intent), shell.html.
Adds androidx.webkit (Apache-2.0) for the origin-restricted message bridge — a
plain @JavascriptInterface leaks into every frame and would break the boundary.
Manifest declares the :napplet activity, the consent activity, and the broker
service. :amethyst:compileFdroidDebugKotlin passes; on-device verification and a
UI entry point are the remaining steps (see the plan doc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Replace the reverse-geocoded "📍 City" line on the Roadstr road event
report and confirmation cards with an osmdroid OpenStreetMap preview
pinned at the event's coordinates.
- Add osmdroid-android 6.1.20 (Apache-2.0) to the version catalog and
the amethyst module.
- New LocationPreviewMap composable: an AndroidView-wrapped MapView with
MAPNIK tiles, a single marker, lifecycle-aware onResume/onPause/onDetach,
and nested-scroll-friendly touch handling so panning the map doesn't
fight the feed scroll. Sets the OSM User-Agent to the package name
(required or OSM returns 403).
- RoadEvent cards resolve a point from the explicit lat/lon tags, falling
back to the center of the finest published geohash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
Bump the app version to 1.12.1 (versionCode 449) in the catalog, which now
drives every module. Add the v1.12.01 changelog (Health Connect workouts,
share-as-image, immersive system bars, deterministic Tor Active fix) and index
it. Refresh the quartz-integration skill's artifact-version references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make every module read its release version from gradle/libs.versions.toml
instead of carrying its own literal, so a release bump is a single-file edit.
- Move the Android versionCode out of amethyst/build.gradle.kts into the
catalog as `appCode`; amethyst reads it via libs.versions.appCode.get().
- quartz publishes with version = libs.versions.app.get().
- geode generates a BuildConfig.VERSION from the catalog (new
generateVersionFile task) and RelayInfo.VERSION reads it, so the NIP-11
software version tracks releases.
- Update the root build comment to point at the appCode entry.
Version-neutral: everything still resolves to the current 1.12.0 / 448.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds foreground auto-detection of finished workouts on Android via Google
Health Connect — the single aggregator every Android health source funnels
into (Samsung Health/Galaxy Watch, Google Fit, Fitbit, Garmin, Strava), the
same Android path RUNSTR uses. On opening the Workouts screen, Amethyst scans
Health Connect for sessions the user hasn't handled and surfaces a banner that
opens the existing workout composer pre-filled, ready to publish as a NIP-101e
WorkoutRecordEvent (kind 1301).
- HealthConnectManager reads ExerciseSessionRecord + aggregated distance,
calories, heart rate, steps and elevation, mapping each to DetectedWorkout.
- ExerciseTypeMapper maps Health Connect activity types to NIP-101e verbs.
- HealthConnectStore remembers handled sessions per account so each is
offered once; 7-day foreground lookback, no background service.
- WorkoutSuggestions banner: connect prompt (on-demand permission request,
never on cold start) or detected-workout rows on the Workouts screen.
- Route.NewWorkout carries optional pre-fill; NewWorkoutViewModel publishes the
richer metrics (heart rate, steps, elevation, start time) with
source=health_connect (new SourceTag constant in quartz).
- androidx.health.connect:connect-client (Apache-2.0) + read-only health
permissions and the required privacy-rationale manifest entries.
Design notes in amethyst/plans/2026-06-16-health-connect-workout-detection.md;
background ~15-min polling + notification left as a documented future seam.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).
- commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
MIT) — to be consumed by the new ImageReencoder in Phase 1.
- amy CLI now sets -Djava.awt.headless=true via three paths so any
transitive ImageIO/AWT touch never spawns a GUI thread:
* applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
installDist startup scripts and any future jpackage launcher),
* the amyImage custom Unix launcher in cli/build.gradle.kts,
* System.setProperty as the first line of cli Main.kt — belt-
and-braces for invocations that bypass the launcher scripts.
- commons:jvmTest forces -Djava.awt.headless=true for the same
reason during test runs.
Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
Posts that use the common dollar-delimiter convention (e.g. the
math-academy "Linear Independence" note) now render their formulas
as real equations instead of raw LaTeX text.
- commons: new MathParser tokenizes a line into atomic math spans
(kept whole, since they contain spaces) interleaved with plain text,
following the pandoc/remark-math dollar rules so currency like
"$5 and $10" and escaped "\$" don't false-fire. New MathSegment
carries the inner LaTeX + display flag through the rich-text pipeline.
- RichTextParser splits math out before the whitespace word-splitter
when a line might contain math; non-math lines keep the existing path.
- amethyst: LatexEquation renders a MathSegment via JLaTeXMath, tinted
to the current text color and sized to the font, with a raw-text
fallback when the formula fails to parse. Wired into both the
preview and no-preview render paths of RichTextViewer.
Scope: dollar delimiters only, regular (non-markdown) render path.
https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
Phase 2 task 10 of the iOS plan — flip on iOS targets for :commons.
Gradle dep resolution is fully green for iOS; actual Kotlin/Native
compilation runs on the macOS CI job (the dev container in which this
was authored can't extract the K/N LLVM toolchain).
Dep reshuffle to match what's actually KMP-available:
- commonMain: kept project(":quartz"), Compose Multiplatform, coil-compose,
androidx-collection, kotlinx-collections-immutable, kotlinx-serialization-json,
compose components-resources, androidx-lifecycle-viewmodel,
androidx-lifecycle-runtime-compose. These all publish iosArm64 +
iosSimulatorArm64 variants per `.module` inspection.
- jvmAndroid (NEW location): project(":nestsClient") (JVM+Android-only),
coil-okhttp (JVM-only), markdown-commonmark / markdown-ui /
markdown-ui-material3 (the RenderMarkdown.kt consumer is already in
jvmAndroid), and androidx-lifecycle-viewmodel-compose (AndroidX publishes
android + jvmStubs + linuxx64Stubs variants — no iOS, so the viewModel()
Composable helper stays JVM-bound until we either swap to the
org.jetbrains.androidx.lifecycle variant or accept a platform-specific
ViewModel access pattern on iOS).
- libs.versions.toml: adds androidx-lifecycle-viewmodel catalog entry.
- New intermediate source set iosMain → both iosArm64Main and
iosSimulatorArm64Main depend on it (clean place for iOS-only actuals
when KmpLock, WeakReference, etc. get their iOS implementations).
- .github/workflows/build.yml: test-quartz-ios job now also runs
:commons:compileKotlinIosArm64 + :commons:compileKotlinIosSimulatorArm64.
Address review feedback: the project already has LargeCache (in quartz,
with jvmAndroid/appleMain/linuxMain actuals) as its KMP concurrent-map
abstraction — it's used pervasively in the model layer. Adding stately
duplicated that capability with an external dep.
- Comparable-key maps switch to LargeCache:
* ChessEventCollector.moves (String key)
* ChessEventCollectorManager.collectors (String key)
* ChessRelayFetchHelper.events (String key)
* ChessRelayFetchHelper.relayEventCounts: LargeCache<NormalizedRelayUrl,
AtomicInt> with getOrCreate { AtomicInt(0) }.addAndFetch(1) — replaces
the stately .block { compute } increment idiom. getOrCreate is atomic
via ConcurrentSkipListMap.putIfAbsent so all threads end up
incrementing the same AtomicInt instance.
* ChessLobbyLogic.recentlyLoadedGames (String key)
- The SubscriptionManager pair (MutableComposeSubscriptionManager,
ComposeSubscriptionManager) keeps a plain mutableMapOf — T :
MutableQueryState is generic and not Comparable, so LargeCache's
ConcurrentSkipListMap backing would ClassCastException at put time.
Concurrency comes from a KmpLock-guarded map.
- Set-shaped uses switch to KmpLock + mutableSetOf:
* ChessEventCollector.processedEventIds
* ChessRelayFetchHelper.eoseReceived
* ChessLobbyLogic.dismissedGameIds + seenEventIds (the bounded LRU
keeps insertion-order eviction; mutableSetOf returns LinkedHashSet
on every KMP target).
- UserRelaysCache.flow's lock: stately Lock -> KmpLock.
Adds expect class KmpLock() with jvmAndroid actual that wraps
ReentrantLock. iOS actual (NSLock) will land with the iOS target.
Mirrors the WeakReference pattern from the previous PR.
Drops stately-concurrent-collections 2.1.0 from libs.versions.toml and
commons/build.gradle.kts (no remaining consumers).
Phase 2 of the iOS plan — clears the ConcurrentHashMap blockers from
commons/commonMain. Five files migrated (the four flagged in the
initial audit + ChessLobbyLogic, which used fully-qualified inline
java.util references that the import-based audit missed).
Adds co.touchlab:stately-concurrent-collections 2.1.0 — a small,
mature KMP library that provides ConcurrentMutableMap /
ConcurrentMutableSet with semantics equivalent to ConcurrentHashMap /
ConcurrentHashMap.newKeySet on every Kotlin target. The .block { }
helper covers the compound-update paths (ChessRelayFetchHelper's
per-relay event-count compute, ChessLobbyLogic's bounded-LRU dedup).
- ComposeSubscriptionManager + MutableComposeSubscriptionManager:
ConcurrentHashMap -> ConcurrentMutableMap
- ChessEventCollector + ChessEventCollectorManager: map and Set
- ChessRelayFetchHelper: in-function event/relay state
- ChessLobbyLogic: replaces dismissedGameIds (synchronizedSet),
recentlyLoadedGames (ConcurrentHashMap), seenEventIds (bounded LRU
using LinkedHashSet via Collections.synchronizedSet + synchronized {}).
seenEventIds keeps insertion-order eviction semantics because
mutableSetOf returns LinkedHashSet on every KMP target.
First Phase 2 verb wired through to the Android App Functions runtime so
Gemini (and other system agents) can drive Amethyst.
Scope is intentionally narrow:
* One read-only verb (searchProfiles), built on top of the existing
SearchActions in commons. No write verbs yet — they need a story
for NIP-46 / NIP-55 signer prompts from a background dispatcher.
* Play channel only. appfunctions 1.0.0-alpha09 is a Google AI alpha;
F-Droid builds continue to ship without any Google AI dependencies.
Architecture:
* AmethystAppFunctions — plain Kotlin host with @AppFunction methods.
The KSP-driven appfunctions-compiler discovers them and generates
the dispatch metadata XML at build time.
* PlayAmethyst — play-only Application subclass implementing
AppFunctionConfiguration.Provider; supplies the factory the
library uses to construct the host class. Manifest replaces
android:name in the play flavor only; F-Droid keeps the unmodified
Amethyst class.
* The androidx-provided PlatformAppFunctionService is registered in
the play manifest as the bind point — Amethyst doesn't ship a
custom Service.
KSP is now a project-wide plugin (apply false at the root); applied in
amethyst/ to run the appfunctions-compiler over the play sourceSet.
Amethyst becomes `open class` so PlayAmethyst can extend it. No other
behavior change.
- chore(cast): drop protocol-toggle settings, strings, perms, deps
- gate cast on play flavor via BuildConfig.IS_CASTING_AVAILABLE
- fix(cast): keep local player paused across recompose; disable transport while casting
fix(cast): handle every Chromecast session terminal state and HLS mime hint
fix(cast): repair DLNA Res ctor and add play-only protocol toggle
fix(cast): backfill Cast button for accounts with pre-existing video settings
fix(cast): include cast glyphs in symbol font subset and add diagnostic logs
fix(cast): wire jUPnP's required Jetty deps and survive dialog dismissal
fix(cast): add jetty-client so jUPnP can issue UPnP control requests
Adds operator-facing TOML configuration to :quartz-relay, with the
section layout deliberately mirroring nostr-rs-relay's config.toml so
existing operators can port across with little churn.
Sections parsed AND enforced today:
[info] — NIP-11 doc fields (name, description, contact, pubkey,
software, supported_nips, …); replaces the previous
hardcoded RelayInfo.default()
[network] — host, port, path
[database] — in_memory toggle + file path
[options] — verify_signatures, require_auth (compose to the right
IRelayPolicy stack)
Sections parsed today but NOT YET ENFORCED (forward-compat for the
upcoming rate-limit / authorization work — relay logs a warning when
they're set):
[limits] — max_event_bytes, messages_per_sec, …
[authorization] — pubkey_whitelist/blacklist, kind_whitelist/blacklist
[options].reject_future_seconds
[network].remote_ip_header
CLI flag precedence over the config file is preserved: --host, --port,
--path, --info, --db, --auth, --verify all override the matching field.
Adds:
- cc.ekblad:4koma 1.2.0 for TOML parsing
- quartz-relay/config.example.toml as the canonical operator reference
- 5 unit tests (defaults, full parse, NIP-11 mapping, bundled example
file, optional sections)
Renames :quartz-test-relay to :quartz-relay and promotes it from a
test-only fixture to a real, runnable Nostr relay that just happens
to also be used in tests.
Two transports now share the same `Relay` core:
- `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal
for unit tests inside one JVM.
- `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening
on a real `ws://` port. Use for `cli` interop tests, Android
instrumented tests, or running the relay standalone.
NIPs implemented (all driven through production `NostrClient` in the
new `LocalRelayServerTest`):
- NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets
- NIP-09 deletion (via existing `DeletionRequestModule`)
- NIP-11 relay info doc — `RelayInfo` wraps the existing
`Nip11RelayInformation` model and is served on HTTP GET when
`Accept: application/nostr+json` is requested. Loadable from a
JSON config file via `RelayInfo.fromFile(...)`.
- NIP-40 expiration (existing `ExpirationModule`)
- NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the
`--auth` flag in the standalone runner)
- NIP-45 COUNT
- NIP-50 search via the existing FTS index
- NIP-62 right-to-vanish (existing module)
Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"`
binds the relay to a real port. Flags: --host, --port, --path,
--info <file>, --db <file>, --auth, --verify.
Test-only event generators moved to a clearly-named
`com.vitorpamplona.quartz.relay.fixtures` package so they're still
shareable with consumer tests without polluting the production API.
Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real
loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process
transport) continues to pass.
Pulls in the per-platform JNI shards (linux-x86_64, linux-aarch64,
darwin-x86_64, darwin-aarch64) that the upstream library now publishes as
runtime dependencies of schnorr256k1-kmp-jvm. The 1.0.3 release also
moved the groupId to com.vitorpamplona.schnorr256k1.
Net effect: Secp256k1CrossValidationTest's customCImplementationMatchesAcinqAndKotlin
no longer prints SKIP, and the Custom C(JNI) column in
Secp256k1TripleBenchmark now reports numbers (verifySchnorr ~25.6k ops/s,
signSchnorr cached pk ~57.4k ops/s) instead of (N/A). 188/188 tests pass
in the secp256k1 suite on a stock JVM with no LD_LIBRARY_PATH or
-Djava.library.path needed.
https://claude.ai/code/session_01XEfLBStDum7h8Brwo7qFXt
The custom C secp256k1 implementation under quartz/src/main/c/ was never
wired into Gradle (no externalNativeBuild block) and only existed for the
3-way benchmark + cross-validation test against ACINQ on JVM/Android.
The C library has been split out to vitorpamplona/libschnorr256k1{,-kmp},
so the in-repo copy is dead weight.
This change replaces it with the published Maven Central artifact
`com.vitorpamplona:schnorr256k1-kmp:1.0.0`, scoped to test/benchmark
configurations only — production crypto continues to use ACINQ
secp256k1-kmp on JVM/Android and the pure-Kotlin Secp256k1 on native.
The Android AAR ships libschnorr256k1_jni.so for arm64-v8a and x86_64,
so the Android benchmark now exercises the C row automatically (no more
manual build_android.sh). On JVM the .so still has to be installed by the
developer; the cross-validation test and triple-benchmark gracefully skip
the C row when System.loadLibrary fails, matching prior behavior.
Verified on JVM: all 13 secp256k1 jvmTest classes pass (188 tests), and
ACINQ vs pure-Kotlin benchmark numbers match the documented baseline
within sandbox noise (verifySchnorr ~15k ops/s, signSchnorr cached
~33k ops/s, pubkeyCreate+Compress ~36k ops/s).
Removes ~5,100 LOC: quartz/src/main/c/ (4,500), Secp256k1InstanceC.*
expect/actual shim (560), Secp256k1C JNI declaration object.
https://claude.ai/code/session_01KnvpK2amcVZKfFiZJvHjVe
Adds com.google.mlkit:genai-image-description as the primary alt-text
source — Gemini Nano via AICore produces full descriptive sentences on
supported devices. When checkFeatureStatus reports anything other than
AVAILABLE (or AICore is missing), the service falls back to the legacy
play-services-mlkit-image-labeling keyword join. Both paths sit behind
the same MLKitImageLabelService.suggestAltText API; the F-Droid stub is
unchanged.