A batchSize <= 0 made the SQLite resumable reindex select no rows yet
never report done, so a caller's loop would spin forever. Clamp the page
size to at least one in both stores and add a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
A full FTS rebuild can run for a long time on a big store, so add a
resumable, batched overload alongside the one-shot:
reindexFullTextSearch(resumeFrom: String?, batchSize): FtsReindexProgress
Each call processes ~batchSize events in its own write transaction and
returns an opaque cursor + done flag. The caller loops until done and may
stop at any point — the cursor is durable across crash/app-restart, and
the writer lock is released between batches, so "pause" is just "don't
make the next call". The path is additive/refresh and keeps search usable
throughout (no up-front wipe); the one-shot variant remains for a
guaranteed-clean rebuild.
- SQLite: FullTextSearchModule.reindexBatch walks event_headers ordered
by the monotonic row_id (a free, stable cursor), restricted to
searchable kinds, delete-then-insert per event so batches are
idempotent and never duplicate rows.
- Filesystem: FsEventStore walks one idx/kind/<k>/ dir per step (linear,
no re-sort); cursor is the next searchable kind. Idempotent linkFts, so
nothing is wiped. Pauses between kinds.
- Wrappers delegate; new FtsReindexProgress value type carries cursor +
progress + done.
- cli: `amy store reindex-fts` now loops the batched path to completion
and reports processed/batch counts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
When Tor is Active but every Tor-routed relay fails (the ExitTimeout /
RESOLVEFAILED barrage), onTorCircuitsDead() now does a warm reset() —
drop the in-process client to rebuild the circuit pool with a fresh exit
draw, while keeping guards and the consensus cache — instead of
resetWithCleanState().
The failure is exit-side, not entry-side: circuits build fine, but the
exits can't reach the relays. Wiping arti/state/ + cache can't improve
exit selection (exits aren't persisted) and only forces a ~60s cold
bootstrap — exactly the blackout that strands users on the
connection-failure dialog. A warm restart reconnects in ~5s.
The poisoned-guards safety net is preserved: the next start() re-runs
noUsableGuards(), so genuinely unusable guards are still wiped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The set of event kinds that implement SearchableEvent — and the text
each contributes via indexableContent() — is baked into the quartz
build, so it changes across app versions. Events stored under older code
keep their old (or missing) NIP-50 full-text-search rows, so search
silently misses them after an upgrade.
Add IEventStore.reindexFullTextSearch() so the app can wipe and rebuild
the FTS index from already-stored events when it has spare cycles.
Speed: only kinds that currently map to a SearchableEvent are scanned.
Kind alone selects the event class in EventFactory, so a single probe per
distinct kind is authoritative, letting us push a `kind IN (...)` filter
(SQLite) / skip whole idx/kind dirs (filesystem) so the non-searchable
bulk — reactions, zaps, follow lists — is never deserialised.
- SQLite: FullTextSearchModule.reindexAll drops+recreates the virtual
table (O(1) wipe) then streams only searchable-kind rows in one write
transaction, reusing a single INSERT statement.
- Filesystem: rebuilds only idx/fts/, driving the walk from
idx/kind/<searchable kind>/ via the new FsIndexer.linkFts.
- Wrappers (EventStore, ObservableEventStore, InterningEventStore)
delegate; the observable layer emits nothing since no event changes.
- cli: `amy store reindex-fts`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
ChatMessageEvent (kind 14) is the decrypted NIP-17 rumor; its content is the
plaintext message. Index it so DMs are locally searchable, consistent with
messaging-client search. Opt-in confirmed by the maintainer; private zaps
(9733) remain excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Restructure the experimental roadstr events to mirror the nip88Polls
file layout, and wire the kinds into LocalCache (previously unhandled,
so they hit the "Event Not Supported" fallback and were dropped).
- Split into report/ and confirmation/ sub-packages, each with
XEvent.kt + TagArrayBuilderExt.kt + TagArrayExt.kt + tags/, matching
nip88Polls/{poll,response}. build() now uses eventTemplate + typed
builder-ext functions; accessors delegate to TagArray ext functions.
- Bundle each enum into its tag class (RoadEventType in RoadEventTypeTag,
RoadEventStatus in RoadEventStatusTag), like PollType in PollTypeTag.
- Add RoadReportTag (a GenericETag `e`-reference) mirroring PollTag, and
have RoadEventConfirmationEvent implement EventHintProvider so the
referenced report is linkable — parallel to PollResponseEvent.
- Shared lat/lon/geohash tag codecs live in roadstr/tags/ with generic
builder/parse extensions.
- LocalCache: consume kinds 1315/1316 as regular events.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY
Zaps carry an optional human-readable message; index it cheaply:
- LnZapRequestEvent (9734): content (public zap comment). Private-zap messages
live encrypted in the `anon` tag, not content, so they stay out.
- LnZapEvent (9735): the comment is in the embedded zap request, which init{}
already parses into `zapRequest` unconditionally — so indexing
`zapRequest?.content` adds no extra parse cost.
- OnchainZapEvent (8333): content (optional message).
- NutzapEvent (9321): content (nutzap message).
LnZapPrivateEvent (9733) is left out: its content is a decrypted *private* zap
message (same privacy class as DMs) — deferred to an explicit opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Put an explicit newline in the plural so "+1\nreply" and "+3\nreplies" both
render the same way (count over word), instead of only the longer plural
wrapping under the width cap. Drops the now-unneeded widthIn constraint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Drop the oversized 55dp centering box that left an extra gap between the
avatar and the name. Use NoteComposeLayout's own metrics instead: 12dp
leading padding and a 10dp avatar-to-name gap, so the spacing around the
collapsed avatar matches an expanded note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Compose Multiplatform string resources don't use Android res/values
escaping, so \' rendered literally as didn\'t on the DM history card.
Use a plain apostrophe to match the sibling new_key_continue_button string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Quartz protocol support and Amethyst rendering for the Roadstr
decentralized traffic-reporting events (https://github.com/jooray/roadstr):
Quartz:
- RoadEventReportEvent (kind 1315) and RoadEventConfirmationEvent
(kind 1316), registered in EventFactory.
- RoadEventType (13 categories with client-side effective TTLs) and
RoadEventStatus enums; lat/lon/status tag codecs with 7-decimal
coordinate formatting; NIP-40 expiration + NIP-31 alt + multi-precision
geohash (4/5/6) tags.
- Generalize the GeoHash encoder/decoder into commonMain
(nip01Core.tags.geohash.GeoHash); the Android module keeps only the
Location <-> GeoHash glue.
- Unit tests for parsing, building, TTLs, coordinate formatting and the
geohash prefix hierarchy.
Amethyst:
- RenderRoadEventReport / RenderRoadEventConfirmation cards wired into
NoteCompose (feed) and ThreadFeedView/NoteMaster (thread), mirroring
the Birdex renderer pattern; reverse-geocoded location line and
localized category labels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY
Adds SearchableEvent to more text-bearing kinds:
- PollEvent (1068, NIP-88): question (content) + option labels
- CodeSnippetEvent (1337, NIP-C0): name, description + code
- GitReplyEvent (1622) and TorrentCommentEvent (2004): reply/comment content
- AudioHeaderEvent (1808): description (content)
- AudioTrackEvent (31337): subject
- InterestSetEvent (30015): title, description + public interest hashtags
(private hashtags in NIP-44 content are not indexed)
- SoftwareReleaseEvent (30063): release notes (content) — note: kind 30063 is
registered to NIP-51 ReleaseArtifactSetEvent in EventFactory, so this is for
completeness and not exercised at runtime.
CommunityRulesEvent (34551) was intentionally left out: it is the NIP-9B
machine-readable rules companion; the human-readable community rules live on
CommunityDefinitionEvent.rules(), which is already indexed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The test asserted exactly two EOSEs (`assertEquals(2, eoseCount)`) and
looped `while (eoseCount < 2)`. But a re-REQ on the same subscription id
silently replaces the previous subscription (NIP-01), and
RelaySession.handleReq cancels the in-flight query coroutine without
emitting an EOSE for the superseded filter. So when both mid-stream
re-subscriptions get collapsed before reaching EOSE, only the final,
never-superseded filter emits one — the consumer loop then blocks on
receive() until the 30s timeout and the assertion fails.
Drain until the relay goes quiet (idle-gap timeout) instead of counting
on a fixed number of EOSEs, and assert only invariants that hold for
every interleaving: at least one EOSE arrives, every non-EOSE entry is a
valid 64-char id, the final advertised-relay-list filter actually
streamed its events, and the total stays under a sane upper bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1bfPf4HZDCvwcfiEHECk
A collapsed reply now displays "+N replies" on the right side, counting the
descendant replies hidden underneath it, next to the expand indicator. The
count is computed in the same depth-first pass that builds the visible list.
Uses a <plurals> resource so the noun declines correctly across locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Adds SearchableEvent to the rest of the event kinds that carry
human-readable fields, indexing only natural-language values (no structural
labels), consistent with the existing implementations.
Public/discovery: relay groups (NIP-29), file/media headers (NIP-94), web
bookmarks (NIP-B0), podcasts (NIP-F4 episode + show), static sites (NIP-5a),
live clips and meeting rooms (NIP-53), contact cards (NIP-85), and the
NIP-51 curation sets (article/video/picture/app curation, media starter
pack, release artifacts).
Personal lists (NIP-51): bookmark/labeled-bookmark/follow/people lists and
relay sets — indexes the list's own title/description so users can find
their lists by name.
Experimental: software apps (NIP-82), fundraisers, interactive stories,
workout records, birdex, attestations, NIP-95 file storage header, profile
gallery.
content is indexed only where it is confirmed human-readable prose
(file caption, podcast notes, clip caption, app/fundraiser/story/workout
body). It is excluded where content is HTML (static sites), base64 (NIP-95),
NIP-44 encrypted (contact cards), an encrypted/JSON private-tag blob (lists),
or always empty — those index their parsed tag fields only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The "title: ", "summary: ", "name: ", "Subject: ", "Option: " etc. prefixes
were tokenized into the single FTS content column as literal words, so every
event of a type matched bare terms like "title" or "summary" and the index
carried useless tokens — the same field-name pollution we avoid for JSON
kinds. The FTS table has one content column and search is plain-text MATCH,
so the labels enabled no fielded search; they were pure noise.
Index bare field values instead (listOfNotNull(...).joinToString("\n")),
which also drops the "null" token the older one-liners produced for absent
fields. Applied across all SearchableEvent implementations for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Kinds 0 (profile), 40/41 (channel create/metadata) and 31990 (app handler)
store their data as JSON in content. Implement SearchableEvent on them by
parsing the JSON (via the existing UserMetadata/ChannelData/AppMetadata
accessors) and indexing only the meaningful fields — names, bio/about, and
the addresses people search by: nip05 email, lightning addresses
(lud06/lud16), and website/picture/banner URLs. This avoids indexing the
JSON keys and structural punctuation that raw-content indexing would add.
Updates the FsSearchTest "non-searchable" case to use an unknown kind, since
MetadataEvent is now searchable, and adds SearchTest coverage for profile
and channel JSON fields (name, about, email, lightning, URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Drops the _connectedRelays changes (the removeRelayInner prune and the
derived-projection refresh) and restores RelayPool to match main. The
incremental onConnected/onDisconnected maintenance is sufficient; the
user-visible background relay-count issues are addressed by the lifecycle
teardown timing and the notification-update throttle, not here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
The channel metadata save/create button is now disabled until the channel
has a non-blank name AND at least one relay. A public chat with no relay
declares an empty relay list, which leaves messages with no reliable home
(the root cause behind the relay-targeting issue). Gating the button at the
screen level keeps the relay-list StateFlow reactive (a ViewModel
derivedStateOf would not recompose on relay add/remove).
Also reverts the temporary PublicChatRelayDebug diagnostic logging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
Replaces the incremental add/remove maintenance of _connectedRelays
(including the removeRelayInner prune) with a recompute from the source of
truth: a relay is connected iff it is in the pool AND its socket reports
ready (isConnected()). refreshConnectedRelays() runs on connect, disconnect
and pool-membership changes.
The earlier prune patched the *readout* on the assumption that "removed
from pool ⟹ disconnected", which is only incidentally true. A set that is
hand-maintained per event drifts from reality whenever an event is missed —
OkHttp's async cancel() callback being dropped under mass teardown, or a
socket dying without an onDisconnected. Projecting the set from each pooled
relay's actual isConnected() can't drift: removed relays are already
disconnected so they fall out, and a silently-dead socket stops being
counted on the next refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Reverts the event_fts rowid-alignment refactor and the background reindex
that it required. Aligning the FTS rowid with event_headers.row_id was a
schema change, which forced a v2->v3 migration to rebuild the index from
~all cached events — and on large caches that reindex was the expensive,
risky part (slow startup, all-or-nothing transaction, resumability and
malformed-row concerns). The cleanup it bought (not tokenizing the numeric
foreign key into the index) isn't worth that cost.
Restores the original design: event_fts keeps its dedicated
event_header_row_id column, queries join on it, DATABASE_VERSION stays 2,
and there is no FTS migration or reindex at all.
Kept: the newly searchable event kinds (they implement SearchableEvent and
work unchanged with the original table) and their test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The v2->v3 upgrade previously rebuilt the entire full-text index inside
the migration transaction. With a large cache (e.g. 100k events) that
blocked every DB operation behind a single long transaction at startup:
the app appeared frozen, risked an ANR if reached on the main thread, and
— because it was all-or-nothing with the version bumped only on success —
a crash, kill, or one malformed cached row could roll everything back and
retry from scratch on every launch (worst case: an unrecoverable boot loop)
while the WAL ballooned.
Decouple the reindex from the migration:
- The migration now only recreates the empty FTS table and writes a
persistent `fts_reindex` marker holding a progress cursor, then bumps the
version. It is cheap and atomic.
- A background coroutine (Dispatchers.IO, cancelled on close()) backfills the
index from event_headers in small committed batches via useWriter, so live
relay inserts/queries interleave between batches instead of waiting.
- Backfill is idempotent (INSERT OR IGNORE), resumable (cursor persists, so a
kill resumes on next launch), and resilient (a row that fails to parse/index
is skipped while the cursor still advances — no stuck retries).
Search is merely degraded (partial results) until the backfill finishes,
never blocked. Adds a test covering backfill + marker clearing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
removeAllRelays() has no call sites — it's dead code — so clearing
_connectedRelays there was never exercised. The live fix for the stale
connected count is the prune in removeRelayInner (driven by updatePool),
which keeps removeAllRelays untouched relative to main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Strips the BgRelayTrace instrumentation added while diagnosing the
background relay-count issues and restores the production grace period.
- LifecycleAwareKeyDataSourceSubscription: UNSUBSCRIBE_GRACE_MILLIS back to
30s, drop the per-subscription label + logs, refresh the doc to describe
the LifecycleEventObserver detection.
- RelayPool: drop updatePool trace logs and the now-unused Log import; keep
the _connectedRelays prune (with a trimmed comment).
- BaseEoseManager: drop the per-assembler relay-count log + Log import.
- SubscriptionController: drop activeRelays(), which only fed that log.
The actual fixes stay: lifecycle-observer teardown detection, the
connected-set prune, and the notification-count throttle + fg/bg wording.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
A device log showed the persistent notification stuck on a stale count
(e.g. "44 inbox relays") while the pool had actually settled lower
(flowConnected=8). Cause: the count collector posted the notification on
every connectedRelaysFlow delta — ~90 updates during feed load, then ~22
in ~250ms during background teardown. Android rate-limits notification
updates (~10/s) and silently drops the excess, so the last value the
framework rendered (a mid-cascade 44) stuck instead of the final 8.
Sample connectedRelaysFlow at 1s before updating the notification. That
caps updates to ~1/s — comfortably under the limit — and the settled
count always lands. Also drops the now-confirmed notif-collector/
notif-popup debug logging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae