New verb: getFeedDigest(hoursBack, maxNotes).
Use when the user asks "summarize my Nostr feed", "give me a digest
of what my follows posted today", "recap Nostr", or any other
summary / digest / recap intent.
Returns a structured snapshot for AI summary instead of a raw note
list: total note count, unique author count, top hashtags (≤10) and
top mentioned users (≤10) — with display names resolved from the
local kind:0 cache — alongside the trimmed note body. The LLM uses
the aggregate signals to write a one-paragraph "the conversation
focused on X, with N people posting about Y" instead of having to
re-derive frequencies from a raw list.
Implementation:
* Shared core extracted into fetchFollowFeed(account, since, limit)
so getRecentFromFollows and getFeedDigest don't duplicate the
drain logic.
* Over-fetches by 3× the visible cap so stats are computed over a
larger sample than the LLM sees, capped at 500 events for bounded
on-device work.
* Hashtag bucketing: lowercases + strips leading #, so #Bitcoin
and #bitcoin collapse.
* Mention bucketing: skips self-mentions (some clients tag the
author themself, not useful for the digest).
New @AppFunctionSerializable result types:
* HashtagFrequency, MentionFrequency — count + identifier.
* FeedDigestResult — windowHours, totalNoteCount, uniqueAuthorCount,
topHashtags, topMentions, notes.
Total verb count: 22. app_metadata.xml updated so Gemini's tool
picker can pitch the summary surface specifically.
Known scope: currently returns kind:1 from the user's kind:3 follow
list — does NOT match the in-app home feed exactly. The home feed
includes reposts, long-form, polls, comments, etc., respects the
user's currently selected NIP-51 list, and filters muted users.
Aligning the digest to the home feed (via HomeNewThreadFeedFilter
against LocalCache) is a documented follow-up.
withTimeoutOrNull(deferred.await()) returns a flattened Response? —
both "timeout" and "wallet sent null" produce null, and we already
catch null via the elvis-return above. The explicit `null ->` arm in
the response switch was dead code; the compiler warned about it.
Folded the "wallet sent null we couldn't decrypt" case into the
timeout error message since they're indistinguishable to the caller.
- cover CodePoints helpers and Channel.relays() equal-count behaviour
- Two new test files in commons/src/commonTest/, both run under :commons:jvmTest.
Returning a BOLT11 invoice for the user to paste somewhere defeated
the point of "Gemini, zap Alice 21 sats". Now when the active account
has a Nostr Wallet Connect (NIP-47) wallet configured in Amethyst,
both zap verbs pay the invoice automatically over NIP-47 and report
the outcome inline.
Implementation:
* payViaNwcOrNull(account, bolt11, zappedNote) — null when no NWC
set up (caller falls back to manual). Otherwise wraps the
callback-based Account.sendZapPaymentRequestFor in a
CompletableDeferred + withTimeoutOrNull. 30s budget; if the
wallet doesn't answer in that window the caller sees an
nwcError of "wallet didn't respond within 30s" and still has
the raw invoice to fall back on.
* Decodes the wallet's response: PayInvoiceSuccessResponse carries
the preimage, PayInvoiceErrorResponse carries a typed code +
message, NwcErrorResponse covers transport-level errors, null
means "couldn't decrypt the reply" (rare — wallet misconfigured
or our signer rejected). Each case maps to a typed
NwcOutcome the verbs can render.
* ZapResult / ZapInvoice grow four fields: nwcAttempted, nwcPaid,
nwcPreimage, nwcError. The invoice is still always returned so
Gemini can show it as a manual-payment fallback when NWC isn't
configured or rejects. zapEvent attempts each split independently
— one wallet failure doesn't block the rest.
Kdoc updates note the NWC behavior so the LLM picks up "if NWC is
configured, this just works" — that's the user-visible promise of
asking Gemini to tip someone.
PR #3047 enabled iosArm64 + iosSimulatorArm64 on :commons and added
:commons:compileKotlinIosSimulatorArm64 as a CI gate, but the Phase 2
migration was incomplete — JVM-only APIs survived in commonMain and
several expect declarations had no iOS actual. Every main CI run since
the merge failed at "Compile Commons for iOS".
Migrations in commonMain
- Dispatchers.IO: add `import kotlinx.coroutines.IO` to 16 files, matching
the quartz/NostrClient.kt pattern (kotlinx-coroutines 1.11 exposes IO on
Native via this import; no shim needed).
- synchronized {}: replace with the existing KmpLock + withLock in
EOSECache, AcceptedGamesRegistry, EventDeduplicator, ThumbHashDecoder,
PeerSessionManager. Restructure two PeerSessionManager methods that
late-init vals from inside the lock — withLock returns a tuple now.
- Unicode code points: drop java.lang.Character / String.codePointAt /
String.offsetByCodePoints. Add commons/util/CodePoints.kt with surrogate
-pair-aware KMP helpers; rewrite EmojiCoder + EmojiUtils against them.
- Byte<->String: encodeToByteArray() / decodeToString() / concatToString()
in EmojiCoder, Base83, BlurHashEncoder, RobohashAssembler,
LongFormPublishAction (drops Charsets / String(CharArray) / toByteArray
no-arg).
- Math.round → Double.roundToLong in BlurHashEncoder.
- String.format → Compose Resources stringResource(res, vararg) overload
in LoadingState (FeedErrorState).
- toSortedSet → sortedByDescending { }.mapTo(LinkedHashSet) in Channel —
preserves the descending-by-relay-count iteration order callers depend
on.
- Comparator<T>: kotlin.Comparator on Native takes non-null T. Align
CreatedAtComparator / CreatedAtComparatorAddresses to compare(a, b) and
drop dead null checks in CreatedAtIdHexComparator.
iOS actuals (commons/src/iosMain/)
- WeakReference: switch from typealias to explicit `actual class`. The
expect param is `referent` (matches java.lang.ref); kotlin.native.ref.
WeakReference uses `referred`, so typealias fails the expect/actual
name-match check on Native. Add @file:OptIn(ExperimentalNativeApi).
- PlatformImage: functional IntArray-backed actual (used by BlurHash and
ThumbHash decoders at runtime); Phase 3 will swap to CGImage.
- ChessDismissedGamesStorage: in-memory only; NSUserDefaults wiring lands
with iosApp in Phase 3.
- SecureKeyStorage: stub throwing SecureStorageException. Keychain
Services binding is Phase 4 per the iOS plan.
- formattedDateTime: NSDateFormatter with "yyyy-MM-dd-HH:mm:ss" + POSIX
locale + local time zone (semantically matches the JVM
DateTimeFormatter "uuuu-MM-dd-HH:mm:ss" for post-1970 timestamps).
- checkNotInMainThread: no-op (mirrors jvmMain).
- PlatformNumberFormatter: NSNumberFormatter(.DecimalStyle), with
NSNumber.numberWithLongLong to disambiguate the NSNumber(Long)
overload set.
- isDebug: false constant; iosApp can flip via Swift `DEBUG` flag later.
Verified locally
- :commons:compileKotlinIosSimulatorArm64 + compileKotlinIosArm64 green
- :quartz:iosSimulatorArm64Test green
- :commons:jvmTest + :quartz:jvmTest green (no JVM regression)
- :quartz:verifyKmpPurity + :commons:verifyKmpPurity + spotlessCheck green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A "missing" key in values-<locale>/strings.xml is not always actionable:
Crowdin omits source-identical translations on export (translator chose
"use English" for brand terms like "Nowhere X", loanwords like "Apps",
or version prefixes like "v%1$s"). Adding source-identical fallbacks
locally is noise that the next Crowdin sync strips again; Android already
falls back to values/strings.xml at runtime.
Add a Step 2.5 sync-timestamp filter that uses the latest
"New Crowdin translations by GitHub Action" commit reachable from HEAD as
the cutoff. Keys added to values/strings.xml after that commit are
genuinely new (Crowdin hasn't exported them yet); anything older is
Crowdin's responsibility. The reachable-from-HEAD check survives the
common workflow of deleting the l10n_crowdin_translations branch after
merging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three deliverables:
1) Two new write verbs:
* zapUser(user, sats, comment?) — builds the NIP-57 kind:9734
profile zap and fetches a BOLT11 invoice from the recipient's
Lightning service. Returns the invoice — caller pastes into a
Lightning wallet (no NWC auto-pay yet). 21 sats default,
1M sats cap, 280-char comment cap.
* zapEvent(eventId, sats, comment?) — same but for a specific
note, with full NIP-57 zap-split support via
ZapActions.buildEventZapRequestsForSplits. Returns one invoice
per recipient when the post carries `zap` tags.
Total verb count: 21 (8 read for feeds/profiles, 3 read for
identity / followers, 4 read for inbox/zaps/streams, 4 write
for note/follow/unfollow/dm, 2 write for zaps).
2) Reworked every verb's kdoc first sentence into an LLM-friendly
"use when..." trigger phrase. Gemini's tool picker matches user
queries against the descriptions (we generate them via
@AppFunction(isDescribedByKDoc = true)) — phrasing like "Find a
person on Nostr by name. Use when the user wants to look someone
up..." gives the model concrete prompts to recognise instead of
internal NIP names.
Affected: searchProfiles, getRecentFromFollows, getNotesByUser,
getProfile, searchByHashtag, getActiveAccountInfo, getRecentDms,
getZapsReceived, postNote, followUser, unfollowUser, sendDm,
zapUser, zapEvent.
3) amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md —
verification protocol for testing on-device whether Gemini's
tool picker actually surfaces our verbs from natural-language
prompts. Includes specific test prompts mapped to expected
verbs, fallback diagnostics (clear AppSearch + restart), and
the conditions under which it'd be worth defining our own
@AppFunctionSchemaDefinition namespace.
Plus minor: comment parameters switched to nullable (String? = null)
because KSP rejects non-nullable types with defaults.
Phase 4 from the signer-prompt plan, scoped to Option B (refuse NIP-55
with a typed NotSupportedException). Internal-key and NIP-46 bunker
accounts can now publish from Gemini.
New @AppFunction methods:
* postNote(text) — kind:1 short text note. Caps at 8000 chars to
catch accidentally-pasted documents; publishes to outbox relays
with per-relay ack reported.
* followUser(user) / unfollowUser(user) — kind:3 contact list
update via FollowActions. Detects already-following / not-
following and returns WriteResult.unchanged() rather than
re-publishing the same kind:3. New follows stamp the relay hint
from the target's cached kind:10002 write list, mirroring
User.bestRelayHint().
* sendDm(recipient, text) — NIP-17 gift-wrap via DmActions.buildTextDm.
Resolves per-recipient relay set through DmActions.resolveDmRelays
(permissive mode — falls back through NIP-65 read to bootstrap so
Gemini users don't trip on the strict kind:10050 rule). Returns
one DmDelivery per wrap (recipient + sender's own copy).
Signer gating — requireInProcessSigner():
* Read-only signers (npub-only login) → AppFunctionNotSupportedException
"sign in with a private key or NIP-46 bunker to publish".
* NIP-55 external signers (Amber) → AppFunctionNotSupportedException
"open Amethyst directly to complete the action". Detected via
qualified class name to avoid hard-coupling the bridge to the
nip55AndroidSigner module.
* NostrSignerInternal / NostrSignerRemote — sign in-process; the
NIP-46 round-trip already suspends through .sign(), no special
handling needed.
New @AppFunctionSerializable types:
* WriteResult — { changed, eventId?, publishedTo, rejectedBy }
* SendDmResult — { messageEventId, deliveries: List<DmDelivery> }
* DmDelivery — { recipientNpub, recipientPubkeyHex, wrapId,
publishedTo, rejectedBy, relaySource }
All 19 verbs now registered in the generated dispatcher (15 read + 4
write). app_metadata.xml updated so Gemini's tool picker pitches the
broader surface, including the NIP-55 caveat.
Every verb that returned a pubkey now also returns the best-effort
display name from the local kind:0 cache. Before this commit Gemini
could only say "you got a DM from npub1abc…" — now it can say
"you got a DM from Alice" because the LLM has the field at hand
instead of having to chain another lookup.
* NoteHit gains authorDisplayName (cache-resolved, null when the
author's kind:0 isn't local yet). Applied to every verb that
returns notes: searchNotes / getRecentFromFollows / getNotesByUser /
searchByHashtag / getMyRecentNotes / getMyMentions /
getRepliesToNote / searchArticles.
* DmMessage gains fromDisplayName + sentByMe — the latter lets
the caller distinguish "Alice said X" from "I said Y" when both
appear in the same thread snapshot.
* LiveStreamHit gains streamingUrl (was missing entirely — without
it the verb is useless, you can't watch a stream you can't open)
plus hostDisplayName.
* getProfile cache-hit path now actually populates `about` — was
silently null before because the early-return branch didn't read
it out of UserInfo. Cache-miss path was always correct.
Implementation: one `displayNameOf(HexKey): String?` helper reads from
Amethyst.instance.cache (LocalCache) — the same cache the foreground
UI uses. Zero allocations beyond the lookup, no network round-trip.
Now exposing the full read-only Nostr surface to Gemini. Seven new
@AppFunction methods on top of the previous eight:
* getMyRecentNotes(limit) — author=me filter on kind:1.
* getMyMentions(limit) — p-tag=me filter on kind:1. "Did anyone @ me?".
* getRepliesToNote(eventId, limit) — e-tag=eventId filter on kind:1.
Pair with getMyRecentNotes(1) for "did anyone respond to my last post?".
* getZapsReceived(hoursBack) — drains kind:9735 receipts addressed to
the user in the window, parses the bolt11 invoice from each, sums
sats. Returns total + zap count + unique zappers + count of
receipts whose bolt11 was unparseable.
* getRecentDms(peer?, hoursBack, limit) — NIP-17 gift-wrap drain +
unwrapAndUnsealOrNull decrypt. kind:14 text DMs only for v1 (skip
kind:15 encrypted-file headers to keep payloads bounded). Widens
the `since` filter by 2 days for NIP-59's randomised-past
created_at trick, then trims back to the requested window.
* searchArticles(query, limit) — same as searchNotes but kind:30023
long-form articles. Content snippet truncated at 2000 chars so a
book-length article doesn't blow up the AppFunctions response;
Gemini can ask the user whether to fetch the full article via a
different verb.
* getLiveStreams(limit) — NIP-53 kind:30311 with status=live (uses
quartz's 8-hour staleness guard via LiveActivitiesEvent.isLive).
Returns title, summary, host npub, start time, event id.
Plus updated res/xml/app_metadata.xml so Gemini's tool picker pitches
the full surface to users.
KSP-verified — 15 verbs total in the generated dispatcher:
getActiveAccountInfo getFollowing getLiveStreams
getMyMentions getMyRecentNotes getNotesByUser
getProfile getRecentDms getRecentFromFollows
getRepliesToNote getZapsReceived searchArticles
searchByHashtag searchNotes searchProfiles
Write verbs (post, follow, zap, sendDm) still deferred behind the
signer-prompt plan in amethyst/plans/2026-05-25-appfunctions-signer-prompts.md
— no behavior change there.
After the on-device round-trip proved the AppFunctions plumbing works,
adding the verbs that make Gemini actually useful for a Nostr user.
All read-only, no signer interaction, all build on existing actions /
Account state.
* getRecentFromFollows(limit) — "what's happening on Nostr today?"
Drains recent kind:1 from people the user follows; same relay set
the home-feed UI uses (account.homeRelays).
* getNotesByUser(user, limit) — "what did Vitor post recently?"
Accepts npub or 64-hex. Prefers the target's NIP-65 write relays
when cached, falls back to the active account's home relays.
* getProfile(user) — "who is npub1xq5...?". Cache-first via
LocalCache; falls back to a short network drain for unseen users.
Returns GetProfileResult{found, profile} so callers know whether
the user just isn't in cache or doesn't have a kind:0 yet.
* searchByHashtag(hashtag, limit) — "find Nostr posts about Bitcoin".
NIP-12 `t` tag filter, lowercased to match the client convention.
* getActiveAccountInfo() — "who am I logged in as?" Diagnostic verb
returning npub, display name, follow count, outbox + DM relay
counts. Distinguishes signed-in from signed-out via a flag rather
than a magic empty result.
Plus:
* decodeUserOrThrow helper for npub/hex parsing, throws
AppFunctionInvalidArgumentException with a typed message so
callers see "expected npub1… or 64-char hex" instead of a stack.
* TextNoteEvent.toNoteHit helper — extracted from the existing
searchNotes path to avoid duplication.
* Updated res/xml/app_metadata.xml description so Gemini's tool
picker can pitch a broader summary to the user.
KSP-verified: $AmethystAppFunctions_AppFunctionInvoker now dispatches
all eight verbs (the three from the previous commits plus these five).
After fixing the missing aggregated XML, Pixel 8 logcat still showed:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Comparing against Google's FilipFan/AppFunctionsPilot sample turned up
a separate metadata pointer the system requires:
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
This goes on the <application> element (not the service) and points to
an XML resource — distinct from the asset-side `app_functions.xml`
that the library auto-merges onto the service. The asset metadata
declares "here are my function ids and schemas"; the resource
metadata gives the agent a user-facing summary like "Search Nostr and
read your follows" to show users before they grant access.
Without the resource, the system can find our service and our
function list but can't resolve the descriptive metadata it shows
the user — so Gemini's tool picker stays empty.
Two new files:
* amethyst/src/play/res/xml/app_metadata.xml — short description +
displayDescription. Update when the @AppFunction surface grows.
* play AndroidManifest <property> pointing at the resource.
Also dropped our explicit <service> declaration for
PlatformAppFunctionService — confirmed via the appfunctions-service
AAR that the library auto-merges that exact entry, complete with
permission + intent-filter, so our copy was redundant.
The androidx.appfunctions-compiler runs in per-module mode by default,
emitting only the dispatcher Kotlin code. The aggregator that builds
the `app_functions.xml` + `app_functions_v2.xml` assets is gated behind
a KSP argument that was off.
Symptom on a Pixel 8 running our APK:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Without the aggregated asset, the manifest's
`android.app.appfunctions` property pointed at a file that didn't
exist; the System UI couldn't enumerate our @AppFunction methods so
Gemini's tool picker never saw them.
Setting `appfunctions:aggregateAppFunctions = "true"` on the amethyst
module turns the aggregator on. Verified post-build:
assets/app_functions.xml (688 bytes — manifest pointer + ids)
assets/app_functions_v2.xml (19.7 KB — full schemas + kdoc descriptions)
Both list searchProfiles / searchNotes / getFollowing with the kdoc
descriptions Gemini will render.
Library modules (commons/quartz) would set this to "false" — only the
final app emits the aggregate. We don't currently apply the KSP plugin
in any library module, so this is the only place that matters.
Two follow-up cleanups from the audit.
Base64Image.parse: when the regex matched but the data capture group
was missing, the migrated version returned an empty ByteArray. The
original threw NPE (java.util.Base64.getDecoder().decode(null)). Both
behaviors are accidents — restore the intended contract: throw the
existing "Unable to convert base64 to image" Exception explicitly.
FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat
test that pins the byte-exact JSON output for a representative
multi-field feed. The legacy-Jackson migration claimed byte-identity
but only round-trip and reverse-compat were covered. Any future change
to field ordering / null handling / number formatting now fails this
test loudly, protecting users who have saved feeds on disk and any
downstream consumer expecting the stable order.
Pure text paragraphs now render as a single Text composable instead
of individual words in FlowRow, eliminating unwanted inter-word gaps.
Co-Authored-By: Claude <noreply@anthropic.com>
Type @ followed by a name in the compose/reply dialog to see a
dropdown of matching users from the local cache. Selecting a user
inserts their nostr:npub reference. Shows avatar + display name +
truncated npub.
Co-Authored-By: Claude <noreply@anthropic.com>
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
Pre-stages the three iosMain actuals that the macOS CI run is most
likely to demand once it compiles :commons for Native (the dev
container can't extract the K/N LLVM toolchain to validate locally).
- KmpLock.ios.kt: NSRecursiveLock — mirrors the ReentrantLock
semantics the jvmAndroid actual exposes (reentrant per-thread).
- WeakReference.ios.kt: actual typealias to kotlin.native.ref.
WeakReference<T> — same constructor + get(): T? shape as the
jvmAndroid typealias to java.lang.ref.WeakReference<T>.
- UrlValidation.ios.kt: NSURL.URLWithString with an explicit scheme
check, since NSURL is more permissive than JVM's URI.toURL() and
accepts scheme-less relatives that the JVM contract rejects.
Lands together so the next CI run's failure mode (if any) is more
informative than "iosArm64 unresolved reference" three times over.
Read-only surface for the Gemini bridge now covers profiles, notes, and
the active account's follow set:
* searchNotes(query, limit) — NIP-50 search over kind:1 short text
notes via SearchActions.searchNotesFilter + INostrClient.fetchAll.
Returns NoteHit list (eventId, npub, content, createdAt). alpha09
of androidx.appfunctions doesn't support List<Int> parameters, so
no caller-configurable kinds — kind:1 only for now.
* getFollowing(limit) — reads account.kind3FollowList.userList.value
(already resolved through LocalCache) and projects to FollowedUser
with display-name / nip05 / picture from cached kind:0. Reports
totalFollowing so the caller knows when limit truncated the list.
Both verbs follow the searchProfiles pattern: snapshot active account +
client at entry, never re-query sessionManager during the dispatch.
Plus amethyst/plans/2026-05-25-appfunctions-signer-prompts.md —
design plan for write verbs. Three signers (Internal / Remote /
External), three different latency + interaction models. Concrete
proposal: Internal first via postNote pilot, Remote as a follow-up,
External via PendingIntent (Option A) or NotSupportedException
(Option B — recommended for v1) depending on what bundle keys the
system shell respects. Open questions enumerated so the experiment
day is bounded.
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.
Three changes that bring commons/commonMain to zero java.* imports
(down from 18 at the start of Phase 2).
- EventListMatchingFilter, NoteListMatchingFilter: moved to jvmAndroid.
Both use ConcurrentSkipListSet + SortedSet for ordered concurrent
iteration, and their only consumer is LocalCache in the Android app.
iOS-time we can revisit if a KMP ordered concurrent set is needed.
- Note.kt's BigDecimal: switch import from java.math.BigDecimal to
quartz's existing expect/actual com.vitorpamplona.quartz.utils.BigDecimal.
BigDecimal.ZERO -> BigDecimal(0); BigDecimal.valueOf(longVal) ->
BigDecimal(longVal) (the expect class already has the Long
constructor). NoteOnchainZapTest gets the same treatment.
- Adds two top-level extensions in quartz commonMain (separate
BigDecimalOps.kt file to avoid the duplicate-JVM-classname collision
with the existing BigDecimal.kt actuals):
operator fun BigDecimal.plus(other: BigDecimal)
operator fun BigDecimal.minus(other: BigDecimal)
Lets += / + / - continue to work on commonMain BigDecimal values.
Commons/commonMain is now structurally iOS-ready as far as the
java.* import audit can tell. Remaining iOS work: actually flip on
the iOS targets, see what UI / dep transitives break, and address.
Fourth verb extraction alongside FollowActions / SearchActions /
ZapActions. Closes the largest remaining amy-expert "thin assembly"
violation in cli/.
Two pieces moved out of cli/.../DmCommands.kt into commons:
* DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65-
read → bootstrap fallback policy the in-app flow uses. Returns a
DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ /
BOOTSTRAP / NONE) so callers can surface the source — amy emits
it on stdout, a future Gemini adapter could mention it in the
assistant response.
* DmActions.buildTextDm / buildFileDmReference are thin wrappers
over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17
that build the kind:14 / kind:15 template and gift-wrap in one
call. Matches the FollowActions / ZapActions builder shape.
amy's DmCommands is now genuinely thin assembly: requireUserHex,
flag plumbing, call DmActions, render JSON. The 583-line file shrank
slightly and — more importantly — no longer carries NIP-17 logic
the rest of the codebase needs to look at.
Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in
amy; too small to extract and tightly coupled to amy's per-relay
attribution.
10 new tests for DmActions: strict/permissive fallback chain, null
recipient lists, RelaySource enum stability, and a smoke test that
buildTextDm produces a kind:14 with the right wrap count (sender +
recipient).
Clears the two java.net.* importers from commons/commonMain.
- Adds expect fun isValidUrl(url: String?): Boolean in
commons/commonMain/util/. The jvmAndroid actual preserves the
exact JVM semantics (URI.toURL() + the same 3 catch arms);
iOS actual will use NSURL when the target lands.
- RichTextParser.isValidURL becomes a thin wrapper around
isValidUrl. Keeps the existing static call site so callers in
the Android app and Desktop need no change.
- UrlInfoItem.kt (link-preview model that wraps URI) moves to
jvmAndroid; its only consumers are the Android link-preview
pipeline (HtmlCharsetParser, UrlPreviewState, UrlPreviewCard),
which already live outside commonMain.
commons/commonMain is now down to 3 java.* importers: Note
(BigDecimal) and the two SortedSet-based observables.
Clears the remaining easy iOS blockers in commons/commonMain by
relocating files whose underlying feature isn't iOS-ready yet, rather
than fabricating expect/actuals we won't need until that feature ships.
- NestViewModel + ActiveSubscription: depend on :nestsClient (audio
rooms — Phase 5 per the iOS plan). Moved as-is; both already lived
in a jvmAndroid-shaped package.
- HtmlCharsetParser: depends on java.nio.charset.Charset, used only
by the Android link-preview pipeline (no Desktop / iOS consumer
today).
- RenderMarkdown: depends on com.halilibo.richtext.* — needs iOS
artifact verification before it can return to commonMain (tracked
for Phase 3).
- MediaContentModels.kt is split:
* URL-based models (BaseMediaContent, MediaUrlImage/Video/Pdf,
EncryptedMediaUrlImage/Video) stay in commonMain — pure KMP, no
java.io.File reference.
* Locally-cached variants (MediaPreloadedContent, MediaLocalImage,
MediaLocalVideo) move to a new MediaLocalContent.kt under
jvmAndroid — they hold a java.io.File and call .exists().
After this PR commons/commonMain has 5 remaining java.* importers
(Note's BigDecimal, the two SortedSet observables, URL parsing in
RichTextParser + UrlInfoItem). Down from 18 at the start of Phase 2.
quartz already ships an extension that does exactly what
AmethystAppFunctions.drain reimplemented — subscribe with the given
filters, collect events until every relay sends EOSE / closed /
cannot-connect or the timeout elapses, unsubscribe, dedup by id,
return sorted newest-first. See
quartz/.../relay/client/accessories/NostrClientFetchAllExt.kt.
Replacing the local drain with `client.fetchAll(filters, timeoutMs)`
trims 70+ lines of subscription listener boilerplate and gives the
adapter the same behavior the rest of the codebase already trusts.
amy's Context.drain stays — it adds per-event signature verification
and persistence to the file event store (the trust boundary for amy)
that fetchAll doesn't do.
The previous wiring forced Amethyst to be `open`, added a 30-line
PlayAmethyst subclass that only implemented AppFunctionConfiguration
.Provider, and used tools:replace="android:name" in the play manifest
to swap classes. The justification was that the appfunctions runtime
discovers @AppFunction host classes via Application.appFunctionConfiguration.
Reading the KSP-generated dispatcher
($AmethystAppFunctions_AppFunctionInvoker.kt) shows that's only half
true. The invoker passes a default-construction fallback lambda when
instantiating the host class, and ConfigurableAppFunctionFactory takes
that fallback as a constructor argument. Provider is only consulted to
*override* construction — required for classes with non-default
constructors, optional otherwise.
AmethystAppFunctions has a no-arg constructor, so:
* PlayAmethyst is deleted entirely
* Amethyst goes back to `class Amethyst : Application()` (no `open`)
* Play manifest reverts to plain `android:name=".Amethyst"`, no
tools:replace gymnastics
Verified by assemblePlayDebug (APK builds clean) and the merged play
manifest still pinning the appfunctions service. If we ever add a
host class with constructor parameters (an Account-injected one, say),
we'll need to add Provider back — kdoc on AmethystAppFunctions
documents that.
Clears the last of the JVM-only synchronization annotations from
commons/commonMain so the model layer can compile on iOS. 15
methods across 4 files migrated.
- @Synchronized -> KmpLock.withLock { } with one per-instance syncLock
field per class. Original semantics preserved: @Synchronized on
methods of the same class synchronized on `this`, and a single
per-instance KmpLock gives the same exclusion.
* Channel.kt: addRelaySync, createOrDestroyFlowSync
* Chatroom.kt: addMessageSync, removeMessageSync
* MarmotGroupChatroom.kt: placeholderNote, addMessageSync,
restoreMessageSync, removeMessageSync, clearAllMessagesSync
* Note.kt: innerAddZap, innerAddOnchainZap,
innerRemoveOnchainZapForSource, innerAddZapPayment, addRelaySync,
createOrDestroyFlowSync
- Note.kt's @Volatile fields: now use kotlin.concurrent.Volatile
(KMP) instead of kotlin.jvm.Volatile (JVM-only) via explicit
import. Volatile semantics preserved on every target.
NestViewModel.kt also uses @Volatile (and the nestsClient project
dep); that file moves to jvmAndroid in a separate PR as planned
(audio rooms is Phase 5).
Closes the remaining items from the comparative review of the extracted
actions against the in-app Amethyst flows. All small, all surfaced by the
review.
* amy follow now stamps the relay hint on new contact-list `p` tags.
Best-effort read from the target's cached kind:10002 advertised
relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() —
follows added via amy no longer have empty relayUri.
* amy search user now dedups by pubkey (sorted newest-first) instead
of by event id, matching the App Functions adapter. Multiple relays
surfacing different kind:0 revisions for the same author collapse
to one hit.
* AmethystAppFunctions.searchProfiles captures the active account AND
the relay client at function entry, then never touches sessionManager
or Amethyst.instance again during the drain. Closes the account-
switch race surfaced in the review.
* FollowActions / SearchActions / ZapActions kdoc now lists the
caller-side responsibilities each builder leaves to the consumer
(publish, writeable check, relay hint, pseudo-kind filtering,
LN round-trip, receipt verification, etc.). Documents the design
rather than letting it leak through reviews.
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).
The previous ZapActions.buildEventZapRequest signed a single zap request
to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53
live-activity host tags, or NIP-89 app-definition metadata expect the
payment to be distributed across multiple parties — so `amy zap event`
silently overpaid one party and underpaid the rest. The correctness
review on the action-set flagged this as the only real bug in the
extracted verbs; this commit fixes it.
* ZapSplitResolver — new commonMain object mirroring the resolution
order in ZapPaymentHandler.kt (splits > live-activity hosts > app
metadata > author fallback). Pure logic; pubkey→LN-address lookup
is passed in as a suspend lambda so amy reads from its file store
and Android reads from LocalCache, no shared cache-coupling.
* ZapActions.buildEventZapRequestsForSplits — high-level helper that
composes the resolver with per-share LnZapRequestEvent signing.
Each request's `relays` tag unions sender + author + recipient
inbox relays so the kind:9735 receipt routes to every interested
party (matches signAllZapRequests in the Android handler).
* amy zap event — rewired to the split-aware path. JSON output now
enumerates each recipient with its share, LN address, request id,
and BOLT11 invoice (or per-recipient invoice_error). Profile zaps
(amy zap user) keep the simple single-recipient path since they
have no split tags.
Tests: 12 new cases — LN-address splits, weighted pubkey splits, author
fallback, drop-silently-on-missing-LN, relay unioning, share rounding.
All 41 action tests green; both Android flavors compile.
Phase 2 of the iOS plan — clears the java.lang.ref.WeakReference
blocker from commons/commonMain. Four model files migrated; one
additional sync primitive replaced.
- Adds expect class WeakReference<T : Any> in
commons/commonMain/util/, with a jvmAndroid actual that typealiases
to java.lang.ref.WeakReference. iOS actual will typealias to
kotlin.native.ref.WeakReference when the target is added.
- Channel / Chatroom / MarmotGroupChatroom: the WeakReference(null)
initializer relied on platform-type nullability of
java.lang.ref.WeakReference's constructor. With T : Any in the expect
class, fields become nullable (WeakReference<...>? = null) and the
.get() callsites become ?.get(). Behaviorally equivalent.
- UserRelaysCache: same WeakReference migration, plus the
synchronized(this) double-checked-locking idiom is replaced with
co.touchlab.stately.concurrency.Lock + withLock (KMP).
kotlin.synchronized is JVM-only; Lock comes in transitively via
stately-concurrent-collections already added in the previous PR.
Model-layer @Synchronized usage in Channel/Chatroom/MarmotGroupChatroom/
Note (also JVM-only) is a separate iOS blocker and a separate PR.
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.
Phase 2 of the iOS plan — two of the ~9 small migrations to clear
java.* imports out of commons/commonMain.
- ChessLobbyState: the AtomicLong stateVersionCounter only existed to
bump a MutableStateFlow<Long>. MutableStateFlow.update is itself
atomic, so the counter is redundant — replaced with
_stateVersion.update { it + 1 }. Removes the dep and simplifies the
code.
- SigningState (GlobalSigningStatus): AtomicInteger is doing real
cross-thread coordination. Migrated to kotlin.concurrent.atomics.
AtomicInt (KMP stdlib). The common-API method names differ from
AtomicInteger — addAndFetch(±1) / store(0) instead of
incrementAndGet / decrementAndGet / set.
Phase 2 of the iOS plan — first of ~9 small migrations to clear the
java.* imports out of commons/commonMain. Replaces java.util.Base64
with kotlin.io.encoding.Base64 (stdlib, KMP-clean). The two callers
(Android Base64Fetcher, Desktop DesktopBase64Fetcher) use the public
parse() signature only, which is unchanged.
Also documents the full Phase 2 audit in
amethyst/plans/2026-05-24-ios-support.md: out of 335 commonMain files,
21 are real iOS blockers grouped into ~10 small mergeable PRs. The
remaining 183 androidx.compose users and 7 androidx.lifecycle users
already map to JetBrains Compose Multiplatform / AndroidX KMP and need
no work.
Rewrites the policy/terms doc with three goals:
1) **Concise & easier to read.** Plain English, short sentences,
removed redundant intros (the "How Amethyst Works (and Why That
Matters Here)" block restated the Privacy intro), merged the
"Visibility" + "Permanence" sections into one paragraph, and
collapsed the Child Safety POC section into a single contact
block near the top of the document.
2) **More truthful.** Two corrections:
- F-Droid build uses UnifiedPush for notifications, not FCM. The
previous text only mentioned Google Firebase Cloud Messaging,
which was inaccurate for the F-Droid distribution.
- Replaced "We rely on Google Play's age verification to make sure
the user downloading the app is an adult" with "Amethyst's Google
Play listing is rated 17+. The app does not request or store age
information." Google Play does not actually verify user age, so
the old wording overstated the protection.
3) **Lower liability.** Several specific changes:
- Dropped the "We aim to acknowledge child-safety reports within
72 hours" service-level commitment that the solo developer cannot
reliably meet.
- Softened "we will recommend that the offending relay be removed"
and "What we can do: acknowledge the report, forward..." to
discretionary "may forward" / "may stop recommending" phrasing.
- Removed the absolute "data is strictly confidential and cannot
be accessed by other apps" guarantee. Replaced with the narrower,
verifiable claim that other apps cannot read app-local storage
on a standard, non-rooted Android device.
- Narrowed "Amethyst is built and distributed to comply with
applicable child safety laws and regulations" to "Amethyst is
distributed under Google Play's Child Safety Standards policy and
applicable law" — same in spirit, smaller surface for dispute.
Content that the Google Play Child Safety Standards checklist
requires is unchanged: explicit CSAE prohibition, child-safety point
of contact (amethyst@vitorpamplona.com), in-app feedback mechanism
(Report Post / Report Account / Block Post / Block Account / Block
Relay / Mute), method for addressing CSAM (in-app report → block
relay → NCMEC/INHOPE → optional developer notice), compliance
statement, and references to the app name "Amethyst" and the Google
Play publisher "Vitor Pamplona". The F-Droid carve-out also remains:
the MIT License in LICENSE is identified as the only instrument
governing source-built distributions, with no additional terms.