diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7a15cb83cc..e0a074e5f7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,7 +3,7 @@ ## Project Overview Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching -over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `amethyst`, +over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should contain implementations of Nostr specifications and utilities to help implement them. Commons stores shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop @@ -48,11 +48,18 @@ amethyst/ │ ├── androidMain/ # Android-specific (crypto, storage) │ ├── jvmMain/ # Desktop JVM-specific │ └── iosMain/ # iOS-specific -├── commons/ # Shared UI components (convert to KMP) +├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe │ └── src/ -│ ├── commonMain/ # Shared composables, icons, state -│ ├── androidMain/ # Android-specific UI utilities -│ └── jvmMain/ # Desktop-specific UI utilities +│ ├── commonMain/ # Domain models, state holders, ViewModels, services +│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop +│ ├── androidMain/ # Android-specific actuals (Keystore, DataStore) +│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline) +├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources) +│ └── src/ +│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts) +│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers +│ ├── androidMain/ # Android Coil bridge +│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS) ├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport) │ └── src/ │ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine @@ -70,12 +77,20 @@ amethyst/ **Sharing Philosophy:** - `quartz/` = Nostr business logic, protocol, data (no UI) -- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the - headless `cli`): domain models, state holders, ViewModels, the relay client, - shared services, **and** the Compose UI that ≥1 GUI front end renders. The - package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?" - guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding - a new package or dropping code into `commons`. +- `commons/` = Shared **headless** code for every front end (Android, Desktop, + iOS, and the headless `cli`): domain models, state holders, ViewModels, the + relay client, shared services. It may use the Compose *runtime* + (`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or + Compose resources — the build enforces this: `commons` has no such deps. +- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders + (composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the + `composeResources` strings/fonts and the generated `Res` class). Depends on + `commons` (as `api`); `cli` never depends on it. Files keep their + `com.vitorpamplona.amethyst.commons.*` packages — the split is a module + boundary, not a package rename. The package taxonomy, the CLI-safe / UI + boundary, and a "where does my code go?" guide are documented in + **`commons/ARCHITECTURE.md`** (+ `commonsUI/ARCHITECTURE.md`) — read them + before adding a new package or dropping code into either module. - `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any KMP project that needs MoQ. Has no Android-framework dependencies. - `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport, @@ -88,7 +103,7 @@ amethyst/ - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation - `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed). May also depend on `:geode` (for `amy serve`, which embeds the - standalone relay); never on `:amethyst` or `:desktopApp`. + standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`. **Plans per module:** design docs for new subsystems live in the owning module's `plans/YYYY-MM-DD-.md` (e.g. `cli/plans/`, `commons/plans/`). @@ -180,16 +195,19 @@ etc. instead of re-implementing them. **Share vs keep platform-native:** -- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and - `commons/commonMain/` (major UI components, **ViewModels** under - `viewmodels/`, icons). ViewModels are platform-agnostic state + logic - (StateFlow/SharedFlow), so they belong in `commons`. +- **Share** → `quartz/commonMain/` (business logic, data models, protocol), + `commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders, + relay client, services — headless) and `commonsUI/commonMain/` (major UI + components, icons, theme). ViewModels are platform-agnostic state + logic + (StateFlow/SharedFlow), so they belong in `commons`; anything that imports + `androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in + `commonsUI`. - **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android `Activity`), navigation (sidebar vs bottom nav), platform interactions (gestures, keyboard shortcuts), system integrations (notifications, file pickers). -When extracting a composable: move it to `commons/commonMain/` (see +When extracting a composable: move it to `commonsUI/commonMain/` (see `/compose-expert`), add expect/actual for any platform behavior (see `/kotlin-multiplatform`), then point both Android and Desktop at the shared version. `quartz/` is protocol-only — no composables. @@ -216,7 +234,7 @@ version. `quartz/` is protocol-only — no composables. ## Dependency Licensing **MANDATORY whenever you introduce a new third-party dependency** — in *any* -module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`, +module (`quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, `quic`, `nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a module's `build.gradle.kts`: determine its license **before** wiring it in. Amethyst ships under the **MIT** license, so a copyleft dependency linked into a @@ -253,9 +271,9 @@ JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns. ## Icons The Material Symbols font bundled at -`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` +`commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` is a **subset** that only contains the glyphs referenced from -`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`. +`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`. **MANDATORY:** Whenever you add a new icon — i.e. introduce a `MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in @@ -274,21 +292,21 @@ regenerating. ### Amethyst's own icons are also a font -The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are +The icons in `commonsUI/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are **also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into a per-instance cached layer, so a feed re-rasterised the same glyph once per card; a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**, overrun P90 **-17.4%** on the feed scroll benchmark. -**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`, +**MANDATORY:** whenever you add or change an icon under `commonsUI/.../commons/icons/`, regenerate the font *and* its codepoint table together: ```bash python3 tools/icon-font/build_icon_font.py \ - commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \ - commons/src/commonMain/composeResources/font/amethyst_icons.ttf \ - commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt + commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \ + commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \ + commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt ``` Both outputs must be committed together: codepoints are assigned in filename order, diff --git a/.claude/hooks/compose_escaping_check.py b/.claude/hooks/compose_escaping_check.py index 1e83bf2e6f..cd9b054eef 100755 --- a/.claude/hooks/compose_escaping_check.py +++ b/.claude/hooks/compose_escaping_check.py @@ -21,7 +21,7 @@ that has to catch it. Repair with: python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\ - commons/src/commonMain/composeResources + commonsUI/src/commonMain/composeResources `--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is idempotent, quote-unwrapping is not, and a second unwrap strips the real display @@ -77,7 +77,7 @@ def main() -> int: print( "\nRepair:\n" " python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n" - " commons/src/commonMain/composeResources\n" + " commonsUI/src/commonMain/composeResources\n" "(--no-unwrap-quotes is mandatory on already-migrated files.)", file=out, ) diff --git a/.claude/skills/amy-expert/references/extraction-recipe.md b/.claude/skills/amy-expert/references/extraction-recipe.md index 2c3b2daa3f..f3edcc024f 100644 --- a/.claude/skills/amy-expert/references/extraction-recipe.md +++ b/.claude/skills/amy-expert/references/extraction-recipe.md @@ -47,7 +47,7 @@ Walk the imports. The usual offenders: | `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). | | `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. | | `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. | -| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. | +| `androidx.compose.*` | Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` must stay out of `commons` entirely — they belong in `:commonsUI`, which Amy never depends on. Only the Compose *runtime* (`@Stable`, snapshot state) is allowed in `commons`. | ### Step 3 — Pick a migration strategy per dependency @@ -66,7 +66,7 @@ Walk the imports. The usual offenders: # Target location depends on what it is: # - Protocol → quartz/src/commonMain/kotlin/… # - Business logic → commons/src/commonMain/kotlin/… -# - UI → commons/src/commonMain/… (needs Compose Multiplatform) +# - UI → commonsUI/src/commonMain/… (needs Compose Multiplatform; never used by amy) git mv amethyst/src/main/java/com/.../FollowListManager.kt \ commons/src/commonMain/kotlin/com/.../FollowListManager.kt ``` diff --git a/.claude/skills/compose-expert/SKILL.md b/.claude/skills/compose-expert/SKILL.md index 6b85114ae8..e6eced37b1 100644 --- a/.claude/skills/compose-expert/SKILL.md +++ b/.claude/skills/compose-expert/SKILL.md @@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop. ## Philosophy: Share by Default -**Default to `commons/commonMain`** unless platform experts indicate otherwise. +**Default to `commonsUI/commonMain`** (shared composables live in `:commonsUI`, the Compose half of the shared layer; headless state/ViewModels stay in `:commons`) unless platform experts indicate otherwise. ### Always Share @@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) { } ``` -**Components** (all in `commons/commonMain`): +**Components** (all in `commonsUI/commonMain`): - `LoadingState` - Progress indicator + message - `EmptyState` - Empty message + optional refresh button - `ErrorState` - Error message + optional retry button @@ -527,12 +527,12 @@ fun FeedList(items: List) { | Task | Pattern | Location | |------|---------|----------| -| Reusable UI | State hoisting | commons/commonMain | +| Reusable UI | State hoisting | commonsUI/commonMain | | Simple state | remember { mutableStateOf() } | Composable scope | | Derived state | derivedStateOf { } | remember block | | Async → state | produceState { } | Composable function | -| Custom icons | roboBuilder + PathData | commons/icons | -| Loading/Error | LoadingState, ErrorState | commons/ui/components | +| Custom icons | roboBuilder + PathData | commonsUI/icons | +| Loading/Error | LoadingState, ErrorState | commonsUI/ui/components | | Theme colors | MaterialTheme.colorScheme | Any @Composable | | Navigation | Delegate to platform expert | amethyst/, desktopApp/ | @@ -540,7 +540,7 @@ fun FeedList(items: List) { ### Creating a Shared Component -1. Start in `commons/src/commonMain/kotlin/.../ui/components/` +1. Start in `commonsUI/src/commonMain/kotlin/.../ui/components/` 2. Use Material3 primitives only 3. Hoist state (parameters for data, callbacks for events) 4. Add modifier parameter @@ -551,7 +551,7 @@ fun FeedList(items: List) { 1. Read current implementation in `amethyst/` or `desktopApp/` 2. Identify pure visual logic (no platform APIs) -3. Create in `commons/commonMain` with hoisted state +3. Create in `commonsUI/commonMain` with hoisted state 4. Replace platform implementations with shared component 5. Keep platform-specific wrappers if needed diff --git a/.claude/skills/compose-expert/references/shared-composables-catalog.md b/.claude/skills/compose-expert/references/shared-composables-catalog.md index 19f7938fd6..337fe06b99 100644 --- a/.claude/skills/compose-expert/references/shared-composables-catalog.md +++ b/.claude/skills/compose-expert/references/shared-composables-catalog.md @@ -1,11 +1,11 @@ # Shared Composables Catalog -This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`. +This catalog documents shared UI components in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/` (the Compose half of the shared layer; headless state stays in `commons`). ## Directory Structure ``` -commons/src/commonMain/kotlin/.../commons/ui/ +commonsUI/src/commonMain/kotlin/.../commons/ui/ ├── components/ # Reusable UI components ├── screens/ # Screen-level composables ├── theme/ # Theming and styling @@ -232,7 +232,7 @@ private val pathData1 = PathData { ``` **roboBuilder** - Custom ImageVector.Builder DSL -- Located in: `commons/robohash/` +- Located in: `commonsUI/.../commons/robohash/` - Pattern: Builder-based, composable paths - Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each) - Colors: Dynamic (fgColor parameter) + Black constants diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md index 1c99e335a9..57a9e9a7d0 100644 --- a/.claude/skills/event-store-semantics/SKILL.md +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason). kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning `created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01 lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored -row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract: -exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked. +row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED` +(`duplicate: a newer version of this replaceable event is already stored`), which the relay +session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same +reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken +by lowest id; older re-inserts blocked but acknowledged as already covered. **STORE-W02 — addressable supersession.** Same as W01 with unique index `(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the diff --git a/.claude/skills/feed-patterns/SKILL.md b/.claude/skills/feed-patterns/SKILL.md index aada17ce3c..ab8846db84 100644 --- a/.claude/skills/feed-patterns/SKILL.md +++ b/.claude/skills/feed-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: feed-patterns -description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. +description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. --- # Feed Patterns @@ -25,7 +25,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong │ ◄── MarmotGroupFeedViewModel │ │ │ │ │ -│ commons/.../ui/feeds/ (shared, KMP) │ +│ commons/.../feeds/ (shared, KMP) │ │ IFeedFilter / FeedFilter (abstract base) │ │ IAdditiveFeedFilter / AdditiveFeedFilter │ │ ChangesFlowFilter │ @@ -68,7 +68,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong ### Shared filter bases (commons) -`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`: +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/`: - **`FeedFilter.kt`** — `abstract class FeedFilter : IFeedFilter`. Has `feed(): List` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`. - **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter : FeedFilter(), IAdditiveFeedFilter`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything. @@ -100,7 +100,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, ## Filter Sharing (Android vs Desktop) -- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**. +- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**. - The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases. - When porting a feed, share the concrete filter only if both platforms need identical inclusion rules. diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index 50699978e5..b41533cf3e 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans ## Overview -Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them. +Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs the missing keys, then offers the two things that close them: **translate** the ones needing translation, and **copy the English value verbatim** for the ones a locale deliberately keeps in English (since 2026-09-12 that copy is what seeds Crowdin — see Background). The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below). @@ -24,9 +24,9 @@ There are two separate `strings.xml` trees, each with its own default `values/` | Tree | Default file | Per-locale file | |------|--------------|-----------------| | **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-/strings.xml` | -| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-/strings.xml` | +| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-/strings.xml` | -The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers). +The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers). **Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match. @@ -37,7 +37,7 @@ The `commons` tree appeared when shared event-renderer composables were extracte Detect name-overlap **and flag value mismatches** in one pass: ```bash -cdef=commons/src/commonMain/composeResources/values/strings.xml +cdef=commonsUI/src/commonMain/composeResources/values/strings.xml adef=amethyst/src/main/res/values/strings.xml comm -12 \ <(grep '"' commons/src/commonMain/composeResources/values-*/strings.xml +grep -nE '"' commonsUI/src/commonMain/composeResources/values-*/strings.xml # The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst. ``` @@ -67,15 +67,23 @@ grep -nE '"' commons/src/commonMain/composeResources/values **Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently. -## Background: Crowdin strip-identical behavior +## Background: source-identical translations and the `import_eq_suggestions` flag -This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated. +This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin does not *store* a translation that exactly equals the source unless it is told to, so historically a key a translator deliberately kept as English (brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, version prefixes like `"v%1$s"`) never appeared in the locale's `strings.xml`, even though the Crowdin UI showed it as 100% translated. + +**That changed on 2026-09-12.** `.github/workflows/crowdin.yml` now passes `import_eq_suggestions: true` to `crowdin/github-action`, so `upload_translations` no longer skips values equal to the source — whatever sits in the repo's locale files is seeded into Crowdin's database, identical values included. `auto_approve_imported` stays at its default `false`, so they arrive as **pending** translations for a translator to approve. + +Confirmed end-to-end the same day: the first sync after the flag landed (workflow run `34706537802` → PR #4107) rewrote all five touched locale files in Crowdin's own key order with **zero net key changes** — 323 additions and 323 removals that pair up exactly. All 330 identical values pushed that morning came back down intact, unapproved included. Since Crowdin's download *replaces* file content with its export, a value it did not hold would have vanished; none did. + +**Reading such a sync diff: compare key *sets* per file, never `-`/`+` lines separately.** A reorder looks identical to a mass strip under `grep '^-'`, and it will convince you the mechanism failed when nothing changed at all. What this means for this skill: -1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work. -2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical. -3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway. +1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is genuinely untranslated, *or* a source-identical entry stripped before 2026-09-12 that no sync has re-seeded yet. Both are reported, and both are now actionable in the repo — translate the first, copy English into the second. The Crowdin web UI ("N untranslated") remains the ground truth for what needs human work. +2. **Source-identical entries are still recognizable, but no longer skipped.** Brand terms (`Nowhere X`), loanwords (`Apps` / `Feed` / `Issues`), symbol- or format-only values (`v%1$s`, `+%1$d`, `%1$d/%2$d`, `∞`, 👀) and example placeholders (`iPhone 13`, `https://example.com`) are the usual cases. Copy the English value into the locale file verbatim so the upload can seed it. +3. **DO add source-identical values — that is now the mechanism, not churn.** A key absent from a locale file is invisible to `upload_translations`; writing the English value in is what gets it into Crowdin, so a translator approves it once in bulk instead of typing it into the UI ~70 times per locale. (Runtime behaviour is unchanged either way: Android still falls back to `values/strings.xml`.) Two exclusions: + - **Never for ``.** Copying English `one`/`other` into cs/pl trips `MissingQuantity`, which is a CI error (cs needs `one`/`few`/`many`/`other`). Plurals stay a Crowdin-UI job. + - **Not for words a locale would genuinely translate.** German `buzz_dm_workspace` ("Arbeitsbereich"), `workout` ("Training"), `relay_group_threads_title` ("Themen"), `calendar_rsvp_section` ("Zusagen") are *gaps*, not deliberate English keeps. Copying English there seeds a wrong pending suggestion — list those for the human to translate rather than approve. 4. **A repo-side edit to a translated value only sticks where Crowdin's database doesn't contradict it.** Download replaces file content with Crowdin's current @@ -96,6 +104,13 @@ What this means for this skill: from `values/strings.xml` removes it project-wide, and attributes declared there propagate into every export. + **This does not contradict item 3 — the two cases differ.** Seeding a key + Crowdin holds *nothing* for (the identical-value copy) sticks, because there is + no stored value to contradict it; that is exactly why the copy pass works. + *Overwriting* a value Crowdin already holds differently — including an empty + one — still loses on the next sync. Add missing entries in the repo; change + existing translations in the UI. + > **Historical note:** an earlier version of this skill tried to auto-filter the > candidate list with a git "sync-timestamp" heuristic (skip any key added before > the last `New Crowdin translations` commit). It was **dropped** because it @@ -132,14 +147,14 @@ Default: amethyst/src/main/res/values/strings.xml Target: amethyst/src/main/res/values-/strings.xml # commons tree -Default: commons/src/commonMain/composeResources/values/strings.xml -Target: commons/src/commonMain/composeResources/values-/strings.xml +Default: commonsUI/src/commonMain/composeResources/values/strings.xml +Target: commonsUI/src/commonMain/composeResources/values-/strings.xml ``` A convenient way to run the whole technique twice is to loop over the two base dirs: ```bash -for base in amethyst/src/main/res commons/src/commonMain/composeResources; do +for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources; do echo "########## tree: $base ##########" # ... run the diff/count/value-extraction commands with $base/values[...] ... done @@ -172,7 +187,7 @@ comm -23 \ This gives two lists of missing key names — keep them separate; `` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care"). -Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated": +Locale files are asymmetric — legacy pre-2026-09-12 strips and uneven translator progress both leave different keys missing in different locales — so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated": ```bash for locale in cs de-rDE sv-rSE pt-rBR; do @@ -190,7 +205,7 @@ for locale in cs de-rDE sv-rSE pt-rBR; do done ``` -The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background). +The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set: translate what needs translating, and copy the English value verbatim for the entries a locale keeps in English (see Background). ### 3. Get English values for missing keys @@ -258,8 +273,8 @@ Flag and offer to fix: # hardcode "1" (or other literal digits) instead of using a placeholder. # Looks at default + all values-* locales, in BOTH resource trees. for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \ - commons/src/commonMain/composeResources/values/strings.xml \ - commons/src/commonMain/composeResources/values-*/strings.xml; do + commonsUI/src/commonMain/composeResources/values/strings.xml \ + commonsUI/src/commonMain/composeResources/values-*/strings.xml; do awk -v file="$f" ' /]*>(.*?)', re.S) # (?' \ amethyst/src/main/res/values*/strings.xml \ - commons/src/commonMain/composeResources/values*/strings.xml + commonsUI/src/commonMain/composeResources/values*/strings.xml ``` Three things this scan taught us, all of which it now encodes: @@ -458,7 +473,7 @@ When adding translated strings to locale files: - **Append new strings at the bottom** of the file, just before the closing `` tag. - Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering. -- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union: +- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because locale files are asymmetric (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union: ```bash # For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE. @@ -528,15 +543,15 @@ When adding translated strings to locale files: ## Common Mistakes -- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately. +- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately. - **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees). - **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format. - **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass. - **Forgetting `translatable="false"`** — these should never appear in locale files - **Diffing only `` is a separate resource type; a source `` missing from a locale will never show up in a `` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `` if the project uses it. - **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead. -- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value. -- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results. +- **Skipping source-identical entries instead of copying them in** — correct before 2026-09-12, wrong now. With `import_eq_suggestions: true` the repo file is the *seed* for Crowdin's database, so a key you leave out stays untranslated in the UI forever and reappears in every future scan. Copy the English value verbatim, except for `` (trips `MissingQuantity`) and words the locale would really translate. (Confirmed by PR #4107: 330 identical values survived the next sync with zero net changes.) +- **Skipping per-locale diffs when only diffing cs** — different keys are missing in different locales (legacy strips plus uneven translator progress), so cs is not a reliable upper bound. Diff each target locale and union the results. - **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.) - **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.) - **Converting a `` to `` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`. diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md index c473298d07..bd21f16742 100644 --- a/.claude/skills/kotlin-multiplatform/SKILL.md +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -383,7 +383,7 @@ import com.fasterxml.jackson.databind.ObjectMapper | State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns | | **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** | | UI formatters (pure) | commons/commonMain | Reusable, no dependencies | -| UI components (simple) | commons/commonMain | Cards, buttons, dialogs | +| UI components (simple) | commonsUI/commonMain | Cards, buttons, dialogs (Compose UI never goes in `commons`) | | **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** | | Navigation | Platform-specific only | Activity vs Window too different | | Permissions | Platform-specific only | APIs incompatible | diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 136ae31af4..844a4f2a59 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.1` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.2` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.15.1" +quartz = "1.15.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.15.1") + implementation("com.vitorpamplona.quartz:quartz:1.15.2") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 63c34ba1ee..88f0eadc5b 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.15.1 +com.vitorpamplona.quartz:quartz:1.15.2 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.15.1" +quartz = "1.15.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.15.1") + implementation("com.vitorpamplona.quartz:quartz:1.15.2") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.15.1") + implementation("com.vitorpamplona.quartz:quartz:1.15.2") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 1b72fb6977..a7c6892b18 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c ## Layout -All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`: +All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer): ``` relayClient/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b5671c3f0f..7665ea2bb6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,16 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Linter (gradle) - run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity + # The three metadata compiles resolve commonMain against only the + # deps every target shares, which is the Apple classpath — a + # dependency that reaches JVM transitively (okio via OkHttp) but is + # missing for iOS fails here, on Linux, instead of in test-quartz-ios. + run: | + ./gradlew spotlessCheck \ + :quartz:verifyKmpPurity :commons:verifyKmpPurity :commonsUI:verifyKmpPurity \ + :quartz:compileCommonMainKotlinMetadata \ + :commons:compileCommonMainKotlinMetadata \ + :commonsUI:compileCommonMainKotlinMetadata build-desktop: needs: lint @@ -93,7 +102,7 @@ jobs: - name: Test + Build Desktop (gradle) run: | - CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}" + CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}" if [ "${{ runner.os }}" = "Linux" ]; then xvfb-run --auto-servernum $CMD else @@ -129,6 +138,7 @@ jobs: path: | quartz/build/reports/tests commons/build/reports/tests + commonsUI/build/reports/tests nestsClient/build/reports/tests cli/build/reports/tests desktopApp/build/reports/tests @@ -307,11 +317,15 @@ jobs: # :commons:jvmTest stays green — this is the job that catches it. # - compileTestKotlinIosArm64 catches device-only compile drift # (iosArm64 = aarch64-apple-ios) without needing a physical device. + # :commonsUI (the Compose half split out of :commons) gets the same + # treatment so the shared composables keep compiling on Apple targets. - name: Test Commons on iOS run: | ./gradlew \ :commons:iosSimulatorArm64Test \ - :commons:compileTestKotlinIosArm64 + :commons:compileTestKotlinIosArm64 \ + :commonsUI:iosSimulatorArm64Test \ + :commonsUI:compileTestKotlinIosArm64 - name: Upload iOS Test Reports uses: actions/upload-artifact@v7 @@ -363,6 +377,7 @@ jobs: :amethyst:lintPlayBenchmark \ :quartz:jvmTest \ :commons:jvmTest \ + :commonsUI:jvmTest \ :nestsClient:jvmTest \ :amethyst:testFdroidDebugUnitTest \ :amethyst:testPlayDebugUnitTest \ diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 85d5fbde49..78bb1aa0f6 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -568,22 +568,22 @@ jobs: ( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib ) echo "Collected: dist/amy-${VER}-jvm.tar.gz" - - name: Enforce CLI size budget (200 MB per asset) + - name: Enforce CLI size budget (120 MB per asset) run: | set -euo pipefail - # The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget - # targets < 80 MB, but :commons currently leaks Compose + Skiko as - # transitive deps (~40 MB of unused UI jars). Budget is set to - # 200 MB until commons is split into core + ui modules — track that - # as a follow-up. Until then, this gate just catches pathological - # regressions (e.g. accidental :amethyst dep pulling Android libs). + # Measured after the :commons / :commonsUI split (1.15.2, Linux x64): + # amy-*-jvm.tar.gz 55 MB, amy-*.tar.gz (jlink image) 80 MB, lib/ 60 MB + # on disk. The budget sits 50% above the largest asset so a Compose / + # Skiko / Android leak (+25-40 MB compressed) trips it, while the + # per-OS JRE variance of the jlink image does not. The "Assert no + # Compose UI" step above is the precise check; this is the coarse one. fail=0 for f in dist/*; do if [[ -f "$f" ]]; then size=$(wc -c < "$f") mb=$(( size / 1048576 )) - if (( size > 209715200 )); then - echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget" + if (( size > 125829120 )); then + echo "::error file=$f::asset is ${mb} MB — exceeds 120 MB amy budget" fail=1 else echo "OK: $f — ${mb} MB" diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index c56009804c..4235feb76b 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -31,6 +31,14 @@ jobs: with: upload_sources: true upload_translations: true + # Upload translations that are identical to the English source (brand + # terms, loanwords like "Feed"/"Apps", bare formats like "v%1$s"). + # Without this they are SKIPPED on upload, so a locale that deliberately + # keeps English never reaches Crowdin's DB and the key keeps coming back + # as untranslated. They arrive as normal UNAPPROVED translations -- + # auto_approve_imported stays at its default false, so a translator still + # approves them in the Crowdin UI (bulk-select in the Editor). + import_eq_suggestions: true download_translations: true # Let the downloaded translations stay in the working tree; the single # create-pull-request step below opens the combined PR. @@ -53,7 +61,7 @@ jobs: # Both files in crowdin.yml are declared `type: android`, so Crowdin's Android # serializer escapes apostrophes on the way down: `l'URL` comes back as `l\'URL`. # That is correct for amethyst/src/main/res/, which aapt un-escapes at build time, - # and WRONG for commons/.../composeResources/, where Compose resolves only \uXXXX, + # and WRONG for commonsUI/.../composeResources/, where Compose resolves only \uXXXX, # \n and \t and leaves \' \" \? \@ alone -- so the backslash reaches the screen. # # Without this step every sync reopens the same regression and CI's @@ -68,14 +76,14 @@ jobs: - name: Convert Android escaping to Compose escaping in the shared catalog run: | python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \ - commons/src/commonMain/composeResources + commonsUI/src/commonMain/composeResources # Assert the conversion actually satisfied the check that guards main, so a case # the converter cannot repair fails the sync loudly here instead of opening a red # PR. Known gap if this ever trips: fix_escapes.py only rewrites text inside # / elements, while the check scans the whole file -- an escape in an # XML comment (comments do propagate into the locale files) has to be fixed at the - # source string in commons/.../composeResources/values/strings.xml by hand. + # source string in commonsUI/.../composeResources/values/strings.xml by hand. - name: Verify the shared catalog is free of Android-only escaping run: .claude/hooks/compose_escaping_check.py @@ -99,7 +107,7 @@ jobs: branch: l10n_crowdin_translations add-paths: | amethyst/src/main/res/**/strings.xml - commons/src/commonMain/composeResources/**/strings.xml + commonsUI/src/commonMain/composeResources/**/strings.xml docs/changelog/translators.json commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders' title: 'New Crowdin Translations' diff --git a/BUILDING.md b/BUILDING.md index f354492819..c65217c52a 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -96,7 +96,7 @@ and each has its own guide: | Artifact | Committed at | Regenerate when | Guide | |---|---|---|---| -| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` | +| **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` | | **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) | > **Material Symbols is mandatory after icon changes.** The bundled font is a @@ -105,8 +105,9 @@ and each has its own guide: > change. Reusing an existing codepoint needs no regeneration. Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a -Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they -are **not** required to build Amethyst from the committed sources. +Rust toolchain + the exact Android NDK revision pinned in +`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs — +they are **not** required to build Amethyst from the committed sources. --- @@ -466,8 +467,8 @@ Homebrew removes the quarantine attribute on its own downloads. > with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and > now prints the per-file notary log on a non-`Accepted` verdict. If it comes > back `Invalid`, the fix is to codesign the dylibs *inside* those jars before -> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the -> `:commons` core/ui split the size budget already flags). The **desktop** app +> zipping (the unused `skiko`/Compose jars left the CLI image with the +> `:commons` / `:commonsUI` split). The **desktop** app > bundles the same jars through Compose/jpackage notarization, so run a desktop > dry-run too; its in-jar handling differs and is likewise unverified. @@ -533,7 +534,7 @@ reads an optional per-release changelog from ## Bootstrap runbook (one-time) -> **Status as of v1.15.1:** both Homebrew packages are now live upstream — the +> **Status as of v1.15.2:** both Homebrew packages are now live upstream — the > `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy` > formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so > `bump-homebrew.yml` finally has something to bump. **Winget is still not @@ -587,7 +588,7 @@ The token then lives only in that maintainer's shell: ```bash export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope -scripts/bump-homebrew-cask.sh v1.15.1 +scripts/bump-homebrew-cask.sh v1.15.2 ``` Create one at @@ -603,7 +604,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives runs fine from macOS or Linux: ```bash -scripts/bump-winget.sh v1.15.1 +scripts/bump-winget.sh v1.15.2 ``` CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the @@ -686,9 +687,11 @@ Caveats that the maintainer must weigh before submitting: - **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to justify it (sandboxed Gradle can't fetch Maven deps). -- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks - Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons` - core/ui split) would shrink it and smooth review — tracked as a follow-up. +- **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked + Compose/Skiko jars onto the CLI classpath. Compose UI now lives in + `:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB + and the jlink image tarball ~80 MB (1.15.2, Linux x64). The release + workflow caps every amy asset at 120 MB. After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot auto-open version-bump PRs on each stable release — no token or workflow on our diff --git a/CONTRIBUTING-WITH-AI.md b/CONTRIBUTING-WITH-AI.md index 4aeac7e61a..a2524947e9 100644 --- a/CONTRIBUTING-WITH-AI.md +++ b/CONTRIBUTING-WITH-AI.md @@ -175,9 +175,13 @@ device. PRs that introduce any of them will be sent back. ### KMP source-set discipline -- **Android-only imports don't belong in `commons/commonMain` or - `quartz/commonMain`.** Use `expect`/`actual` for platform-specific - bits, or move the Android-specific code to `androidMain`. +- **Android-only imports don't belong in `commons/commonMain`, + `commonsUI/commonMain` or `quartz/commonMain`.** Use `expect`/`actual` + for platform-specific bits, or move the Android-specific code to + `androidMain`. +- **Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` don't belong + in `commons` at all** — that module is on the CLI classpath. Put the file + in `commonsUI` (same package) instead. ### Logging diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56a3348ba6..8ce5e4b4ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ Thanks for your interest in improving Amethyst. This document captures the expectations, conventions, and review rules for code, documentation, and translation contributions across all modules in this repository (`amethyst/`, -`desktopApp/`, `quartz/`, `commons/`, `cli/`, `quic/`, `nestsClient/`). +`desktopApp/`, `quartz/`, `commons/`, `commonsUI/`, `cli/`, `quic/`, `nestsClient/`). By contributing, you agree to license your work under the MIT license. Any work contributed where you are not the original author must contain its @@ -157,7 +157,8 @@ Common Gradle entry points: Modules: - `quartz/` — Nostr KMP library (protocol, crypto, models). **No UI.** -- `commons/` — Shared Compose Multiplatform UI, icons, ViewModels, flows. +- `commons/` — Shared headless layer: models, ViewModels, flows, relay client. **No Compose UI** (the CLI depends on it). +- `commonsUI/` — Shared Compose Multiplatform UI, icons, theme, Compose resources, on top of `commons`. - `quic/` — Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport. - `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and `:quartz`. @@ -175,7 +176,8 @@ of PR churn. Place new code by purpose: | What you're adding | Goes in | |---|---| | Nostr event types, NIPs, tags, signing, crypto, Bech32 | `quartz/commonMain/` | -| Shared Composables, icons, ViewModels, StateFlows | `commons/commonMain/viewmodels/` or `commons/commonMain/` | +| Shared ViewModels, StateFlows, relay subscriptions | `commons/commonMain/viewmodels/` or `commons/commonMain/` | +| Shared Composables, icons, theme | `commonsUI/commonMain/` (same packages as `commons`) | | Android-only screen, navigation, system integration | `amethyst/` | | Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` | | `amy ` subcommand (thin assembly only) | `cli/src/main/kotlin/.../cli/` | @@ -188,8 +190,9 @@ Hard rules: - `cli/` has **no Nostr protocol or business logic** — it's a thin assembly layer over `quartz` + `commons`. If your CLI command needs new behavior, extract it into `commons/` first. -- ViewModels belong in `commons/commonMain/`. Only screens (the Composable - that wires layout + navigation) stay in the platform module. +- ViewModels belong in `commons/commonMain/`; shared composables in + `commonsUI/commonMain/`. Only screens (the Composable that wires layout + + navigation) stay in the platform module. - For platform-specific behavior in a shared file, use `expect`/`actual`. ## Workflow @@ -265,9 +268,11 @@ front: sequentially: `for peer in aioquic picoquic quic-go quinn; do quic/interop/run-matrix.sh -s $peer; done`. Plan at `quic/interop/plans/2026-05-06-interop-runner.md`. -- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless - variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive - Marmot variant prompts a human to drive the Android UI. +- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every + relay-backed suite boots the embedded `amy serve` relay (geode) — no + external relay binary; only the Marmot suites additionally need `cargo` + for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to + drive the Android UI. If a change is documentation-only, UI-only, build-script-only, or otherwise cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip diff --git a/README.md b/README.md index f6218a8750..92edd3525e 100644 --- a/README.md +++ b/README.md @@ -328,16 +328,16 @@ repositories { Add the following line to your `commonMain` dependencies: ```gradle -implementation('com.vitorpamplona.quartz:quartz:1.15.1') +implementation('com.vitorpamplona.quartz:quartz:1.15.2') ``` Variations to each platform are also available: ```gradle -implementation('com.vitorpamplona.quartz:quartz-android:1.15.1') -implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.1') -implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.1') -implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.1') +implementation('com.vitorpamplona.quartz:quartz-android:1.15.2') +implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.2') +implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.2') +implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.2') ``` Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz) diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index 7b1fd6cf8b..d1ac130fc0 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -240,9 +240,9 @@ readable by anyone with push access here), so a maintainer runs the last step: ```bash # after merging the sync PRs export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope -scripts/bump-homebrew-cask.sh v1.15.1 +scripts/bump-homebrew-cask.sh v1.15.2 -scripts/bump-winget.sh v1.15.1 # no token — uses your `gh` auth +scripts/bump-winget.sh v1.15.2 # no token — uses your `gh` auth ``` Both scripts re-verify the published artifact's sha256 before submitting, and diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 77010ea9fe..0ea720bc54 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -399,12 +399,13 @@ dependencies { // Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source: // adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \ // -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver - debugImplementation("androidx.compose.runtime:runtime-tracing") - debugImplementation("androidx.tracing:tracing-perfetto:1.0.1") - debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1") + debugImplementation(libs.androidx.compose.runtime.tracing) + debugImplementation(libs.androidx.tracing.perfetto) + debugImplementation(libs.androidx.tracing.perfetto.binary) implementation(project(":quartz")) implementation(project(":commons")) + implementation(project(":commonsUI")) implementation(project(":nestsClient")) // Agent text stream previews: the raw-QUIC binding plus the QUIC // stack under it (for the certificate validator it requires). @@ -598,6 +599,15 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) + // In-process Nostr relay (geode) so unit tests that drive a real + // NostrClient talk to an embedded relay instead of a public one. Same + // wiring quartz uses for its jvmAndroidTest source set: the engine, its + // testFixtures (RelayClientTest base, preload/publish helpers) and the + // JVM SQLite driver the in-memory EventStore needs on a host JVM. + testImplementation(project(":geode")) + testImplementation(testFixtures(project(":geode"))) + testImplementation(libs.androidx.sqlite.bundled.jvm) + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit.ktx) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index eb31a759d4..8e8e4fcd67 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -241,7 +241,7 @@ class ImageUploadTesting { @Ignore("Returns invalid hash") fun testSovbit() = runBlocking { - testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom)) + testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.Blossom)) } @Test() diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt index 5ea16ce0b6..6c2e86b123 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getUnclippedBoundsInRoot -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -62,7 +62,8 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class PlaybackErrorOverlayFitTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt index 564715b2f9..c9f738a88b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.service.playback.composable.audioSquare @@ -50,7 +50,8 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class AudioPlayerBoxOverflowTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() private class Bounds { var top = 0f diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt index 54a1cf4624..b6ef4068fd 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.WindowInsetsAnimationCompat @@ -70,7 +70,8 @@ import org.junit.Test * fallback would silently start reading a dead value too. */ class ComposeImeInsetWedgeTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() private val keyboardHeight = 957 diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt index 12b6892a0f..a2cbb273e7 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt deleted file mode 100644 index f4ed293a27..0000000000 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.commons.defaults.Constants -import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger -import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking -import okhttp3.OkHttpClient -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class EventSyncTest { - companion object { - val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl() - val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - - val rootClient = - OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) - .build() - val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } - } - - @Test - fun testSync() = - runBlocking { - val sync = - EventSync( - accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - relayDb = { - listOf(Constants.mom, Constants.nos) - }, - outboxTargets = { setOf(vitor) }, - inboxTargets = { setOf(vitor) }, - dmTargets = { setOf(vitor) }, - clientBuilder = { - NostrClient(socketBuilder, appScope) - }, - scope = appScope, - ) - - sync.runSync() - } - - @Test - fun testFiatjafSync() = - runBlocking { - val sync = - EventSync( - accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - relayDb = { listOf(fiatjaf) }, - outboxTargets = { setOf(vitor) }, - inboxTargets = { setOf(vitor) }, - dmTargets = { setOf(vitor) }, - clientBuilder = { - val newClient = NostrClient(socketBuilder, appScope) - val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false) - - val signer = NostrSignerInternal(KeyPair()) - - // Authenticates with relays. - val auth = - RelayAuthenticator( - newClient, - appScope, - signWithAllLoggedInUsers = { _, authTemplate, _ -> - listOf(signer.sign(authTemplate)) - }, - ) - - newClient - }, - scope = appScope, - ) - - sync.runSync() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 1b8397a2b0..7fd2db22f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -184,6 +184,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -647,6 +648,41 @@ class AppModules( onionCache = onionLocationCache, ) + // Drops pooled connections once per real Tor route change. When the user switches + // Tor on, the direct clients' idle sockets to real hosts would otherwise sit in the + // pool for its 5-minute keepalive after the user has asked for everything to go + // through Tor. No request could use them either way -- OkHttp keys the pool by + // `Address`, which includes the proxy, so a connection on a dead route is already + // unreachable -- which is why this is hygiene and not correctness, and why it is + // fine for it to be a little late. + // + // Every source here is a plain StateFlow, so subscribing costs nothing. Deliberately + // NOT torManager.activePortOrNull: that chains to TorManager.status, whose upstream + // is WhileSubscribed and calls service.start() when collected, so a process-lifetime + // subscription there would hold Arti's control flow open forever -- the same hazard + // the battery ledger above documents and sidesteps the same way. + // + // Also deliberately not the per-feature Tor switches (imagesViaTor, videosViaTor, ...): + // those change which of the two existing clients a request picks, not the route either + // one uses, so no pooled connection goes stale. + init { + applicationIOScope.launch { + combine( + torPrefs.torType, + torPrefs.externalSocksPort, + torService.status.map { it.socksPort }, + ) { torType, externalPort, artiPort -> Triple(torType, externalPort, artiPort) } + .distinctUntilChanged() + // Only later moves count; the route in force at process construction is the + // status quo, and nothing is pooled yet to evict. + .drop(1) + .collect { + okHttpClients.factory.evictPooledConnections() + okHttpClientForRelays.factory.evictPooledConnections() + } + } + } + // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index dcabff9b11..9735216a02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.LogLevel import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes import kotlin.time.DurationUnit @@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot { private const val STATE_DUMP_TAG = "STATE DUMP" fun debugState(context: Context) { + // Everything below is logged at DEBUG, and every argument is built eagerly (the + // eager Log.d overload, not the lambda one). Gate on the level that would drop + // those lines, because the arguments are the expensive part: nine materialising + // LargeCache.filter scans over notes/addressables/users/channels, plus three + // passes calling Event.countMemory() — which walks every tag of every cached + // event. MainActivity.onPause() calls this unconditionally, so without the gate + // a release build (minLevel WARN) did all of that on every backgrounding and + // threw the result away. Benchmark builds sit at INFO and paid it too. + if (Log.minLevel > LogLevel.DEBUG) return + val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024) val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024) val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index ac39759a4c..eb854749ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -37,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request @@ -162,6 +165,16 @@ class AccountZapActions( ?.supportsMethod(NwcMethod.PAY) == true } + /** + * True when this account can settle a BOLT12 zap at all: an NWC wallet is + * configured and the default one advertises `pay`. The sender-side half of the + * BOLT12 route; the recipient-side half is a published kind:10058 offer. + */ + fun canZapViaBolt12(): Boolean = + account.settings.nwcWallets.value + .isNotEmpty() && + defaultWalletSupportsBolt12Pay() + /** * Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet. * @@ -173,6 +186,14 @@ class AccountZapActions( * still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for * a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no * external-wallet or LNURL fallback because only NWC returns the proof. + * + * Outcomes are split by what they say about the money: + * - [onNotPaid]: the wallet answered with an error. The wallet does the offer → + * invoice exchange itself, so a stale or dead offer lands here too. Whether a + * retry is safe depends on the code — `PAYMENT_FAILED` may be a timeout with the + * HTLC still in flight — see `Bolt12LightningFallback`. + * - [onError]: paid but no valid receipt, or nothing conclusive. Never retry. + * - [onTimeout]: the wallet never answered. Unknown state — never retry. */ suspend fun sendBolt12Zap( zappedEvent: Event?, @@ -183,15 +204,21 @@ class AccountZapActions( zapType: LnZapEvent.ZapType, // (messageResId, detail) — the caller localizes; detail carries a wallet error, if any. onError: (Int, String?) -> Unit, + // (code, detail) — the wallet refused or failed the payment; no funds moved. + onNotPaid: suspend (NwcErrorCode?, String?) -> Unit, + onTimeout: () -> Unit, onProcessed: () -> Unit, ) { // NONZAP means "pay, but publish no receipt" — settle the offer without binding // a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP. if (zapType == LnZapEvent.ZapType.NONZAP) { - sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response -> account.scope.launch { - if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage()) - onProcessed() + try { + if (response is IErrorResponseLike) onNotPaid(response.nwcErrorCode(), response.errorMessage()) + } finally { + onProcessed() + } } } return @@ -211,7 +238,7 @@ class AccountZapActions( val payerNote = Bolt12ZapBuilder.payerNote(intent) - sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response -> + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote), onTimeout) { response -> account.scope.launch { // try/finally so a failure while assembling/publishing the receipt (e.g. a // remote signer error) still steps progress and surfaces an error, instead @@ -234,7 +261,7 @@ class AccountZapActions( } } - is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) + is IErrorResponseLike -> onNotPaid(response.nwcErrorCode(), response.errorMessage()) else -> onError(R.string.bolt12_zap_paid_no_receipt, null) } @@ -374,3 +401,11 @@ class AccountZapActions( return this } } + +/** The NIP-47 error code on a failed reply, whichever error shape the wallet used. */ +private fun Response.nwcErrorCode(): NwcErrorCode? = + when (this) { + is NwcErrorResponse -> error?.code + is PayInvoiceErrorResponse -> error?.code + else -> null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt index f8123a60fd..63ccacf2a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException @Stable data class VanishEventItem( @@ -124,7 +125,10 @@ class VanishRequestsState( } ) } - } catch (_: Exception) { + } catch (e: Exception) { + // A cancelled check has no result. Reporting ERROR would show the relay as + // having answered badly when it was never asked. + if (e is CancellationException) throw e item.complianceResults.update { it + (relay to ComplianceStatus.ERROR) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 3f37ce3066..0844905ab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -166,7 +166,8 @@ class NamecoinSharedPreferences( } else { emptyList() } - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index f53cc2144a..0c3b1593c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -46,6 +46,12 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup * through that rail — matching the existing best-effort behaviour of the * actual send paths (Lightning skips pubkeys with no `lnAddress`; on-chain * separately warns about lnAddress-only splits that can't be paid on-chain). + * + * [hasLightning] is the whole Lightning rail, not just BOLT11: a recipient with + * no `lnAddress` but a published kind:10058 BOLT12 offer counts when our own + * NWC wallet can pay offers, because the zap send path routes them over BOLT12 + * (see `ZapPaymentHandler`). The chip stays one bolt either way — which flavour + * gets used is decided at send time, not in the picker. */ @Immutable data class RailCapability( @@ -143,6 +149,12 @@ object RailCapabilityResolver { baseNote: Note, cashuState: CashuWalletState, payToEnabled: Boolean = false, + /** + * Whether our default NWC wallet can pay BOLT12 offers + * (`AccountZapActions.canZapViaBolt12`). When true, a recipient's published + * offer makes them payable on the Lightning rail even without an lnAddress. + */ + bolt12Payable: Boolean = false, ): RailCapability { val author = baseNote.author?.pubkeyHex val splits = baseNote.event?.zapSplitSetup().orEmpty() @@ -170,7 +182,9 @@ object RailCapabilityResolver { val hasLightning = lnAddressOnlySplits.isNotEmpty() || pubKeyRecipients.any { pk -> - LocalCache.getUserIfExists(pk)?.lnAddress() != null + val user = LocalCache.getUserIfExists(pk) + user?.lnAddress() != null || + (bolt12Payable && user?.bolt12Offers()?.isNotEmpty() == true) } // On-chain pays the pubkey directly; an event with only lnAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index 162cc22c9c..32ca910f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -60,6 +60,7 @@ import java.net.URLDecoder import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException /** * Fetches a resource URL on an applet's behalf — the applet has no direct network @@ -162,7 +163,10 @@ class NappletResourceFetcher( return failure(ERROR_BLOCKED, e.message) } catch (_: InterruptedIOException) { return failure(ERROR_TIMEOUT) - } catch (_: Exception) { + } catch (e: Exception) { + // Cancellation is not an upstream failure — do not report it to the + // napplet as one, and do not keep the request alive past it. + if (e is CancellationException) throw e return failure(ERROR_NETWORK) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt new file mode 100644 index 0000000000..4f73a3b2c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode + +/** + * Decides whether a BOLT12 zap the wallet refused should be re-sent as a BOLT11 zap. + * + * Only ever consulted for a NIP-47 *error* reply. An allowlist, because not every + * error means no money moved: NIP-47 defines `PAYMENT_FAILED` as "may be due to a + * timeout, exhausting all routes, insufficient capacity or similar", and a wallet + * that gave up on a payment whose HTLC is still in flight can see it settle later. + * Retrying on that, or on the catch-all `INTERNAL` / `OTHER` / no-code replies, + * could pay the recipient twice. Only refusals the wallet raises *before* it + * attempts a payment qualify — the offer could not be resolved or has expired, the + * request was rejected as malformed, or our wallet does not handle `lno` at all. + * Those are the "recipient's configuration is stale" cases the fallback exists for. + * Refusals about our own wallet (balance, quota, permissions) are out too: BOLT11 + * through the same wallet would fail identically and only add a second error. + */ +object Bolt12LightningFallback { + /** Refusals raised before any payment attempt, about the offer or the instruction. */ + private val offerSideCodes = + setOf( + NwcErrorCode.EXPIRED, + NwcErrorCode.NOT_FOUND, + NwcErrorCode.BAD_REQUEST, + NwcErrorCode.NOT_IMPLEMENTED, + NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, + NwcErrorCode.UNSUPPORTED_NETWORK, + ) + + /** True when a refusal with [code] (null when the wallet sent none) should be retried over BOLT11. */ + fun shouldRetry(code: NwcErrorCode?): Boolean = code in offerSideCodes +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index cd44293231..0d08176f3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -84,11 +86,17 @@ class ZapPaymentHandler( val user: User? = null, ) - /** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */ + /** + * A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we + * hold an NWC wallet. [lnAddress] is their BOLT11 route, kept so a refused offer can + * fall back to a regular zap (see [payViaBolt12]); null when they publish none. + */ data class Bolt12Recipient( val user: User, val offer: String, val weight: Double = 1.0, + val lnAddress: String? = null, + val relay: NormalizedRelayUrl? = null, ) suspend fun zap( @@ -167,16 +175,13 @@ class ZapPaymentHandler( // BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for // the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient // stays on lightning, so an unsupported wallet degrades gracefully instead of erroring. - val canBolt12 = - account.settings.nwcWallets.value - .isNotEmpty() && - account.zaps.defaultWalletSupportsBolt12Pay() + val canBolt12 = account.zaps.canZapViaBolt12() val bolt12Recipients = unverifiedZapsToSend.mapNotNull { val user = it.user if (canBolt12 && it.bolt12Offer != null && user != null) { - Bolt12Recipient(user, it.bolt12Offer, it.weight) + Bolt12Recipient(user, it.bolt12Offer, it.weight, it.lnAddress, it.relay) } else { null } @@ -233,48 +238,20 @@ class ZapPaymentHandler( // --- Lightning lane ----------------------------------------------------------- if (zapsToSend.isNotEmpty()) { - val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight) - - if (splitZapRequests.isNotEmpty()) { - onProgress(0.05f) - - val payables = - assembleAllInvoices( - requests = splitZapRequests, - totalAmountMilliSats = amountMilliSats, - message = message, - okHttpClient = okHttpClient, - onError = onError, - onProgress = { onProgress(it * 0.7f + 0.05f) }, - context = context, - totalWeight = totalWeight, - ) - - if (payables.isNotEmpty()) { - onProgress(0.75f) - - // Route through the user's selected default payment source. A CLINK debit takes - // precedence over NWC when it is the chosen default; NWC-only users are unaffected - // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. - when (val source = account.settings.defaultPaymentSource()) { - is PaymentSource.ClinkDebit -> { - payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) - }, context) - } - - is PaymentSource.Nwc -> { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) - } - - null -> { - onPayViaIntent(payables.toImmutableList()) - } - } - } - } + zapOverLightning( + zapsToSend = zapsToSend, + note = note, + pollOption = pollOption, + message = message, + zapType = zapType, + totalAmountMilliSats = amountMilliSats, + totalWeight = totalWeight, + okHttpClient = okHttpClient, + onError = onError, + onProgress = onProgress, + onPayViaIntent = onPayViaIntent, + context = context, + ) } // --- BOLT12 lane -------------------------------------------------------------- @@ -282,12 +259,15 @@ class ZapPaymentHandler( payViaBolt12( recipients = bolt12Recipients, note = note, + pollOption = pollOption, totalAmountMilliSats = amountMilliSats, totalWeight = totalWeight, message = message, zapType = zapType, + okHttpClient = okHttpClient, onError = onError, onProgress = { onProgress(it * 0.25f + 0.75f) }, + onPayViaIntent = onPayViaIntent, context = context, ) } @@ -295,6 +275,68 @@ class ZapPaymentHandler( onProgress(1f) } + /** + * The BOLT11 lane: signs one kind 9734 per recipient, fetches each invoice from + * the recipient's LNURL, then settles through the default payment source. Used + * for every lnAddress recipient of a zap, and again by [payViaBolt12] for a + * recipient whose offer the wallet refused. [onProgress] spans 0.05..1.0. + */ + private suspend fun zapOverLightning( + zapsToSend: List, + note: Note, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + totalAmountMilliSats: Long, + totalWeight: Double, + okHttpClient: (String) -> OkHttpClient, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + context: Context, + ) { + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, totalAmountMilliSats, totalWeight) + if (splitZapRequests.isEmpty()) return + + onProgress(0.05f) + + val payables = + assembleAllInvoices( + requests = splitZapRequests, + totalAmountMilliSats = totalAmountMilliSats, + message = message, + okHttpClient = okHttpClient, + onError = onError, + onProgress = { onProgress(it * 0.7f + 0.05f) }, + context = context, + totalWeight = totalWeight, + ) + if (payables.isEmpty()) return + + onProgress(0.75f) + + // Route through the user's selected default payment source. A CLINK debit takes + // precedence over NWC when it is the chosen default; NWC-only users are unaffected + // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) + }, context) + } + + is PaymentSource.Nwc -> { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + } + + null -> { + onPayViaIntent(payables.toImmutableList()) + } + } + } + private fun calculateZapValue( amountMilliSats: Long, weight: Double, @@ -463,21 +505,41 @@ class ZapPaymentHandler( * and (if the returned proof validates) publishes a 9736 zap — see * [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic * and settlement/errors surface later through the async NWC response. + * + * When the wallet refuses the offer before attempting a payment — it resolves the + * offer itself, so a stale, expired or unsupported offer fails there — and the + * recipient also publishes a lightning address, the same share is re-sent as a + * regular BOLT11 zap through [zapOverLightning], silently. The BOLT12 error is + * shown when there is no BOLT11 route or when the refusal does not qualify + * ([Bolt12LightningFallback]: a failed payment attempt may still settle, and a + * refusal about our own wallet would repeat on BOLT11). A paid-but-no-receipt + * outcome and a wallet that never answers are never retried either. */ suspend fun payViaBolt12( recipients: List, note: Note, + pollOption: Int?, totalAmountMilliSats: Long, totalWeight: Double, message: String, zapType: LnZapEvent.ZapType, + okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, context: Context, ) { val progress = PaymentProgress(recipients.size, onProgress) mapNotNullAsync(recipients) { recipient: Bolt12Recipient -> + fun reportBolt12Error( + msgRes: Int, + detail: String?, + ) { + val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes) + onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user) + } + account.zaps.sendBolt12Zap( zappedEvent = note.event, recipientPubKey = recipient.user.pubkeyHex, @@ -485,9 +547,46 @@ class ZapPaymentHandler( amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight), message = message, zapType = zapType, - onError = { msgRes, detail -> - val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes) - onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user) + onError = ::reportBolt12Error, + onNotPaid = { code, detail -> + val lnAddress = recipient.lnAddress + if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) { + Log.i("ZapPaymentHandler") { "BOLT12 offer refused ($code: $detail); re-sending over BOLT11 to $lnAddress" } + try { + zapOverLightning( + zapsToSend = listOf(MyZapSplitSetup(lnAddress, recipient.weight, recipient.relay, recipient.user)), + note = note, + pollOption = pollOption, + message = message, + zapType = zapType, + totalAmountMilliSats = totalAmountMilliSats, + totalWeight = totalWeight, + okHttpClient = okHttpClient, + onError = onError, + // The zap's own progress finished when the BOLT12 request was + // dispatched; the retry settles in the background like NWC does. + onProgress = {}, + onPayViaIntent = onPayViaIntent, + context = context, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Nothing was paid on either rail. Report it as the lightning failure it + // is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt". + Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e) + onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user) + } + } else { + // bolt12_payment_failed always formats a detail; a wallet may send neither + // message nor a recognised code. + reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name) + } + }, + onTimeout = { + // No response callback will fire, so account for the settlement step here. + reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context)) + progress.step() }, onProcessed = { progress.step() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index a7ac9a4d4c..f28d53b3a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -201,6 +201,9 @@ class EventNotificationConsumer( .onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } } .getOrNull() + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") private suspend fun dispatchForAccount( event: Event, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 6c66b6dc6b..41c4619596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -112,6 +112,9 @@ class NotificationDispatcher( // recipient account. // `internal` (was `private`) so the notification-kinds contract test // can pin the push-side kind set against the in-app feed's kind set. + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") internal val NOTIFICATION_KINDS: Set = setOf( // Direct-arrival diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 74870a5645..b94e006dd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -320,6 +320,14 @@ class NotificationRelayService : Service() { } launch { + // This flow used to over-report: it is fed by socket callbacks, and until + // the OkHttp adapters answered a relay's CLOSE frame a relay-initiated close + // produced none (no onClosed, no onFailure, and a silent cancel() + // afterwards), so after the feeds tore down in the background it carried + // hundreds of already-dropped relays for minutes. The pool now clears it + // itself whenever it lets a relay go, and every transport reports its + // session end exactly once (see WebSocket), so what it emits is the count. + // // sample() caps how often we touch the notification. During feed // load/teardown connectedRelaysFlow churns dozens of times per second; // posting on every delta blows past Android's notification rate limit diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt index 1c8b8196b6..086f230ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt @@ -81,6 +81,9 @@ object CodeNotification { event: GitPullRequestUpdateEvent, ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content) + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be rendered. + @Suppress("DEPRECATION") suspend fun notify( context: Context, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 4c70c12498..e90277072f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import java.util.concurrent.atomic.AtomicBoolean class OkHttpWebSocket( val url: NormalizedRelayUrl, @@ -41,7 +42,17 @@ class OkHttpWebSocket( val out: WebSocketListener, ) : WebSocket { private var usingOkHttp: OkHttpClient? = null - private var socket: okhttp3.WebSocket? = null + + @Volatile private var socket: okhttp3.WebSocket? = null + + /** + * Set once, by whichever of `onClosed`, `onFailure` or [disconnect] ends the session first. + * One adapter is one session (the relay client builds a fresh one per dial, and OkHttp binds + * exactly one socket to the listener), so a callback only has to ask whether the session + * already ended. See quartz's `BasicOkHttpWebSocket` for the full reasoning; the two adapters + * differ only in how [needsReconnect] is decided. + */ + private val ended = AtomicBoolean(false) fun buildRequest() = Request.Builder().url(url.url).build() @@ -68,8 +79,10 @@ class OkHttpWebSocket( } override fun connect() { - usingOkHttp = httpClient(url) - socket = usingOkHttp?.newWebSocket(buildRequest(), OkHttpWebsocketListener(out)) + if (socket != null || ended.get()) return + val client = httpClient(url) + usingOkHttp = client + socket = client.newWebSocket(buildRequest(), OkHttpWebsocketListener(out)) } inner class OkHttpWebsocketListener( @@ -91,35 +104,60 @@ class OkHttpWebSocket( } } + /** Claims the session's single terminal report. False if it already ended. */ + private fun endSession(): Boolean { + if (!ended.compareAndSet(false, true)) return false + socket = null + incomingMessages.close() + job.cancel() + scope.cancel() + return true + } + override fun onOpen( webSocket: okhttp3.WebSocket, response: Response, - ) = out.onOpen( - (response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(), - response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, - ) + ) { + if (ended.get()) return + out.onOpen( + (response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(), + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, + ) + } override fun onMessage( webSocket: okhttp3.WebSocket, text: String, ) { - // Asynchronously send the received message to the channel. - // `trySendBlocking` is used here for simplicity within the callback, - // but it's important to understand potential thread blocking if the buffer is full. + if (ended.get()) return + // Never blocks (unlimited channel): the OkHttp reader thread must + // stay free to keep draining the socket. incomingMessages.trySendBlocking(text) } + override fun onClosing( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) { + // The relay sent a CLOSE frame. OkHttp fires onClosed only once BOTH peers have sent + // one, and sending ours is the application's job (WebSocketListener KDoc; its own + // WebSocketEcho recipe does exactly this). Unanswered, the socket sat half-closed: + // no onClosed, no onFailure, send() still accepted and discarded, a later cancel() + // silent too -- so the relay client believed it was connected until the 120s ping + // path failed up to two intervals later. + // + // Always 1000 rather than echoing `code`: close() validates the code it writes and + // throws on the reserved ones (1005, 1006, 1015), and a relay may send anything. + webSocket.close(1000, null) + } + override fun onClosed( webSocket: okhttp3.WebSocket, code: Int, reason: String, ) { - // Close the channel on failure, and propagate the error. - incomingMessages.close() - job.cancel() - scope.cancel() - - socket = null + if (!endSession()) return out.onClosed(code, reason) } @@ -128,12 +166,7 @@ class OkHttpWebSocket( t: Throwable, response: Response?, ) { - // Close the channel on failure, and propagate the error. - incomingMessages.close() - job.cancel() - scope.cancel() - - socket = null + if (!endSession()) return out.onFailure(t, response?.code, response?.message) } } @@ -153,9 +186,15 @@ class OkHttpWebSocket( } override fun disconnect() { - // uses cancel to kill the SEND stack that might be waiting - socket?.cancel() + // Claim the session ourselves and cancel (which also kills a SEND stack that might be + // waiting): OkHttp's cancel() raises no callback when no reader is left to fail, and when + // it does the failure arrives later on its own thread. The relay client needs the answer + // now, and must not hear from this socket again. + val closing = socket ?: return + if (!ended.compareAndSet(false, true)) return socket = null + closing.cancel() + out.onClosed(1000, "client disconnect") } override fun send(msg: String): Boolean = socket?.send(msg) ?: false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 7c8eb5e992..bdc0fafe6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -87,7 +87,7 @@ fun RenderTopButtonsPreview() { ThemeComparisonColumn { Box(Modifier.background(BitcoinOrange)) { RenderTopButtons( - mediaData = MediaItemData("http://test.mp4"), + mediaData = MediaItemData("https://test.mp4"), hasMultipleQualities = false, qualityButton = {}, controllerVisible = remember { mutableStateOf(true) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt index 354d0a435e..0a0ef7d197 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -191,7 +192,7 @@ fun Bolt12OfferEntry( horizontalArrangement = Arrangement.SpaceAround, ) { Text( - text = "${offer.take(14)}…${offer.takeLast(6)}", + text = abbreviateBolt12Offer(offer), style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt index 4fc05e1c02..5f777ba65e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt @@ -43,7 +43,7 @@ val DEFAULT_MEDIA_SERVERS: List = ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom), ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom), ServerName("Primal", "https://blossom.primal.net/", ServerType.Blossom), - ServerName("Sovbit", "https://cdn.sovbit.host", ServerType.Blossom), + ServerName("Sovbit", "https://files.sovbit.host", ServerType.Blossom), ServerName("Nostr.Download", "https://nostr.download", ServerType.Blossom), ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom), ServerName("NostrMedia (Paid)", "https://nostrmedia.com", ServerType.Blossom), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt index 37858696cb..e198c246f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt @@ -65,6 +65,7 @@ import org.webrtc.RtpSender import org.webrtc.VideoTrack import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException private const val TAG = "CallSession" private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000 @@ -429,6 +430,7 @@ class CallSession( try { withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e) return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index 6620020e49..b2107b5979 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -47,10 +47,13 @@ import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.gif +import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size6dp import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -72,6 +75,15 @@ fun GifVideoView( // remember() to avoid the recompute would cost more (slot read + N equality checks) // than the work it saves; that's why this stays as a plain expression. val ratio = dimensions?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(videoUri) + + // Mirrors [mediaSizingModifier]: Crop gets fillMaxSize() and a known ratio gets + // aspectRatio(), both of which bound the height. Everything else is a bare + // fillMaxWidth() that wraps its content, and only THAT case needs a loading state + // with intrinsic height to keep the note from collapsing to nothing. Keying the + // fallback on `ratio` alone would put raw URL text inside every Crop card cell -- + // MyAsyncImage passes dimensions/blurhash/thumbhash all null, so a gif in a card + // slot hits this on first load, before MediaAspectRatioCache knows its size. + val heightIsBounded = contentScale == ContentScale.Crop || ratio != null val autoPlay = accountViewModel.settings.autoPlayVideos() val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current @@ -108,13 +120,29 @@ fun GifVideoView( when (state) { is AsyncImagePainter.State.Loading -> { - DisplayBlurHash( - blurhash, - contentDescription, - contentScale, - Modifier.fillMaxSize(), - thumbhash = thumbhash, - ) + // When the height is unbounded (see [heightIsBounded]) this branch MUST + // emit something with an intrinsic height, or the box wraps nothing and the + // whole note collapses to zero -- no picture, no URL, no spinner, just a gap + // in the feed until the load finishes. DisplayBlurHash renders NOTHING when + // both hashes are absent (placeholderModel returns null), which is exactly + // what a no-imeta post hits. Mirrors UrlImageView's ladder. + if (blurhash != null || thumbhash != null) { + DisplayBlurHash( + blurhash, + contentDescription, + contentScale, + Modifier.fillMaxSize(), + thumbhash = thumbhash, + ) + } else if (heightIsBounded) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingAnimation(Size40dp, Size6dp) + } + } else { + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(videoUri) + } + } } is AsyncImagePainter.State.Success -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt index ea0f43dffc..f8b68bce78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.sp +import androidx.core.graphics.withTranslation import com.vitorpamplona.amethyst.commons.richtext.MathParser import ru.noties.jlatexmath.JLatexMathDrawable @@ -130,12 +131,11 @@ fun LatexEquation( Canvas(modifier = equationModifier) { drawIntoCanvas { canvas -> val native = canvas.nativeCanvas - val checkpoint = native.save() // Position the icon's baseline on the text baseline within the padded box. - native.translate(0f, drawTopPx) - drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) - drawable.draw(native) - native.restoreToCount(checkpoint) + native.withTranslation(y = drawTopPx) { + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(this) + } } } if (trailing.isNotEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 8bda095d45..e6d4dfd847 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -110,6 +110,10 @@ fun RenderLoaded( accountViewModel: AccountViewModel, nav: INav? = null, ) { + // Bound to a local: `UrlInfoItem` lives in another module, so the null check below cannot + // smart-cast the property itself. + val playableMedia = state.previewInfo.playableMediaUrl + when { state.previewInfo.mimeType.startsWith("image") -> { Box(modifier = HalfVertPadding) { @@ -122,10 +126,14 @@ fun RenderLoaded( } } - state.previewInfo.mimeType.startsWith("video") -> { + // Audio rides the video pipeline; see RichTextParser.videoExt. The fetched Content-Type is + // forwarded because it is the only type signal for an extension-less URL -- an HLS + // playlist served as `audio/x-mpegurl` off `/stream?id=1` is otherwise handed to the + // progressive source and fails. + state.previewInfo.mimeType.startsWith("video") || state.previewInfo.mimeType.startsWith("audio") -> { Box(modifier = HalfVertPadding) { ZoomableContentView( - content = MediaUrlVideo(url, uri = callbackUri), + content = MediaUrlVideo(url, uri = callbackUri, mimeType = state.previewInfo.mimeType), roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, @@ -144,6 +152,29 @@ fun RenderLoaded( } } + // An HTML page that declares the media file it is a player for: nostr.build publishes one + // such page per upload and names the real file in `og:video` or `og:audio`. Play that + // file, with the page's `og:image` as poster/cover art, instead of a card that only links + // out. `playableMediaUrl` is null unless the page's declared type held up, so a page whose + // `og:video` is really an embed (YouTube's, say) still falls through to the card below. + playableMedia != null -> { + Box(modifier = HalfVertPadding) { + ZoomableContentView( + content = + MediaUrlVideo( + url = playableMedia, + description = state.previewInfo.title.ifBlank { null }, + uri = callbackUri, + artworkUri = state.previewInfo.imageUrlFullPath.ifBlank { null }, + mimeType = state.previewInfo.playableMediaType, + ), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } + else -> { UrlPreviewCard( url, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 85726775f6..358cc53c1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -117,12 +117,16 @@ fun UrlPreviewCard( }, ), ) { - AsyncImage( - model = previewInfo.imageUrlFullPath, - contentDescription = previewInfo.title, - contentScale = ContentScale.FillWidth, - modifier = previewCardImageModifier, - ) + // A Loaded preview no longer implies an image: a player page that ships no cover art is + // kept (it has media to play), and painting its empty string left a blank 180dp box. + if (previewInfo.imageUrlFullPath.isNotBlank()) { + AsyncImage( + model = previewInfo.imageUrlFullPath, + contentDescription = previewInfo.title, + contentScale = ContentScale.FillWidth, + modifier = previewCardImageModifier, + ) + } Row( modifier = MaxWidthWithHorzPadding, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt index f088252e13..611d27ff59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.commons.feeds.AdditiveComplexFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent +import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.service.BundledInsert import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveComplexFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent -import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt index 828a195367..be96af6803 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState import kotlinx.coroutines.flow.MutableStateFlow @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index 3dd7ee1089..845f76928b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -28,8 +28,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 786ece9fba..a5d3eaf40a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt index c7e968742c..1fbdc53637 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.imageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser @@ -42,7 +43,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt index b4f1a10d8e..6ec8901131 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt @@ -36,7 +36,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt index 1a2357a84e..52938f54d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.LifecycleResumeEffect -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent @Composable fun WatchLifecycleAndUpdateModel(model: InvalidatableContent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index 5427119880..b9580ea8ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5a52fc7e9e..8c13a982ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -61,8 +61,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.isCommunityDefinition import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.model.textNoteModifications import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.approve @@ -78,8 +80,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation -import com.vitorpamplona.amethyst.commons.ui.note.isCommunityDefinition -import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a394481d99..b469b4091e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -216,6 +216,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet @@ -225,6 +226,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -1480,10 +1482,15 @@ fun zapClick( choices.size == 1 -> { // One-tap fast path is Lightning-only. If the recipient can't - // receive Lightning (no lud16/lud06), firing a zap here would just - // fail — open the picker instead so the rail-aware chip can route to - // cashu / on-chain / reload. - val caps = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState) + // receive Lightning (no lud16/lud06, and no BOLT12 offer our wallet + // can pay), firing a zap here would just fail — open the picker + // instead so the rail-aware chip can route to cashu / on-chain / reload. + val caps = + RailCapabilityResolver.peek( + baseNote, + accountViewModel.account.cashuWalletState, + bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(), + ) if (caps.hasLightning) { onZapStarts() accountViewModel.zap( @@ -2124,16 +2131,26 @@ fun observeZapRailCapability( ): RailCapability { val cashuState = accountViewModel.account.cashuWalletState val author = baseNote.author - // These four are deliberately read only to drive the recompute below — do NOT - // delete them as "unused". Each observe* call ALSO subscribes the relay fetch - // (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is - // open), and each value is a remember() key so railCapability recomputes when - // it arrives. RailCapabilityResolver.peek re-reads everything itself; these - // just say *when* to re-run it. + // Every value below up to `showOnchainWallet` is deliberately read only to drive + // the recompute — do NOT delete them as "unused". Each observe* call ALSO + // subscribes the relay fetch (so a not-yet-seen kind:0 / kind:10019 / kind:10058 + // gets pulled in while the popup is open), and each value is a remember() key so + // railCapability recomputes when it arrives. RailCapabilityResolver.peek re-reads + // everything itself; these just say *when* to re-run it. val cashuMints by cashuState.mints.collectAsStateWithLifecycle() val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle() val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value } val nutzapInfo = author?.let { observeNoteEvent(it.nutzapInfoNote, accountViewModel).value } + // BOLT12 route inputs, same contract: the recipient's kind:10058 offer list + // (rides in UserMetadataForKeyKinds beside kind:0) and our default NWC wallet, + // whose `pay` support decides whether that offer makes them Lightning-payable. + val bolt12OfferList = author?.let { observeNoteEvent(it.bolt12OfferListNote, accountViewModel).value } + val nip47State = accountViewModel.account.nip47SignerState + val defaultWalletUri by nip47State.defaultWalletUri.collectAsStateWithLifecycle() + // The wallet's kind:13194 info (its `pay` support) is a plain cache read inside + // canZapViaBolt12(); this counter is what recomputes when it lands after opening. + val walletInfoUpdates by remember(nip47State) { nip47State.infoCache?.updates ?: MutableStateFlow(0) } + .collectAsStateWithLifecycle() // Honors the user's "show on-chain wallet" preference: off hides the on-chain // rail from the zap chips too, matching the wallet screen, profile chips, and // Send Payment screen. @@ -2180,11 +2197,20 @@ fun observeZapRailCapability( cashuEntries, recipientInfo, nutzapInfo, + bolt12OfferList, + defaultWalletUri, + walletInfoUpdates, showPayToChip, recipientPayTo, payToApps, ) { - val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip) + val rc = + RailCapabilityResolver.peek( + baseNote, + cashuState, + showPayToChip, + bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(), + ) if (onchainEnabled) { rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 5d9161d1e6..28a5d99dfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import android.graphics.ColorFilter import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter -import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -36,6 +35,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -159,7 +159,7 @@ fun LocationPreviewMap( remember(pinColor, pinEmoji) { if (pinColor != null && pinEmoji != null) { val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) - BitmapDrawable(context.resources, bitmap) + bitmap.toDrawable(context.resources) } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index 6839746d97..0d4b5ce7bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -228,7 +228,7 @@ private fun MyLoadUrlPreviewDirect( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxHeight().aspectRatio(1f), ) - } else if (state.previewInfo.mimeType.startsWith("video")) { + } else if (state.previewInfo.mimeType.startsWith("video") || state.previewInfo.mimeType.startsWith("audio")) { VideoView( state.previewInfo.url, mimeType = state.previewInfo.mimeType, @@ -297,7 +297,7 @@ private fun MyLoadUrlPreviewDirectFillWidth( contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth(), ) - } else if (state.previewInfo.mimeType.startsWith("video")) { + } else if (state.previewInfo.mimeType.startsWith("video") || state.previewInfo.mimeType.startsWith("audio")) { VideoView( state.previewInfo.url, mimeType = state.previewInfo.mimeType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 9655ea3835..600109abcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -46,7 +46,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessGameViewer +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessGameViewer import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chess_accept import com.vitorpamplona.amethyst.commons.resources.chess_decline diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index ee51d3ba73..ea5f987dc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -35,10 +35,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel -import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt index d0dfbbe2d4..0706b83d8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt @@ -34,8 +34,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists -import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AndroidFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AndroidFeedViewModel.kt index 55d89aa772..0e276722ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AndroidFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AndroidFeedViewModel.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 154db53254..3b2213ddc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -28,8 +28,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index d2e039deb8..11a03b2d60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -25,10 +25,10 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -118,6 +118,5 @@ open class UserFeedViewModel( override fun onCleared() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundler.cancel() - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 29253b62fa..6431de9af5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.ComponentCallbacks2 import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index cda7e35f72..c53ba18afc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Dao import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuild import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCacheAsync @@ -1196,7 +1196,7 @@ class AccountViewModel( .isNotEmpty() /** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */ - fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay() + fun canPayBolt12ViaNwc(): Boolean = account.zaps.canZapViaBolt12() /** * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 @@ -2647,7 +2647,6 @@ class AccountViewModel( com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge .clear() feedStates.destroy() - super.onCleared() } fun loadMentions( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/TopNavFeedQueryStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/TopNavFeedQueryStates.kt index 9a39283b4e..8c38940c35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/TopNavFeedQueryStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/TopNavFeedQueryStates.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import kotlinx.coroutines.flow.StateFlow /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt index 253880b693..43746d124d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt @@ -30,7 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt index c1d40b1334..6a791ed454 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt index 599bc74ce5..305e69cb6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt index eb623a3b65..dfabe677ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt index 13a20eabe9..530d9ec0c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt index 55a57e62d3..f8b64f5039 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account class BookmarkPrivateFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt index 7909af0e93..e6d7cba209 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account class BookmarkPublicFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt index e816b151e4..8daddfc649 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt @@ -214,7 +214,7 @@ private fun Picture( onValueChange = { bookmarkGroupInfoViewModel.picture.value = it }, placeholder = { Text( - text = "http://mygroup.com/logo.jpg", + text = "https://mygroup.com/logo.jpg", color = MaterialTheme.colorScheme.placeholderText, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt index 7ad12a6445..d2191ed8a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account class OldBookmarkPrivateFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt index ef339929d4..db18f1075c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account class OldBookmarkPublicFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt index 2e94d7e0f2..a24837c64b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt index 9cc4f867cd..f59d590b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index ad6c0e879b..771037b0ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() { override fun onCleared() { stopObserving() stopWatching() - super.onCleared() } /** One decrypted observer telemetry frame rendered on the Observer tab. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt index e3f106f595..fe0d4e9ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() { override fun onCleared() { stopWatching() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index dd2284163e..4fd4f3509f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() { override fun onCleared() { liveJob?.cancel() liveJob = null - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt index 62ead90720..1f60812ea3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() { override fun onCleared() { stopWatching() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 4dc32ff52c..10982c18d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() { override fun onCleared() { stopWatching() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt index 4defd26faf..3aa84b8df5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index b14bfa1625..e96ef8220b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note @@ -56,8 +58,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_collection_count import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_subtitle import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_title import com.vitorpamplona.amethyst.commons.resources.calendar_export_event -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index 96c4bf8dc0..d2d37ba6ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -51,6 +51,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange @@ -63,8 +65,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_day_subtitle import com.vitorpamplona.amethyst.commons.resources.calendar_empty_day_title import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_day import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_day -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index 66ef72ab54..782330a58a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,11 +59,15 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale +import java.util.concurrent.ConcurrentHashMap // Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new // SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling. -private val MonthShortFormatter: DateTimeFormatter = - DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) +// Cached per locale rather than in a single val that captures the locale once: the month +// names have to follow a language the user changes while the app is running. +private val monthShortFormatters = ConcurrentHashMap() + +private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) } @Composable fun CalendarEventListCard( @@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) { Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate() } val day = localDate.dayOfMonth.toString() - val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() } + // LocalLocale rather than Locale.getDefault(): the latter is not observable, so a + // locale change while the app runs would leave the month name in the old language. + val locale = LocalLocale.current.platformLocale + val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() } Column( modifier = Modifier.size(width = 52.dp, height = 60.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt index 08b0b3ec65..187b9202c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt @@ -38,6 +38,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds @@ -46,8 +48,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_subtitle import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_title import com.vitorpamplona.amethyst.commons.resources.calendar_section_past import com.vitorpamplona.amethyst.commons.resources.calendar_section_upcoming -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt index eb411f87f7..607d74acc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -53,6 +53,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MONTH_GRID_MAX_LANES import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars @@ -62,8 +64,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_day_a11y_selected_s import com.vitorpamplona.amethyst.commons.resources.calendar_day_a11y_today_suffix import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_month import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_month -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt index da789203af..1918fe346d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -51,6 +51,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded import com.vitorpamplona.amethyst.commons.resources.Res @@ -60,8 +62,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_week_subtitle import com.vitorpamplona.amethyst.commons.resources.calendar_empty_week_title import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_week import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_week -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt index cf32ddb379..1222688659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt index 84e811a228..be0fdf4c00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt @@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() { override fun onCleared() { liveScanJob?.cancel() - super.onCleared() } fun toggle(address: Address) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt index 504f1a54f9..95c026daf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt index ef882c6bd6..38079db31d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 317608eda0..b3792d2d13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -36,9 +36,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt index b0179bf111..29bc3b2e5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.MarmotGroupFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.MarmotGroupFeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt index ad768a3be6..81ba7c19cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -119,7 +119,6 @@ class MarmotFileUploader( // imprecisely. val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename) - val v2Cipher = cipher item.orchestrator.uploadEncrypted( uri = media.uri, @@ -142,21 +141,19 @@ class MarmotFileUploader( // compression and metadata stripping — because that is what the // key was derived from. val reference = - v2Cipher?.let { - EncryptedMediaReferenceV2( - locators = - listOf( - MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url), - ), - ciphertextSha256 = it.ciphertextSha256, - plaintextSha256 = it.plaintextSha256, - nonce = it.nonce, - mediaType = it.mediaType, - filename = filename, - dim = serverResult.fileHeader.dim?.toString(), - thumbhash = serverResult.fileHeader.thumbHash?.thumbhash, - ) - } + EncryptedMediaReferenceV2( + locators = + listOf( + MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url), + ), + ciphertextSha256 = cipher.ciphertextSha256, + plaintextSha256 = cipher.plaintextSha256, + nonce = cipher.nonce, + mediaType = cipher.mediaType, + filename = filename, + dim = serverResult.fileHeader.dim?.toString(), + thumbhash = serverResult.fileHeader.thumbHash?.thumbhash, + ) results.add( Mip04UploadResult( url = serverResult.url, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 3509835ae2..41834f744f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -38,13 +38,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip04 import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip17 import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt index 4c9475a391..2d205233e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.ChatroomFeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 4c7e45f109..863e3ff8ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -801,7 +801,6 @@ class ChatNewMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index d2866b00b2..fd64e9dfff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -54,14 +54,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 6816068e04..b2f6b23c37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.ChangesFlowFilter import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index 7326bbb8c7..6371037daf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -285,7 +285,7 @@ private fun Picture( onValueChange = { postViewModel.channelPicture.value = it }, placeholder = { Text( - text = "http://mygroup.com/logo.jpg", + text = "https://mygroup.com/logo.jpg", color = MaterialTheme.colorScheme.placeholderText, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index 6ac0c0237d..4bf9340445 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -46,8 +48,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_invite_only_to_post import com.vitorpamplona.amethyst.commons.resources.relay_group_join_to_post import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 6589a3b8c1..c0f3f19e0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note @@ -68,7 +69,6 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_browse_title import com.vitorpamplona.amethyst.commons.resources.relay_group_favorite_relay import com.vitorpamplona.amethyst.commons.resources.relay_group_message_count_short_capped import com.vitorpamplona.amethyst.commons.resources.select_list_to_filter -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11 import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt index 6f716453b4..4820b9f746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 4b51c95c6e..baab2e6279 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -1027,7 +1027,6 @@ open class ChannelNewMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 3d2c05aacf..ea593a5005 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -28,7 +29,6 @@ import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.publicChatChannelIdOf -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index e6abb78322..216f74e473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 0a64279bcd..4347511358 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -38,6 +38,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus @@ -45,8 +47,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip04 import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip17 import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index eb6778de01..591388afaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -47,7 +48,6 @@ import com.vitorpamplona.amethyst.commons.resources.mark_all_as_read import com.vitorpamplona.amethyst.commons.resources.mark_all_known_as_read import com.vitorpamplona.amethyst.commons.resources.mark_all_new_as_read import com.vitorpamplona.amethyst.commons.resources.mark_as_read_dialog_title -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index cc370097e3..6f7b18e1f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt index cadfd00a4b..ad037ac95b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 955350ef91..ee13250077 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -35,7 +35,7 @@ import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt index 03338511ce..17260fb9d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt index bcfd3daf8b..336b6d4877 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt @@ -35,10 +35,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.share_to_dm_start_new import com.vitorpamplona.amethyst.commons.resources.share_to_dm_title -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 30708947a1..4fcdc9cdc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -61,10 +61,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastBanner import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncBanner -import com.vitorpamplona.amethyst.commons.nip64Chess.LiveChessGameScreen +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.LiveChessGameScreen import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chess_game_id import com.vitorpamplona.amethyst.commons.resources.chess_game_not_found diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index a22798865f..108f71cb76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -62,16 +62,16 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.nip64Chess.ActiveGameCard -import com.vitorpamplona.amethyst.commons.nip64Chess.ChallengeCard import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncBanner -import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGameCard -import com.vitorpamplona.amethyst.commons.nip64Chess.NewChessGameDialog -import com.vitorpamplona.amethyst.commons.nip64Chess.OutgoingChallengeCard -import com.vitorpamplona.amethyst.commons.nip64Chess.OverlappingAvatars -import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGameCard -import com.vitorpamplona.amethyst.commons.nip64Chess.SpectatingGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ActiveGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChallengeCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.CompletedGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.OutgoingChallengeCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.OverlappingAvatars +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.PublicGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.SpectatingGameCard import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.dismiss import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 8966460217..893c23fb17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -132,7 +132,6 @@ class ChessViewModelNew( fun clearFocusedGame() = logic.clearFocusedGame() override fun onCleared() { - super.onCleared() logic.stopPolling() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt index 635d797f9c..e51a5ac748 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt index a7363ee286..4b45bfa7ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt index 4b97bad3f2..2f9ec5a1b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt index 05c625e419..918521a43d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt @@ -32,8 +32,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index a5a4106e7b..7f9d31b72a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -51,10 +51,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index 3628391d1b..e4e5f8198e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 0488d076df..ed5ff94326 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -739,7 +739,6 @@ class LongFormPostViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt index ac0e44e899..d6e8a47c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index 488136911f..5dbd40284b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index a16abbbcc1..4ac0f53b5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter @@ -29,7 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ParticipantListBuilder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt index a7f73f90a5..1c3edc905b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.mapNotNullIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index e5df683ea7..5beb3f634d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter @@ -30,7 +31,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ParticipantListBuilder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index 06d36dcad0..0ac05f6360 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 3075d4a6a7..32a137507f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -612,7 +612,6 @@ open class NewProductViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 8c00e42fe7..2dd017f1f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -49,8 +51,6 @@ import com.vitorpamplona.amethyst.commons.resources.delete_all import com.vitorpamplona.amethyst.commons.resources.delete_all_drafts_confirmation import com.vitorpamplona.amethyst.commons.resources.no import com.vitorpamplona.amethyst.commons.resources.yes -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt index 0a23a0d673..b7a69869b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index 037a14310f..25ed12f438 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt index 52d115b3da..4fb8ca7ed9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt @@ -35,9 +35,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt index 82d9e7fe0e..668f4791d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt index 75b5991491..920d1b43e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index 084878b853..9457ddaac8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.isRenderableRepost import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt index b0abc23470..319dc4d92a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache.checkGetOrCreateUser import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt index 91d7f0f959..a58a481b32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksScreen.kt index 201b1a289b..845fc2a46a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index b6b1a5074f..91f53b621d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index 6733218386..203c14141b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -46,13 +46,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.git_pr_revised import com.vitorpamplona.amethyst.commons.resources.git_untitled -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.note.StatusKind import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index afc1cf9958..bee22ce43d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -77,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.resources.git_repo_tab_code import com.vitorpamplona.amethyst.commons.resources.git_repo_tab_issues import com.vitorpamplona.amethyst.commons.resources.git_repo_tab_patches import com.vitorpamplona.amethyst.commons.resources.loading_feed -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import com.vitorpamplona.amethyst.model.GitStatusIndex diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt index bd9adcdb33..ccd10c98fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GitStatusIndex import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt index 53e867679d..31a84ce7ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GitStatusIndex import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt index 71f0dbdf70..55c77e3fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt @@ -46,11 +46,11 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.git_repositories_search_no_results import com.vitorpamplona.amethyst.commons.resources.git_repositories_search_placeholder import com.vitorpamplona.amethyst.commons.search.GitRepositorySearchMatcher -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt index e2f1dba118..3df33d4b16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt index 75ec475770..4f69da5a67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt @@ -37,12 +37,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.hashtag_label_added_by -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index bf25ad758a..d296144a0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.isRenderableRepost import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/HighlightsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/HighlightsScreen.kt index d3882b7b47..a81a8a694d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/HighlightsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/HighlightsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/dal/HighlightsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/dal/HighlightsFeedFilter.kt index 718b087366..5051f6d333 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/dal/HighlightsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/dal/HighlightsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 5455702064..e9ae7e948e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -56,6 +56,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel @@ -63,8 +65,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.feed_is_empty import com.vitorpamplona.amethyst.commons.resources.refresh -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt index c7152db058..9443a0555e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -30,7 +30,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.nip64Chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.NewChessGameDialog import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.new_chess_game import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index af9a45edd5..cf79d6b90d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1977,7 +1977,6 @@ open class ShortNotePostViewModel : } override fun onCleared() { - super.onCleared() writingAssistant?.close() writingAssistant = null Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 3ba074d1ca..0bf353eea1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -320,7 +320,6 @@ class VoiceReplyViewModel : ViewModel() { override fun onCleared() { cancel() - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index e110a1654a..abd1f6c5ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.HomeFeedType import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeEverythingFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeEverythingFeedFilter.kt index 26dadb6048..9f0d69fce1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeEverythingFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeEverythingFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 547f9f8901..8322b27b31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveComplexFeedFilter import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel @@ -31,7 +32,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveComplexFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index e608a81ed4..71065b32bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.isRenderableRepost import com.vitorpamplona.amethyst.commons.model.HomeFeedType import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index 40d02d603f..76fa83e1ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -87,7 +87,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn private const val PREVIEW_LIST_TITLE = "Sample List Title" private const val PREVIEW_LIST_DESCRIPTION = "Sample List Description" -private const val PREVIEW_LIST_IMAGE = "http://some.com/image.png" +private const val PREVIEW_LIST_IMAGE = "https://some.com/image.png" @Preview() @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt index 230a7067fe..e6589161d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt @@ -238,7 +238,7 @@ private fun Picture( onValueChange = { postViewModel.picture.value = it }, placeholder = { Text( - text = "http://mygroup.com/logo.jpg", + text = "https://mygroup.com/logo.jpg", color = MaterialTheme.colorScheme.placeholderText, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt index 1d82e23afc..bd22a19493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt @@ -238,7 +238,7 @@ private fun Picture( onValueChange = { postViewModel.picture.value = it }, placeholder = { Text( - text = "http://mygroup.com/logo.jpg", + text = "https://mygroup.com/logo.jpg", color = MaterialTheme.colorScheme.placeholderText, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsFeedLoaded.kt index e63ad00f2b..09e5840161 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsScreen.kt index 8b0d167268..c19068f21d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt index e3ca5aeca1..de76ab6728 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter @@ -29,7 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ParticipantListBuilder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt index c745740b84..9f632ce190 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt index 25c8b39000..d031907c6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt index 6dc84b8580..b49dffd907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.video.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -86,7 +86,7 @@ class LongsFeedFilter( SupportedContent( blockedUrls = listOf("youtu.be", "youtube.com"), mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, - supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(), + supportedFileExtensions = (RichTextParser.videoExt + RichTextParser.imageExt).toSet(), ) fun acceptableVideoiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt index 3707b774fa..23078cb368 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -102,7 +102,6 @@ class AddToMusicPlaylistViewModel : ViewModel() { override fun onCleared() { liveScanJob?.cancel() - super.onCleared() } private suspend fun rescan() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt index f6c682e86a..a52e76df5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt index e12a3821a4..4015bc5d23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt index 0aa5490343..1fafc57f72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt index 3420b802e7..dde24bcf73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt index ef54874270..b0ad642776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt index f9854ff6a8..958da2ee90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 58ac9f3239..4085b82674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res @@ -67,7 +68,6 @@ import com.vitorpamplona.amethyst.commons.resources.nests_section_live_now import com.vitorpamplona.amethyst.commons.resources.nests_section_recently_ended import com.vitorpamplona.amethyst.commons.resources.nests_section_scheduled import com.vitorpamplona.amethyst.commons.resources.wallet_loading -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt index 76be48c844..9dbc1a5715 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -41,7 +42,6 @@ import com.vitorpamplona.amethyst.commons.resources.nest_no_server_body import com.vitorpamplona.amethyst.commons.resources.nest_no_server_cancel import com.vitorpamplona.amethyst.commons.resources.nest_no_server_title import com.vitorpamplona.amethyst.commons.resources.nest_no_server_use_default -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt index 54b2cfd566..550a029bb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter @@ -30,7 +31,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ParticipantListBuilder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 86ebb445bb..4eb9691cfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -597,7 +597,6 @@ open class NestNewMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 0e163803bc..7015ea6654 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -24,16 +24,16 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent +import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.service.BundledInsert import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent -import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 750043a37c..dccad9ac96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet @@ -28,7 +29,6 @@ import com.vitorpamplona.amethyst.commons.model.isMutedPublicChatMessage import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -139,6 +139,9 @@ class NotificationFeedFilter( AttestationRequestEvent.KIND, ) + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") val NOTIFICATION_KINDS = // Kinds that RENDER as a row on the Notifications tab. This is a // display gate over whatever is already in LocalCache — it plays no @@ -268,6 +271,9 @@ class NotificationFeedFilter( // Shared with EventNotificationConsumer so push notifications and the // in-app feed apply the same per-kind "is this event for me" rule. + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") fun tagsAnEventByUser( note: Note, authorHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 1fbfff94a6..d573313ee9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -660,7 +660,6 @@ class NewPublicMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt index 623b11cbba..1e6a405240 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt index 50676aff69..aecd3b126b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt index c85c54af0b..291d23c13f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/dal/PinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/dal/PinnedNotesFeedFilter.kt index 0d7a2c2b2b..c4efaaad3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/dal/PinnedNotesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/dal/PinnedNotesFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account class PinnedNotesFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt index 20c132858f..70bde0af77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt index 940282b58f..20e3d00f98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index 619d71e1a8..e0e5fd90bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -43,11 +43,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.podcast_no_episodes -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt index 2827387975..44873fbc5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt index 65978ea824..35ecafb503 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -28,11 +28,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.podcast_your_podcast -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index 427b77c7c6..fc1b5f2624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt index d0ced7eea6..3022ab13a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt index f378633840..54bff2d535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt index ba3ba863d7..c05833f142 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt index 09a8bc8098..073f608c40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt index e6410a9b2d..6f2371177e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt index 4c8b039f9b..ae785a66e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt index e0166d9b09..ea695e4028 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt @@ -33,8 +33,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt index 282c4d2527..b0c57965de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/dal/ProductsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/dal/ProductsFeedFilter.kt index b94a87827f..21e4f67820 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/dal/ProductsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/dal/ProductsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.products.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/apps/dal/UserProfileAppsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/apps/dal/UserProfileAppsFeedFilter.kt index 90de260dec..106c550663 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/apps/dal/UserProfileAppsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/apps/dal/UserProfileAppsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.apps.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.filter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt index 8d1e8799f4..36fbe239b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 93142d1dc7..70541e8c39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index 67856f9ccf..d45a27d915 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -33,7 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 6530ca5812..0425c0d86a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.filter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt index f0a50ef5a5..e377c20473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -55,80 +54,19 @@ import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.bolt12_pay_with_wallet import com.vitorpamplona.amethyst.commons.resources.bolt12_payment_amount_sats -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.util.setText -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import kotlinx.coroutines.launch -@Composable -fun Bolt12PayButton( - user: User, - accountViewModel: AccountViewModel, -) { - val address = - remember(user.pubkeyHex) { - Bolt12OfferListEvent.createAddress(user.pubkeyHex) - } - - LoadAddressableNote(address, accountViewModel) { note -> - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - val event by observeNoteEvent(note, accountViewModel) - val offers = - remember(event) { - event?.offers() ?: emptyList() - } - if (offers.isNotEmpty()) { - Bolt12PayButtonWithOffers(offers, accountViewModel) - } - } - } -} - -@Composable -fun Bolt12PayButtonWithOffers( - offers: List, - accountViewModel: AccountViewModel, -) { - var expanded by remember { mutableStateOf(false) } - - FilledTonalButton( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { expanded = true }, - contentPadding = ZeroPadding, - ) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = stringRes(R.string.bolt12_offers), - ) - } - - if (expanded) { - Bolt12OffersDialog( - offers = offers, - accountViewModel = accountViewModel, - onDismiss = { expanded = false }, - ) - } -} - @Composable fun Bolt12OffersDialog( offers: List, @@ -210,7 +148,7 @@ private fun Bolt12OfferRow( .padding(horizontal = 16.dp, vertical = 10.dp), ) { Text( - text = "${offer.take(14)}…${offer.takeLast(6)}", + text = abbreviateBolt12Offer(offer), style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface, @@ -295,3 +233,10 @@ private fun Bolt12NwcAmountDialog( } } } + +/** + * The short form every BOLT12 surface shows for an `lno1…` offer: enough of the + * head to recognise the prefix, the tail to tell two offers apart. Shared by the + * profile chip, this dialog and the offers settings screen so they agree. + */ +fun abbreviateBolt12Offer(offer: String): String = "${offer.take(14)}\u2026${offer.takeLast(6)}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt index c35e73e5c4..86bea970f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -54,98 +53,20 @@ import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.no_payment_targets_message import com.vitorpamplona.amethyst.commons.resources.show_qr -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.util.setText -import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import kotlinx.coroutines.launch -@Composable -fun PaymentButton( - user: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val address = - remember(user.pubkeyHex) { - PaymentTargetsEvent.createAddress(user.pubkeyHex) - } - - LoadAddressableNote(address, accountViewModel) { note -> - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - val event by observeNoteEvent(note, accountViewModel) - val targets = - remember(event) { - event?.paymentTargets() ?: emptyList() - } - if (targets.isNotEmpty()) { - PaymentButtonWithTargets(user, targets, nav) - } - } - } -} - -@Composable -fun PaymentButtonWithTargets( - user: User, - targets: List, - nav: INav, -) { - var expanded by remember { mutableStateOf(false) } - - FilledTonalButton( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { expanded = true }, - contentPadding = ZeroPadding, - ) { - Icon( - symbol = MaterialSymbols.AccountBalanceWallet, - contentDescription = stringRes(R.string.payment_targets), - ) - } - - if (expanded) { - PaymentTargetsDialog( - targets = targets, - onDismiss = { expanded = false }, - payInApp = { target -> - // Targets one of the user's wallets can pay (lightning, bitcoin) - // go to the Send Payment screen, which collects the amount and - // confirms in place — no extra dialog. Returns false when no - // in-app wallet applies so the dialog falls back to payto://. - val route = inAppPaymentRouteFor(user.pubkeyHex, target) - if (route != null) { - expanded = false - nav.nav(route) - true - } else { - false - } - }, - ) - } -} - @Composable fun PaymentTargetsDialog( targets: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index 3501530049..5a1a29969b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -40,10 +40,6 @@ fun ProfileActions( ) { MessageButton(baseUser, accountViewModel, nav) - PaymentButton(baseUser, accountViewModel, nav) - - Bolt12PayButton(baseUser, accountViewModel) - val isMe by remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index eeda4bccf3..43e0949b2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -36,8 +36,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.bolt12_lightning_offer import com.vitorpamplona.amethyst.commons.resources.clink_lightning_offer import com.vitorpamplona.amethyst.commons.resources.send_payment_method_cashu import com.vitorpamplona.amethyst.commons.resources.send_payment_method_lightning @@ -76,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import kotlinx.coroutines.launch import androidx.compose.material3.Icon as M3Icon @@ -84,9 +88,10 @@ private val CashuPurple = Color(0xFFA855F7) /** * One FlowRow of tappable chips for every way to pay this profile: Lightning - * (lud16, long-press copies the address), the CLINK offer, the NIP-BC on-chain - * wallet, Cashu nutzaps (shown only when the logged-in user's cashu wallet - * shares a mint the recipient accepts), and the NIP-A3 payment-target chips. + * (lud16, long-press copies the address), the CLINK offer, the NIP-B1 BOLT12 + * offers (one chip each), the NIP-BC on-chain wallet, Cashu nutzaps (shown only + * when the logged-in user's cashu wallet shares a mint the recipient accepts), + * and the NIP-A3 payment-target chips. * A single FlowRow so the chips wrap together with uniform spacing instead of * stacking as separately padded rows. */ @@ -113,35 +118,57 @@ fun DisplayPaymentRailChips( .collectAsStateWithLifecycle() val onchainAvailable = showOnchainWallet && LocalCache.onchainBackend != null - val address = + val targetsAddress = remember(baseUser.pubkeyHex) { PaymentTargetsEvent.createAddress(baseUser.pubkeyHex) } + val bolt12Address = + remember(baseUser.pubkeyHex) { + Bolt12OfferListEvent.createAddress(baseUser.pubkeyHex) + } - LoadAddressableNote(address, accountViewModel) { note -> + LoadAddressableNote(targetsAddress, accountViewModel) { targetsNote -> val targets = - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - val event by observeNoteEvent(note, accountViewModel) + if (targetsNote != null) { + EventFinderFilterAssemblerSubscription(targetsNote, accountViewModel) + val event by observeNoteEvent(targetsNote, accountViewModel) remember(event) { event?.paymentTargets() ?: emptyList() } } else { emptyList() } - if (lud16.isNullOrEmpty() && clinkOffer == null && !onchainAvailable && cashuMintUrl == null && targets.isEmpty()) { - return@LoadAddressableNote - } + LoadAddressableNote(bolt12Address, accountViewModel) { bolt12Note -> + val bolt12Offers = + if (bolt12Note != null) { + EventFinderFilterAssemblerSubscription(bolt12Note, accountViewModel) + val event by observeNoteEvent(bolt12Note, accountViewModel) + remember(event) { event?.offers() ?: emptyList() } + } else { + emptyList() + } - RailAndTargetChips( - baseUser = baseUser, - lud16 = lud16, - clinkOffer = clinkOffer, - onchainAvailable = onchainAvailable, - cashuMintUrl = cashuMintUrl, - targets = targets, - accountViewModel = accountViewModel, - nav = nav, - ) + if (lud16.isNullOrEmpty() && + clinkOffer == null && + bolt12Offers.isEmpty() && + !onchainAvailable && + cashuMintUrl == null && + targets.isEmpty() + ) { + return@LoadAddressableNote + } + + RailAndTargetChips( + baseUser = baseUser, + lud16 = lud16, + clinkOffer = clinkOffer, + bolt12Offers = bolt12Offers, + onchainAvailable = onchainAvailable, + cashuMintUrl = cashuMintUrl, + targets = targets, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } @@ -151,6 +178,7 @@ private fun RailAndTargetChips( baseUser: User, lud16: String?, clinkOffer: NOffer?, + bolt12Offers: List, onchainAvailable: Boolean, cashuMintUrl: String?, targets: List, @@ -161,6 +189,9 @@ private fun RailAndTargetChips( nav.nav(Route.SendPayment(baseUser.pubkeyHex, method.routeKey)) } + // The BOLT12 offer whose pay/copy dialog is open, if any. + var bolt12DialogOffer by remember { mutableStateOf(null) } + FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -199,6 +230,23 @@ private fun RailAndTargetChips( } } + bolt12Offers.forEach { offer -> + ProfilePaymentChip( + color = BitcoinOrange, + label = stringRes(Res.string.bolt12_lightning_offer), + detail = remember(offer) { abbreviateBolt12Offer(offer) }, + copyValue = offer, + onClick = { bolt12DialogOffer = offer }, + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + tint = BitcoinOrange, + modifier = Size16Modifier, + ) + } + } + if (onchainAvailable) { ProfilePaymentChip( color = BitcoinOrange, @@ -239,6 +287,14 @@ private fun RailAndTargetChips( PaymentTargetChip(baseUser, target, accountViewModel, nav) } } + + bolt12DialogOffer?.let { offer -> + Bolt12OffersDialog( + offers = listOf(offer), + accountViewModel = accountViewModel, + onDismiss = { bolt12DialogOffer = null }, + ) + } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt index fef6bfdefd..612abae7b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.commons.resources.profile_app_recommendations_ import com.vitorpamplona.amethyst.commons.resources.profile_apps_empty_hint import com.vitorpamplona.amethyst.commons.resources.profile_apps_header import com.vitorpamplona.amethyst.commons.resources.profile_apps_header_empty -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt index dcd1c8204f..62a2c55d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index 729abddadd..28db360db3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.isRenderableRepost import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index 70cc25c600..d329203f3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 788603c341..1556915f2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.isRenderableRepost import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt index 662f03b9ab..de4838deca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip51Lists.PinListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index 4f4beac566..cb0e4df73a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -25,10 +25,10 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip01Core.RelayInfo import com.vitorpamplona.amethyst.commons.model.nip01Core.Wrapper -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -196,6 +196,5 @@ class RelayFeedViewModel : override fun onCleared() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index a5694a36e9..0f4c0e8f3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -43,10 +43,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt index 88340294fa..43696a5635 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt index f1671c7c67..2c3e605062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt index 2a8dc20881..5f8e0c7fba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt @@ -85,8 +85,8 @@ fun ShowQRScreenPreview() { name = "My Name" picture = "Picture" nip05 = null - banner = "http://banner.com/test" - website = "http://mywebsite.com/test" + banner = "https://banner.com/test" + website = "https://mywebsite.com/test" about = "This is the about me" }, MetadataEvent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt index 74065fd186..3f68ab78d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 12626cd923..bc79b4bd89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -1677,8 +1677,8 @@ fun RelayHeaderPreview() { ), SpamDebugMessage( time = TimeUtils.now() - 24000, - link1 = "http://test1.com", - link2 = "http://test2.com", + link1 = "https://test1.com", + link2 = "https://test2.com", ), ), accountViewModel = mockAccountViewModel(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt index 0918f1a13b..0a9066af70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -43,10 +43,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException @@ -90,6 +92,9 @@ class EventSync( /** Maximum number of completed-relay entries kept in the activity log. */ const val MAX_ACTIVITY_LOG = 5000 + + /** Poll interval while waiting for the last forwarded events to be acknowledged. */ + const val OUTBOX_DRAIN_POLL_MS = 100L } // ------------------------------------------------------------------------- @@ -392,6 +397,13 @@ class EventSync( val sourceRelayOfEvent = ConcurrentHashMap() + // (event id, destination) pairs already counted as sent. The outbox is + // at-least-once: it writes an event as soon as the socket is ready and + // resends everything still unacknowledged when the connection finishes + // syncing, so one event can hit the same relay twice before its OK lands. + // The relay dedups the second copy; the counters must too. + val sentPairs = ConcurrentHashMap.newKeySet() + val runningState = SyncState.Running( relaysCompleted = 0, @@ -423,7 +435,12 @@ class EventSync( success: Boolean, ) { super.onSent(relay, cmdStr, cmd, success) - if (cmd is EventCmd) { + // `success` is "written to the socket", not "OK received". A write to a + // destination that is still connecting fails and the outbox resends it + // once the socket opens; counting the failed attempt too made every + // cold destination report one extra event sent. Likewise a successful + // resend of an unacknowledged event is the same send, not a second one. + if (cmd is EventCmd && success && sentPairs.add(cmd.event.id + relay.url.url)) { var hasSent = false if (outboxDedup.contains(cmd.event.id)) { @@ -586,6 +603,13 @@ class EventSync( }, ) + // `publish` is fire-and-forget through the client's outbox, and `use` closes + // the client as soon as this block returns. Without a drain, the events + // forwarded from the last page of the last relay are still waiting for a + // socket or an OK when the outbox is destroyed — the sync reports Done and + // silently never delivers them. Bounded by the same per-relay timeout. + awaitOutboxDrain(client, outboxDedup + inboxDedup + dmDedup) + _syncState.value = SyncState.Done( totalEventsReceived = runningState.eventsReceived.value, @@ -613,4 +637,22 @@ class EventSync( } } } + + /** + * Waits until no forwarded event in [ids] has a relay left in the client's outbox, or + * until [RELAY_TIMEOUT_MS] passes. Ids that drain are dropped from the working set so + * each poll only revisits what is still pending. + */ + private suspend fun awaitOutboxDrain( + client: INostrClient, + ids: Set, + ) { + val pending = ids.toMutableSet() + withTimeoutOrNull(RELAY_TIMEOUT_MS) { + while (pending.isNotEmpty()) { + pending.removeAll { client.pendingPublishRelaysFor(it).isNullOrEmpty() } + if (pending.isNotEmpty()) delay(OUTBOX_DRAIN_POLL_MS) + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index e2cdb1d1c7..ebaa14bc7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.relayClient.search.SearchQueryState import com.vitorpamplona.amethyst.commons.search.RenderableKinds @@ -43,7 +44,6 @@ import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.search.SearchSource import com.vitorpamplona.amethyst.commons.search.SearchState import com.vitorpamplona.amethyst.commons.search.wholeInputNip19 -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.navigation.routes.Route diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt index dab29ef30a..850c0826c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt @@ -45,11 +45,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.action_unmute import com.vitorpamplona.amethyst.commons.resources.settings_muted_threads_unknown -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index 53d18baf2e..ecc970c800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -25,9 +25,9 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -116,6 +116,5 @@ open class StringFeedViewModel( override fun onCleared() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundler.cancel() - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt index 93cc0542e3..62dac1eee8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt index b93c14c97f..cca71c4d6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account class HiddenWordsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt index a9e558104a..0ebd6f32ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt index 8791105d67..4299a90a11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt index aefd536d07..81bf3daa04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt index a1708a8d03..2dae1974b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt index 4683322742..5f72c74f6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.video.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -86,7 +86,7 @@ class ShortsFeedFilter( SupportedContent( blockedUrls = listOf("youtu.be", "youtube.com"), mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, - supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(), + supportedFileExtensions = (RichTextParser.videoExt + RichTextParser.imageExt).toSet(), ) fun acceptableVideoiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt index 2229b5b40e..785031ec61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip82_section_latest_release import com.vitorpamplona.amethyst.commons.resources.nip82_section_links import com.vitorpamplona.amethyst.commons.resources.nip82_section_platforms import com.vitorpamplona.amethyst.commons.resources.nip82_section_topics -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt index 89df8a22d1..fc71e432d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt @@ -32,8 +32,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt index d731d8ff58..a08289a857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 29eb94f918..170f0d1829 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.hi_there_is_this_still_available @@ -83,7 +84,6 @@ import com.vitorpamplona.amethyst.commons.resources.send_a_direct_message import com.vitorpamplona.amethyst.commons.resources.send_the_seller_a_message import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint import com.vitorpamplona.amethyst.commons.ui.note.RenderCodeSnippetHeaderForThread diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/AndroidLevelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/AndroidLevelFeedViewModel.kt index 8872291d82..370cb640cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/AndroidLevelFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/AndroidLevelFeedViewModel.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.thread.LevelFeedViewModel import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt index fd3ad55593..f4b345b5b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index 6f86c14111..3ef26996ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -32,8 +32,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt index f671d73790..fee279e1f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt @@ -20,24 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser + class SupportedContent( val blockedUrls: List, val mimeTypes: Set, val supportedFileExtensions: Set, ) { - private fun validExtension(fullUrl: String): Boolean { - val queryIndex = fullUrl.indexOf('?') - if (queryIndex > 0) { - return supportedFileExtensions.any { fullUrl.startsWith(it, queryIndex - it.length) } - } - - val fragmentIndex = fullUrl.indexOf('#') - if (fragmentIndex > 0) { - return supportedFileExtensions.any { fullUrl.startsWith(it, fragmentIndex - it.length) } - } - - return supportedFileExtensions.any { fullUrl.endsWith(it) } - } + // Delegates to the shared matcher rather than repeating the scan, which is what let the two + // copies drift: this one also matched an extension's letters without the introducing dot, so + // nostr.build's HTML player page at `.../a__mp3` was admitted into the video feed. + private fun validExtension(fullUrl: String): Boolean = RichTextParser.hasExtensionIn(fullUrl, supportedFileExtensions) fun acceptableUrl( url: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index 462c21afdd..0db05fecc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.video.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -48,7 +48,7 @@ class VideoFeedFilter( SupportedContent( blockedUrls = listOf("youtu.be", "youtube.com"), mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, - supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(), + supportedFileExtensions = (RichTextParser.videoExt + RichTextParser.imageExt).toSet(), ) override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value.code diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index f5115a3feb..aed382fcf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -180,7 +180,6 @@ open class NewHlsVideoViewModel : ViewModel() { } override fun onCleared() { - super.onCleared() currentJob?.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 532b8c925a..8a30b81dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -459,7 +459,6 @@ class ReloadMintViewModel : ViewModel() { // The pipeline runs on the AccountViewModel scope, not this VM's, so it would // outlive the screen — cancel it when the screen goes away. job?.cancel() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index 6fde099208..f6dc022003 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -267,7 +267,6 @@ class TopUpMintViewModel : ViewModel() { // The pipeline runs on the AccountViewModel scope, not this VM's, so it would // outlive the screen — cancel it when the screen goes away. job?.cancel() - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt index f3254b872a..6f157fd4c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType data class TransactionRowLabels( val title: Title, val subtitle: String?, + /** The note a zap was for (event id or `a` address), or null for a profile zap or a plain payment. */ + val zappedNoteId: String?, ) { sealed interface Title { /** Render the counterparty's profile for this pubkey, falling back to [name]. */ @@ -83,6 +85,7 @@ data class TransactionRowLabels( return TransactionRowLabels( title = named ?: Title.Literal(fallback), subtitle = comment ?: fallback.takeIf { named != null }, + zappedNoteId = parsed?.zappedNoteId(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index dcb39e2879..ecfa6e6065 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -65,11 +66,13 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_filter_zaps import com.vitorpamplona.amethyst.commons.resources.wallet_incoming import com.vitorpamplona.amethyst.commons.resources.wallet_loading import com.vitorpamplona.amethyst.commons.resources.wallet_no_transactions +import com.vitorpamplona.amethyst.commons.resources.wallet_open_zapped_note import com.vitorpamplona.amethyst.commons.resources.wallet_outgoing import com.vitorpamplona.amethyst.commons.resources.wallet_refresh import com.vitorpamplona.amethyst.commons.resources.wallet_transactions import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime @@ -289,12 +292,23 @@ private fun TransactionItem( TransactionRowLabels.resolve(tx, directionLabel) } val counterpartyPubkeyHex = (labels.title as? TransactionRowLabels.Title.User)?.pubkeyHex + val zappedNoteId = labels.zappedNoteId Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .then( + // Before the padding, so the whole row height is the target. The + // picture keeps its own tap (profile): picture -> profile, row -> note. + if (zappedNoteId != null) { + Modifier.clickable(onClickLabel = stringRes(Res.string.wallet_open_zapped_note)) { + nav.nav(Route.Note(zappedNoteId)) + } + } else { + Modifier + }, + ).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { if (counterpartyPubkeyHex != null) { @@ -368,6 +382,17 @@ private fun TransactionItem( MaterialTheme.colorScheme.onBackground }, ) + + if (zappedNoteId != null) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + symbol = MaterialSymbols.ChevronRight, + // The row's onClickLabel already announces it. + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt index 2b3724c09b..9051c9a277 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt @@ -293,7 +293,6 @@ class CashuWalletWizardViewModel : ViewModel() { } override fun onCleared() { - super.onCleared() discovery?.cancel() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index fb3cf4a4e5..452f8c8643 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -64,6 +64,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note @@ -83,8 +85,6 @@ import com.vitorpamplona.amethyst.commons.resources.web_bookmark_url_label import com.vitorpamplona.amethyst.commons.resources.web_bookmark_url_placeholder import com.vitorpamplona.amethyst.commons.resources.yes import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -290,7 +290,7 @@ private fun WebBookmarkCard( ) { val previewInfo = (urlPreviewState as? UrlPreviewState.Loaded)?.previewInfo - if (previewInfo?.imageUrlFullPath != null) { + if (previewInfo?.imageUrlFullPath?.isNotBlank() == true) { AsyncImage( model = previewInfo.imageUrlFullPath, contentDescription = event.title() ?: previewInfo.title, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt index 24fbc15764..e3dbc9ca80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt index c4d562485f..134da7496b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt index 191cfe8053..b5c98dff33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.FilterByListParams diff --git a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so index 8b4a4b828b..77d8aa98cd 100755 Binary files a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so and b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/amethyst/src/main/jniLibs/x86_64/libarti_android.so b/amethyst/src/main/jniLibs/x86_64/libarti_android.so index a3825bccd0..f40ce4b0b2 100755 Binary files a/amethyst/src/main/jniLibs/x86_64/libarti_android.so and b/amethyst/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/amethyst/src/main/res/CLAUDE.md b/amethyst/src/main/res/CLAUDE.md index 69b914d9ee..8d3ceec4a4 100644 --- a/amethyst/src/main/res/CLAUDE.md +++ b/amethyst/src/main/res/CLAUDE.md @@ -43,7 +43,7 @@ It diffs every locale's resource names against its tree's default `values/` and exits non-zero listing any orphan. It covers **both** Crowdin-managed resource systems — the Android res trees (`amethyst/src/main/res`, `commons/src/androidMain/res`) and the Compose-Multiplatform catalog -(`commons/src/commonMain/composeResources`) — and says which consequence applies: +(`commonsUI/src/commonMain/composeResources`) — and says which consequence applies: lint only polices the Android trees, but an orphan in the Compose catalog is the same mistake and leaves a dead translation behind. diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 37b86d4990..db18f3545b 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -1466,4 +1466,7 @@ + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 6d022b3e04..10b508d2be 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -1345,4 +1345,7 @@ + diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 3aafe10d31..71f7fb1c3e 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2605,4 +2605,8 @@ Value-for-Value + + Health Connect a Amethyst diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e1aaa02f5d..077c3c79f3 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2387,4 +2387,8 @@ Value-for-Value + + Health Connect und Amethyst diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index b60111f3d4..316262c16e 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -1301,4 +1301,7 @@ + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 1622027eb9..2c0aeb5f4b 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -103,4 +103,7 @@ + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 21b2561b10..3527119238 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -1336,4 +1336,7 @@ + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 112520aafb..946bf253a0 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1358,4 +1358,7 @@ + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 0e32fa0d0e..cfc22bd566 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1351,4 +1351,7 @@ + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 7e0d1303d5..a08d4b128f 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1351,4 +1351,7 @@ + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 394232c803..ee1f75703a 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -1347,4 +1347,7 @@ + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 28fd0a596c..27754e3fe4 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -1326,4 +1326,7 @@ + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 06a04d0498..9f0cc0f74c 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1278,4 +1278,7 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 27a5519514..0318e4963a 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1420,4 +1420,7 @@ Valeur-pour-valeur + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index a04ecf23ef..90dce48694 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -375,6 +375,7 @@ परिचय अक्षरफलक कार्य पुनःप्रसारक + रहस्यीकरण भण्डार भुगतान जाल @@ -383,6 +384,7 @@ आपकी ख्याप्यकुंचिका पढें अक्षरफलक द्रुतपथ बन्धन पढें हस्ताक्षर करें तथा प्रकाशित करें आपके घटनाओं को + निजी सन्देशों का रहस्यीकरण तथा अरहस्यीकरण करें अपनी कुंचिका से उसका अपना निजी भण्डार लैटनिंग चालान का भुगतान जाल तथा ब्लोस्सम॰ संसाधन ले आएँ @@ -400,6 +402,8 @@ यह नोस्टर संलग्नक्रमक आपके रूप में एक %1$d प्रकार घटना का हस्ताक्षरण तथा प्रकाशन करना चाहता है। यह नोस्टर संलग्नक्रमक आपके रूप में एक %1$d प्रकार घटना का हस्ताक्षरण तथा प्रकाशन करना चाहता है : यह नोस्टर संलग्नक्रमक आपके रूप में एक रहस्यीकृत घटना भेजना चाहता है। + यह स्थान आपके नोस्टर कुंचिका से %1$s को एक सन्देश का रहस्यीकरण करना चाहता है। + यह स्थान आपके नोस्टर कुंचिका से %1$s को एक सन्देश का अरहस्यीकरण करना चाहता है। यह नोस्टर संलग्नक्रमक आपके पुनःप्रसारकों से घटनाओं को पढना चाहता है। यह नोस्टर संलग्नक्रमक अपना निजी भण्डार का प्रयेग करना चाहता है। यह नोस्टर संलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है। @@ -1201,6 +1205,19 @@ संगीत तथा ध्वनि पुटप्रसार धनसंग्रहण + मूल्यांकन तथा समीक्षाएँ + मूल्यांकित पाँच में से %1$s + पाठ्य संसाधन + निर्देशिका + अभिलेख + + %1$d वस्तु + %1$d वस्तुएँ + + + %1$d वस्तु अधिक + %1$d वस्तुएँ अधिक + अनुस्मारकसूची धनकोष संयोजन प्रदर्शनशैली @@ -2323,6 +2340,15 @@ अद्यतन असफल : %1$s समूह बनाने में असफल : %1$s समूह छोडनें में असफल : %1$s + पहले एक ब्लोस्सम श्रव्यदृश्याभिलेख सेवासंगणक जोडें स्थापना विकल्पों में। समूह के लिए कोई स्थान आवश्यक है संलग्न अभिलेखों का आरोहण करने के लिए। + यह समूह अब रहस्यीकृत संलग्न अभिलेखों का उपयोग करता है + इस समूह में रहस्यीकृत संलग्न अभिलेखों का उपयोग सक्षम करने में असफल : %1$s + समूह विघटित + समूह का अन्त किया जा रहा है। इसका अन्त हो जाएगा जब समूह सहमत होगा। + समूह का अन्त किया जा रहा है। आप इसे अभी भी पढ सकते हैं। + आप इस समूह को छोड रहे हैं। + आप इस समूह के सदस्य अब नहीं रहे। + समूह का विघटन असफल : %1$s जोडा जा रहा है %1$s… %1$s जोडने में असफल : %2$s अज्ञात अपक्रम @@ -2360,4 +2386,8 @@ मूल्य के लिए मूल्य + + स्वास्थ्य संयोजन तथा अमेथिस्ट diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index af6005c222..057287ec4b 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -375,6 +375,7 @@ Azonosság Billentyűzetműveletek Átjátszók + Titkosítás Tárhely Fizetések Hálózat @@ -383,6 +384,7 @@ Az Ön nyilvános kulcsának olvasása Billentyűkötések hozzárendelése Események olvasása, aláírása és közzététele + A privát üzeneteket a saját kulcsával titkosítja és fejti vissza az alkalmazás Saját privát tárhely Lightning-számlák kifizetése Webes és Blossom erőforrások lekérése @@ -400,6 +402,8 @@ Ez a nKisalkalmazás alá szeretne írni és közzétenni egy %1$d típusú eseményt az Ön nevében. Ez a nKisalkalmazás alá szeretne írni és közzétenni egy %1$d típusú eseményt az Ön nevében: Ez a nKisalkalmazás egy titkosított eseményt szeretne küldeni az Ön nevében. + Ez az oldal az Ön Nostr-kulcsával titkosítani szeretne egy üzenetet %1$s felhasználó számára. + Ez az oldal az Ön Nostr-kulcsával vissza szeretne fejteni egy üzenetet, amit %1$s felhasználótol kapott. Ez a nKisalkalmazás eseményeket szeretne olvasni az Ön átjátszóiról. Ez a nKisalkalmazás az Ön privát tárhelyét szeretné használni. Ez a nKisalkalmazás ki szeretne fizetni egy Lightning számlát. @@ -2336,6 +2340,15 @@ Nem sikerült frissíteni: %1$s Nem sikerült létrehozni a csoportot: %1$s Nem sikerült elhagyni a csoportot: %1$s + Először adjon hozzá egy Blossom médiakiszolgálót a beállításokban, mert a csoportnak szüksége van egy helyre, ahová a mellékleteket feltöltheti. + Ez a csoport mostantól titkosított mellékleteket használ + Nem sikerült átállítani ezt a csoportot a titkosított mellékletek használatára: %1$s + Csoport feloszlatva + A csoport feloszlatása akkor ér véget, amikor annak tagjai egyezségre jutnak. + Ez a csoport feloszlik, de továbbra is olvasható marad. + Ön elhagyja a csoportot. + Ön már nem tagja ennek a csoportnak. + Nem sikerült feloszlatni a csoportot: %1$s %1$s hozzáadása… Nem sikerült hozzáadni a következőt: %1$s: %2$s ismeretlen hiba @@ -2373,4 +2386,8 @@ Értéket az értékért + + Health Connect és Amethyst diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 67fc66350a..c4d8c2d138 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -1269,4 +1269,7 @@ Seharusnya %3$s + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 44b2f0e6d7..4235685088 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -1310,4 +1310,7 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 58ead362ee..d331b35a06 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -1303,4 +1303,7 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 8fa0007f18..9ba49413df 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1299,4 +1299,7 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 9acd4a2801..dd45a2d395 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -1368,4 +1368,7 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 8d728100a9..d946265b92 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2294,4 +2294,7 @@ Value-for-Value + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 2c98212fd2..59acce21ce 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -405,6 +405,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Tożsamość Operacje klawiatury Transmitery + Szyfrowanie Schowek Płatności Sieć @@ -413,6 +414,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Odczytaj swój klucz publiczny Przypisz skróty klawiszowe Przeczytaj, podpisz i opublikuj swoje wydarzenia + Zaszyfruj i odszyfruj prywatne wiadomości za pomocą klucza Własny prywatny schowek Opłać faktury Lightning Pobierz zasoby sieci web i blossom @@ -430,6 +432,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ta aplikacja nApplet chce podpisać i opublikować zdarzenie typu %1$d w Twoim imieniu. Ta aplikacja nApplet chce podpisać i opublikować zdarzenie typu %1$d w Twoim imieniu: Ten nApplet chce wysłać zaszyfrowane wydarzenie jako Ty. + Ta strona chce zaszyfrować wiadomość do %1$s za pomocą klucza Nostr. + Ta strona chce odszyfrować wiadomość od %1$s za pomocą klucza Nostr. Ten nApplet chce odczytywać wydarzenia z twoich transmiterów. Ta aplikacja nApplet chce korzystać ze swojego prywatnego schowka. Ta aplikacja nApplet chce zapłacić fakturę w systemie Lightning. @@ -2554,6 +2558,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Nie udało się zaktualizować: %1$s Nie udało się utworzyć grupy: %1$s Nie udało się opuścić grupy: %1$s + Dodaj w ustawieniach serwer multimediów „Blossom” — grupa potrzebuje miejsca, do którego będzie można przesyłać załączniki. + Ta grupa używa zaszyfrowanych załączników + Nie udało się przełączyć tej grupy na tryb załączników szyfrowanych: %1$s + Grupa rozwiązana + Zakończenie działalności grupy. Grupa kończy działalność po osiągnięciu porozumienia. + Ta grupa zostanie zamknięta. Nadal można ją przeglądać. + Opuszczasz tę grupę. + Nie jesteś już członkiem tej grupy. + Nie można rozwiązać grupy: %1$s Dodawanie %1$s… Nie udało się dodać %1$s: %2$s nieznany błąd @@ -2591,4 +2604,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wartość-za-wartość + + Health Connect i Amethyst diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 4e732dfe42..2dde5acce8 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1210,6 +1210,10 @@ Recurso de aprendizagem Diretório Registro de arquivo + + %1$d item + %1$d itens + mais %1$d item mais %1$d itens @@ -2381,4 +2385,8 @@ Valor-para-Valor + + Health Connect e Amethyst diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index eff87a15de..a5ec0d9bcc 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1313,4 +1313,7 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 653fb152fc..7623077a1f 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -1386,4 +1386,7 @@ + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 9f3ed8dea8..275b77b36a 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1381,4 +1381,7 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 5e6276153b..ad8489e5e1 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2530,4 +2530,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vrednost za vrednost + diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 77cd6ecf04..83c61da299 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -1356,4 +1356,7 @@ + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 2a5ff55f6e..29bb166f4c 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1536,6 +1536,7 @@ Aviseringar + Media Kalendrar Kalenderlistor Schack @@ -2384,4 +2385,8 @@ Värde -för-värde + + Health Connect och Amethyst diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index ad99395c0f..a9fad01a27 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -1334,4 +1334,7 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 3bd3126c9b..0397bcc831 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -1320,4 +1320,7 @@ + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index b3194b37d2..cbb2b4c1d1 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -1172,4 +1172,7 @@ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 47339a52ac..ed79f95c32 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -1317,4 +1317,7 @@ + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 5ec3ceeb90..02b5d8813c 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -1381,4 +1381,7 @@ + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 95edfb8276..aa6090f8e9 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -1336,4 +1336,7 @@ + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 9dc89e7412..82e55f65bc 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -1287,4 +1287,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index c8dd0fab60..2c8beb4150 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1827,4 +1827,7 @@ Value-for-Value + diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 63bd6b135e..6f23be117d 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -1321,4 +1321,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 55fe72178d..5ac2435775 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1321,4 +1321,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index c2e3c70e20..4bb6cb6493 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -1297,4 +1297,7 @@ + diff --git a/amethyst/src/main/res/values/colors.xml b/amethyst/src/main/res/values/colors.xml index 611066da6b..1f20970d04 100644 --- a/amethyst/src/main/res/values/colors.xml +++ b/amethyst/src/main/res/values/colors.xml @@ -1,13 +1,7 @@ - #FFBB86FC #FF6200EE #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - #00FFFFFF diff --git a/amethyst/src/test/java/android/util/Log.java b/amethyst/src/test/java/android/util/Log.java index 245a440a12..4272c9bdd5 100644 --- a/amethyst/src/test/java/android/util/Log.java +++ b/amethyst/src/test/java/android/util/Log.java @@ -1,8 +1,17 @@ package android.util; public class Log { - public static Boolean isLoggable(String tag, Integer msg) { - return true; + // Primitive signature on purpose: OkHttp's Android platform probe (AndroidLog.enableLogging) + // links against `boolean isLoggable(String, int)`, and a boxed variant is a different method. + // Answering false keeps OkHttp from installing its Android log handler, which would route + // every internal task-runner trace through println() below on the dispatcher threads. + public static boolean isLoggable(String tag, int level) { + return false; + } + + public static int println(int priority, String tag, String msg) { + System.out.println(tag + ": " + msg); + return 0; } public static int d(String tag, String msg) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt index c0bfc3a5c5..865f6c1457 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt @@ -47,6 +47,9 @@ import java.io.File */ class LocalCacheSearchParityTest { companion object { + /** Hoisted out of [loadCorpus]: building a Json format is expensive enough that the compiler warns on it. */ + private val json = Json { ignoreUnknownKeys = true } + private lateinit var corpus: List @BeforeClass @@ -57,7 +60,7 @@ class LocalCacheSearchParityTest { .firstOrNull { it.isFile } ?: error("tools/search-parity/fixture.json is missing; run tools/search-parity/fetch_fixtures.py") - val root = Json { ignoreUnknownKeys = true }.parseToJsonElement(file.readText()).jsonObject + val root = json.parseToJsonElement(file.readText()).jsonObject corpus = root["cases"]!! .jsonArray diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt new file mode 100644 index 0000000000..98fe9c90fc --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class Bolt12LightningFallbackTest { + @Test + fun offerSideRefusalsRetryOverLightning() { + listOf( + NwcErrorCode.EXPIRED, + NwcErrorCode.NOT_FOUND, + NwcErrorCode.BAD_REQUEST, + NwcErrorCode.NOT_IMPLEMENTED, + NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, + NwcErrorCode.UNSUPPORTED_NETWORK, + ).forEach { code -> + assertTrue("$code should fall back to BOLT11", Bolt12LightningFallback.shouldRetry(code)) + } + } + + @Test + fun aFailedOrAmbiguousPaymentNeverRetries() { + // PAYMENT_FAILED "may be due to a timeout" (NIP-47): the HTLC can still settle. + // The catch-alls and a reply with no code say nothing about whether funds moved. + listOf( + NwcErrorCode.PAYMENT_FAILED, + NwcErrorCode.INTERNAL, + NwcErrorCode.OTHER, + null, + ).forEach { code -> + assertFalse("$code may already have paid; a retry could double-spend", Bolt12LightningFallback.shouldRetry(code)) + } + } + + @Test + fun senderSideRefusalsDoNotRetry() { + listOf( + NwcErrorCode.INSUFFICIENT_BALANCE, + NwcErrorCode.QUOTA_EXCEEDED, + NwcErrorCode.RATE_LIMITED, + NwcErrorCode.RESTRICTED, + NwcErrorCode.UNAUTHORIZED, + NwcErrorCode.UNSUPPORTED_ENCRYPTION, + ).forEach { code -> + assertFalse("$code is about our wallet, BOLT11 would fail the same way", Bolt12LightningFallback.shouldRetry(code)) + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt index c235ee280a..773af27f96 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt @@ -40,6 +40,7 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.io.encoding.Base64 class BlossomReadAuthTokenProviderTest { private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6" @@ -160,8 +161,8 @@ class BlossomReadAuthTokenProviderTest { * End-to-end BUD-11 check on the token this path actually mints: reused * across every blob on the host, so it must be `server`-scoped and carry no * `x` tag ("When `x` tags are present, the token is only valid for - * operations on the specified blob hashes"), and be Base64url without - * padding. + * operations on the specified blob hashes"), and be standard padded Base64 + * that a strict decoder accepts. */ @Test fun mintedTokenIsAReusableBud11GetToken() = @@ -170,9 +171,9 @@ class BlossomReadAuthTokenProviderTest { val token = provider.header(host)!!.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME) - assertTrue("token must be base64url without padding, got: $token", token.none { it == '=' || it == '+' || it == '/' }) - val event = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString() + // Strict standard decode, as deployed servers do — see BlossomAuthorizationEvent.rawToken. + val event = Base64.decode(token).decodeToString() val parsed = JacksonMapper.fromJson(event) as BlossomAuthorizationEvent assertEquals(BlossomAuthorizationEvent.KIND, parsed.kind) @@ -218,6 +219,35 @@ class BlossomReadAuthTokenProviderTest { ) } + /** + * The same single-flight guarantee, but with a signature that returns almost + * immediately — an in-process [NostrSignerInternal], which is what the image + * path uses for a local key. + * + * A fast signature is the harder case: the leader can finish, cache its token + * and retire its in-flight entry while a straggler is still between its own + * cache miss and its look at the in-flight map. That straggler finds both + * empty, and must pick the just-minted token up instead of signing a second + * one. Run over many rounds because the window is only microseconds wide. + */ + @Test + fun aFastSignerStillSharesOneSignature() = + runBlocking { + repeat(ROUNDS) { round -> + val instant = DelayingTestSigner(delayMs = 0) + val provider = BlossomReadAuthTokenProvider({ instant }, scope) + + val results = + (1..CONCURRENT_CALLERS) + .map { async(Dispatchers.Default) { provider.header(host) } } + .awaitAll() + + assertEquals("round $round: one signature for $CONCURRENT_CALLERS callers", 1, instant.signatures) + assertEquals("round $round: every caller must get the same token", 1, results.toSet().size) + assertNotNull("round $round: token must be non-null", results.first()) + } + } + private companion object { // Matches OkHttpClientFactory's maxRequestsPerHost: the worst realistic // burst is one gated host filling every per-host dispatcher slot. @@ -225,5 +255,9 @@ class BlossomReadAuthTokenProviderTest { // How long one signature takes in the concurrency test. const val SIGN_MS = 300L + + // Rounds of the fast-signer burst. The leader-finished-early window is + // microseconds wide, so one round hits it only now and then. + const val ROUNDS = 200 } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt new file mode 100644 index 0000000000..907a0b1ec4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStream +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread + +/** + * The Android app's socket must answer a relay-initiated close, like quartz's + * `BasicOkHttpWebSocket` (see `BasicOkHttpWebSocketCloseHandshakeTest` there for the full story). + * OkHttp fires `onClosed` only once BOTH peers have sent a CLOSE frame; until [OkHttpWebSocket] + * sent ours, a relay's close left the socket half-closed and the relay client believed it was + * connected until the ping path failed minutes later. + * + * Same minimal RFC 6455 loopback server as the quartz test: the two socket classes live in + * different modules with no shared test fixtures, and the harness is small enough to carry twice. + */ +class OkHttpWebSocketCloseHandshakeTest { + private class TinyRelay : AutoCloseable { + private val server = ServerSocket(0) + val url = NormalizedRelayUrl("ws://127.0.0.1:${server.localPort}/") + + private val handshaken = CountDownLatch(1) + val clientCloseFrame = CountDownLatch(1) + val clientCloseCode = AtomicInteger(-1) + + private var socket: Socket? = null + private var out: OutputStream? = null + + private val thread = + thread(isDaemon = true, name = "tiny-relay") { + runCatching { + val s = server.accept() + socket = s + val input = s.getInputStream() + val output = s.getOutputStream() + out = output + handshake(input, output) + handshaken.countDown() + readFrames(input) + } + } + + private fun handshake( + input: InputStream, + output: OutputStream, + ) { + var key: String? = null + val line = StringBuilder() + while (true) { + val c = input.read() + check(c != -1) { "EOF during handshake" } + if (c == '\n'.code) { + val l = line.toString().trim() + if (l.isEmpty()) break + if (l.lowercase().startsWith("sec-websocket-key:")) key = l.substring(18).trim() + line.setLength(0) + } else if (c != '\r'.code) { + line.append(c.toChar()) + } + } + val accept = + Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-1").digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray()), + ) + output.write( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: $accept\r\n\r\n" + ).toByteArray(Charsets.ISO_8859_1), + ) + output.flush() + } + + /** Client frames are masked; decode enough to spot a CLOSE and read its status code. */ + private fun readFrames(input: InputStream) { + while (true) { + val b0 = input.read() + if (b0 == -1) return + val b1 = input.read() + if (b1 == -1) return + val opcode = b0 and 0x0F + var len = b1 and 0x7F + if (len == 126) { + len = (input.read() shl 8) or input.read() + } else if (len == 127) { + len = 0 + repeat(8) { len = (len shl 8) or input.read() } + } + val masked = (b1 and 0x80) != 0 + val mask = if (masked) ByteArray(4) { input.read().toByte() } else ByteArray(4) + val payload = ByteArray(len) { i -> (input.read() xor mask[i % 4].toInt()).toByte() } + if (opcode == 0x8) { + if (len >= 2) { + clientCloseCode.set(((payload[0].toInt() and 0xFF) shl 8) or (payload[1].toInt() and 0xFF)) + } + clientCloseFrame.countDown() + } + } + } + + fun awaitClient() = handshaken.await(5, TimeUnit.SECONDS) + + /** Server-initiated CLOSE, status 1000, unmasked as servers send it. The TCP session stays open. */ + fun sendClose() { + val output = checkNotNull(out) { "no client yet" } + output.write(byteArrayOf(0x88.toByte(), 0x02, 0x03, 0xE8.toByte())) + output.flush() + } + + override fun close() { + runCatching { socket?.close() } + runCatching { server.close() } + thread.join(2_000) + } + } + + private class Recorder : WebSocketListener { + val opened = CountDownLatch(1) + val closed = CountDownLatch(1) + val closedCount = AtomicInteger(0) + val closedCode = AtomicInteger(-1) + val failure = AtomicReference(null) + + override fun onOpen( + pingMillis: Int, + compression: Boolean, + ) = opened.countDown() + + override suspend fun onMessage(text: String) {} + + override fun onClosed( + code: Int, + reason: String, + ) { + closedCode.set(code) + closedCount.incrementAndGet() + closed.countDown() + } + + override fun onFailure( + t: Throwable, + code: Int?, + response: String?, + ) { + failure.set(t) + } + } + + @Test + fun `a relay initiated close is answered and reported as closed`() { + TinyRelay().use { relay -> + val recorder = Recorder() + val client = OkHttpClient() + val socket = OkHttpWebSocket(relay.url, { client }, recorder) + + socket.connect() + assertTrue("relay never saw the client", relay.awaitClient()) + assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS)) + + relay.sendClose() + + assertTrue("client never answered the relay's CLOSE frame", relay.clientCloseFrame.await(5, TimeUnit.SECONDS)) + assertEquals(1000, relay.clientCloseCode.get()) + + assertTrue("onClosed never fired", recorder.closed.await(5, TimeUnit.SECONDS)) + assertEquals("the relay's status code is what gets reported", 1000, recorder.closedCode.get()) + assertNull("a clean handshake is not a failure", recorder.failure.get()) + + // The session already ended; the usual teardown afterwards must not report it twice. + socket.disconnect() + assertEquals("one terminal report per session", 1, recorder.closedCount.get()) + assertTrue("a closed socket needs a fresh dial", socket.needsReconnect()) + + client.dispatcher.executorService.shutdown() + } + } + + @Test + fun `disconnect reports the session end once, synchronously, and drops what OkHttp says afterwards`() { + TinyRelay().use { relay -> + val recorder = Recorder() + val client = OkHttpClient() + val socket = OkHttpWebSocket(relay.url, { client }, recorder) + + socket.connect() + assertTrue("relay never saw the client", relay.awaitClient()) + assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS)) + + socket.disconnect() + + // Reported before disconnect() returned: the relay client dials the replacement + // right after this call and must not hear from the old socket later. + assertEquals("disconnect() must report synchronously", 1, recorder.closedCount.get()) + assertEquals(1000, recorder.closedCode.get()) + assertTrue(socket.needsReconnect()) + + // OkHttp's own reaction to cancel() -- a failure on its reader thread -- and the relay's + // reaction to the dropped TCP session must both be swallowed. + Thread.sleep(500) + assertEquals("no second report", 1, recorder.closedCount.get()) + assertNull("the cancel's failure must not surface", recorder.failure.get()) + + client.dispatcher.executorService.shutdown() + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt index f022d7192f..12586ddf8a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt @@ -153,6 +153,7 @@ class RelayAuthPromptBusTest { * answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that * long despite the user having answered. Marking it shown is what makes the answer land now. */ + @OptIn(ExperimentalCoroutinesApi::class) @Test fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt index 369f853b52..bbb2ea53a8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -185,6 +186,7 @@ class RelayAuthSessionGrantsTest { } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() = runTest { @@ -205,6 +207,7 @@ class RelayAuthSessionGrantsTest { assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun neverAllowStopsAuthenticatingBeforeItsWriteLands() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt index 4921786a2d..05b567cabe 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.commons.relayClient.account.nip01Notifications.NotificationsPerKeyKinds2 import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.RepliesAndReactionsKinds2 +import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.RootScopedRepliesKinds import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent @@ -32,6 +33,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -68,6 +70,7 @@ class Nip34NotificationCoverageTest { * A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles * modern comments through its own separate wiring. */ + @Suppress("DEPRECATION") private val nip34ParticipantKinds = setOf( GitPatchEvent.KIND, @@ -116,25 +119,31 @@ class Nip34NotificationCoverageTest { /** * A status/update event's discovery path when a repo or PR is on screen: the - * [`e`=][RepliesAndReactionsKinds2] engagement subscription. Without - * this the closed/merged pill on a repo listing can never populate — the - * status event's only other route to the device is the `#p`=me subscription, - * which only fires for accounts that were pre-tagged as participants. - * Patches/issues/PRs are self-anchored (they ARE the target, not events - * about the target), so they are intentionally NOT expected here. + * engagement subscription. Without this the closed/merged pill on a repo + * listing can never populate — the status event's only other route to the + * device is the `#p`=me subscription, which only fires for accounts that were + * pre-tagged as participants. Patches/issues/PRs are self-anchored (they ARE + * the target, not events about the target), so they are intentionally NOT + * expected here. + * + * Which of the two engagement filters carries a kind depends on how that kind + * anchors itself, so the two halves are asserted separately below: statuses + * and replies use a `root`-marked lowercase `e`, PR revisions use NIP-22's + * uppercase `E`. Asserting the wrong half passes the kind list while matching + * nothing on the wire. */ + @Suppress("DEPRECATION") @Test - fun `status and PR-update kinds are pulled by the engagement subscription`() { - val threadedActivityKinds = + fun `status kinds are pulled by the lowercase-e engagement subscription`() { + val eAnchoredActivityKinds = setOf( - GitPullRequestUpdateEvent.KIND, GitReplyEvent.KIND, GitStatusOpenEvent.KIND, GitStatusAppliedEvent.KIND, GitStatusClosedEvent.KIND, GitStatusDraftEvent.KIND, ) - val missing = threadedActivityKinds - RepliesAndReactionsKinds2.toSet() + val missing = eAnchoredActivityKinds - RepliesAndReactionsKinds2.toSet() assertTrue( "Kinds $missing are missing from RepliesAndReactionsKinds2. When a repo/PR row is " + "on screen the app fetches replies + reactions targeting the visible events — " + @@ -144,4 +153,26 @@ class Nip34NotificationCoverageTest { missing.isEmpty(), ) } + + /** + * Kind 1619 has no lowercase `e` tag at all — NIP-34's PR Update example carries + * only `["E", ]` — so it can only ever arrive through the + * uppercase root-scope filter. It sat in [RepliesAndReactionsKinds2] for a while + * and matched nothing there. + */ + @Test + fun `PR-update kind is pulled by the uppercase-E engagement subscription`() { + assertTrue( + "GitPullRequestUpdateEvent (1619) is missing from RootScopedRepliesKinds. It anchors " + + "at the PR it revises with NIP-22's uppercase `E` and carries no lowercase `e`, " + + "so the `#e` engagement filter can never surface a PR's revision chain.", + GitPullRequestUpdateEvent.KIND in RootScopedRepliesKinds, + ) + assertFalse( + "GitPullRequestUpdateEvent (1619) is back in RepliesAndReactionsKinds2, the `#e` " + + "filter's kind list. 1619 has no lowercase `e` tag, so the entry matches nothing " + + "and only makes the filter look like it covers PR updates.", + GitPullRequestUpdateEvent.KIND in RepliesAndReactionsKinds2, + ) + } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt new file mode 100644 index 0000000000..f729de7e9e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync + +import com.vitorpamplona.geode.InProcessRelays +import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Drives [EventSync] end to end against geode's in-process relays: one + * "source" relay that already holds the account's history and three empty + * destination relays (outbox / inbox / DM). No network, no public relay — + * every relay is a [RelayEngine] inside this JVM, so the assertions are on + * what actually landed in each destination store, not on "it didn't crash". + * + * The second scenario gates the source relay behind NIP-42 ([FullAuthPolicy]) + * to cover the [RelayAuthenticator] wiring the sync screen relies on when a + * user's relay demands AUTH before serving REQs. + */ +class EventSyncTest : RelayClientTest() { + private val account = NostrSignerSync(KeyPair()) + private val other = NostrSignerSync(KeyPair()) + + private val source: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://source.relay/") + private val outbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://outbox.relay/") + private val inbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://inbox.relay/") + private val dm: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://dm.relay/") + + /** Separate hub so only the source relay demands AUTH; destinations stay open. */ + private val authHub = InProcessRelays(defaultPolicy = { FullAuthPolicy(source) }) + + @After + fun tearDownAuthHub() { + authHub.close() + } + + private fun note( + author: NostrSignerSync, + content: String, + tagged: HexKey? = null, + ): Event = author.sign(eventTemplate(1, content) { tagged?.let { pTag(it) } }) + + private fun legacyDm( + author: NostrSignerSync, + recipient: HexKey, + ): Event = author.sign(eventTemplate(4, "ciphertext") { pTag(recipient) }) + + private val mine = List(3) { note(account, "mine $it") } + private val mentions = List(2) { note(other, "hey $it", tagged = account.pubKey) } + private val dmToMe = legacyDm(other, account.pubKey) + private val noise = note(other, "unrelated") + + private fun corpus(): List = mine + mentions + dmToMe + noise + + /** [decorate] runs on every client the sync builds, e.g. to attach an authenticator. */ + private fun eventSync( + builder: WebsocketBuilder, + decorate: (NostrClient) -> Unit = {}, + ): EventSync = + EventSync( + accountPubKey = account.pubKey, + relayDb = { listOf(source) }, + outboxTargets = { setOf(outbox) }, + inboxTargets = { setOf(inbox) }, + dmTargets = { setOf(dm) }, + clientBuilder = { NostrClient(builder, scope).also(decorate) }, + scope = scope, + ) + + /** + * Publishes are fire-and-forget on the client side, so the destination + * store can lag `runSync` returning by a few ticks. Poll instead of + * asserting a snapshot. + */ + private suspend fun RelayEngine.awaitCount( + filter: Filter, + expected: Int, + ): Int = + withTimeoutOrNull(10_000) { + while (store.count(filter) < expected) delay(25) + store.count(filter) + } ?: store.count(filter) + + private suspend fun assertRouted(hubOfTargets: InProcessRelays) { + val outboxRelay = hubOfTargets.getOrCreate(outbox) + val inboxRelay = hubOfTargets.getOrCreate(inbox) + val dmRelay = hubOfTargets.getOrCreate(dm) + + assertEquals( + "every event authored by the account lands on the outbox relay", + mine.size, + outboxRelay.awaitCount(Filter(authors = listOf(account.pubKey)), mine.size), + ) + assertEquals( + "non-DM mentions land on the inbox relay", + mentions.size, + inboxRelay.awaitCount(Filter(tags = mapOf("p" to listOf(account.pubKey))), mentions.size), + ) + assertEquals( + "the kind-4 DM lands on the DM relay", + 1, + dmRelay.awaitCount(Filter(kinds = listOf(4)), 1), + ) + + // Routing is exclusive per rule: nothing leaks across destinations and the + // unrelated note never leaves the source. + assertEquals("outbox holds only the account's events", mine.size, outboxRelay.store.count(Filter())) + assertEquals("inbox holds only the mentions", mentions.size, inboxRelay.store.count(Filter())) + assertEquals("dm relay holds only the DM", 1, dmRelay.store.count(Filter())) + assertEquals("noise stays on the source", 0, outboxRelay.store.count(Filter(ids = listOf(noise.id)))) + } + + @Test + fun syncRoutesEventsFromSourceToOutboxInboxAndDmRelays() = + runBlocking { + hub.getOrCreate(source).preload(corpus()) + + val sync = eventSync(hub) + withTimeout(30_000) { sync.runSync() } + + val done = sync.syncState.value + assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done) + done as EventSync.SyncState.Done + assertEquals( + "mine + mentions + dm match a routing rule; noise does not", + mine.size + mentions.size + 1, + done.totalEventsReceived, + ) + // runSync drains the outbox before closing its client, so by the time + // Done is published every forwarded event has been written to its + // destination socket — not merely queued. + assertEquals( + "every routed event was sent before the client closed", + mine.size + mentions.size + 1, + done.totalEventsSent, + ) + + assertRouted(hub) + } + + @Test + fun syncReadsFromAuthRequiredSourceOnceAuthenticated() = + runBlocking { + authHub.getOrCreate(source).preload(corpus()) + + // Source demands NIP-42 before serving REQs; destinations are the open hub. + val router = + object : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = if (url == source) authHub.build(url, out) else hub.build(url, out) + } + + val authSigner = NostrSignerSync(KeyPair()) + var authenticator: RelayAuthenticator? = null + val sync = + eventSync(router) { client -> + authenticator = + RelayAuthenticator(client = client, scope = scope) { _, template, _ -> + listOf(authSigner.sign(template)) + } + } + + try { + withTimeout(30_000) { sync.runSync() } + } finally { + authenticator?.destroy() + } + + val done = sync.syncState.value + assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done) + assertEquals( + "the auth-gated source still yields every routed event", + mine.size + mentions.size + 1, + (done as EventSync.SyncState.Done).totalEventsReceived, + ) + + assertRouted(hub) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt index 1a5dc619c8..e6e0029e17 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt @@ -53,4 +53,20 @@ class SupportedContentTest { assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", "application/docx")) // Unsupported extension/mime assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", null)) // Unsupported extension/mime } + + @Test + fun extensionsAreMatchedOnlyAfterADot() { + // The undotted spelling the production filters actually pass (RichTextParser.videoExt). + val contentSupport = SupportedContent(emptyList(), emptySet(), setOf("mp4", "mp3", "jpg")) + + assertTrue(contentSupport.acceptableUrl("https://example.com/clip.mp4", null)) + assertTrue(contentSupport.acceptableUrl("https://example.com/clip.MP4?sig=a", null)) + + // nostr.build's HTML player page: `_mp3`, not `.mp3`. It used to be admitted into the + // video feed by a bare endsWith. + assertTrue(!contentSupport.acceptableUrl("https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3?t=Aria", null)) + assertTrue(!contentSupport.acceptableUrl("https://e.nostr.build/v_ETvKzX2OdOGmFEp1avRlm5_mp4?t=Clip", null)) + assertTrue(!contentSupport.acceptableUrl("https://example.com/my-thoughts-on-mp3", null)) + assertTrue(!contentSupport.acceptableUrl("https://example.com/mp4", null)) + } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt index 81c39d94be..6e34dfe90e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt @@ -116,4 +116,29 @@ class TransactionRowLabelsTest { // The comment merely repeats the description, so it is not shown twice. assertEquals("Test", labels.subtitle) } + + /** A note zap's `e` tag reaches the row, so it can open the zapped note. */ + @Test + fun aNoteZapCarriesTheNoteId() { + val noteHex = "d".repeat(64) + val labels = + TransactionRowLabels.resolve( + NwcTransaction( + type = "outgoing", + metadata = mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("p", recipientHex)))), + ), + "Sent", + ) + assertEquals(noteHex, labels.zappedNoteId) + } + + @Test + fun aProfileZapCarriesNoNoteId() { + val labels = + TransactionRowLabels.resolve( + NwcTransaction(type = "outgoing", metadata = mapOf("nostr" to mapOf("tags" to listOf(listOf("p", recipientHex))))), + "Sent", + ) + assertNull(labels.zappedNoteId) + } } diff --git a/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so b/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so index ef6d500311..18f10a595b 100755 Binary files a/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so and b/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so differ diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index a9a0622133..8f9ee7bd68 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { androidTestImplementation(libs.androidx.benchmark.junit4) androidTestImplementation(project(":quartz")) androidTestImplementation(project(":commons")) + androidTestImplementation(project(":commonsUI")) // Custom C secp256k1 (libschnorr256k1) for the 3-way Android benchmark androidTestImplementation(libs.schnorr256k1.kmp) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index 3b11aa84df..ee091ac91f 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -136,7 +136,7 @@ class HexBenchmark { /** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */ @Test fun hexIsHex64ThenDecode() { - r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null } + r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) } } @Test diff --git a/build.gradle.kts b/build.gradle.kts index 6b87fbd049..d3ea321ee8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,18 +9,18 @@ import java.util.Properties // compiles this buildscript {} section in an earlier stage that can't see the // file's imports (hence the qualified Properties) or share code with the body, // but it can publish values — the gate is computed once here and read below -// via `by extra`. +// via the project's extra properties. buildscript { val localProperties = File(rootDir, "local.properties") - val sonarProperties by extra( + val sonarProperties = java.util.Properties().apply { if (localProperties.exists()) localProperties.inputStream().use { load(it) } - }, - ) - val sonarEnabled by extra( + } + extra.set("sonarProperties", sonarProperties) + val sonarEnabled = sonarProperties.getProperty("sonar.host.url") != null && - gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }, - ) + gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") } + extra.set("sonarEnabled", sonarEnabled) if (sonarEnabled) { repositories { gradlePluginPortal() @@ -110,9 +110,9 @@ subprojects { // `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the // command line. sonar.projectKey/projectName default to the root project name // ("Amethyst") and only need overriding in local.properties if desired. -val sonarEnabled: Boolean by extra +val sonarEnabled = extra["sonarEnabled"] as Boolean if (sonarEnabled) { - val sonarProperties: Properties by extra + val sonarProperties = extra["sonarProperties"] as Properties apply(plugin = "org.sonarqube") sonarProperties diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index d343eb4fb8..7e96d3f501 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -348,7 +348,7 @@ Amy-specific layer still needs its own coverage: | Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. | | JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. | | File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. | -| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). | +| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up the embedded `amy serve` relay (geode) and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). | The JVM suite drives `runCli` **in-process** through the shared `amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr, diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index ede7f2c648..74645af784 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -180,11 +180,11 @@ move anything, re-audit — you're probably duplicating logic. 9. **Test suite** — largely in place, two layers: - **Shell harnesses** under `cli/tests/` — ten suites: `blossom` (live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`), - `marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`, + `marmot` (vs MDK), `nests` (manual audio-rooms matrix), `pow`, `relaygroup`, `sync`, plus the shared `headless/` helpers. See - `cli/tests/README.md`. - None run in CI yet (the relay-backed ones need Rust + a ~3 min - cold `nostr-rs-relay` build). + `cli/tests/README.md`. Every relay-backed suite runs against the + embedded `amy serve` relay (geode) — no external relay binary. + None run in CI yet (the Marmot ones need Rust for MDK's `wn`). - **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing, exit-code contract, and `--json` shape tests driving `runCli` in-process via the `amy.home` isolation seam. diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index cd33bc9080..52ed74887e 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -110,7 +110,7 @@ application { // JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy // then signs a kind:7 whose `content` is four replacement characters. // Whitenoise rejects it with "Invalid reaction content". -val patchAmyLauncherCharset by tasks.registering { +val patchAmyLauncherCharset = tasks.register("patchAmyLauncherCharset") { val appName = application.applicationName val startScriptsTask = tasks.named("startScripts") dependsOn(startScriptsTask) diff --git a/cli/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb index 458196aefb..a585805cf8 100644 --- a/cli/packaging/homebrew/amy.rb +++ b/cli/packaging/homebrew/amy.rb @@ -38,8 +38,8 @@ class Amy < Formula desc "Nostr client from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.15.1/amy-1.15.1-jvm.tar.gz" - sha256 "1d23620575397a56ca47f052f5551b737e6da084bc62277aed32ec29f86a63fe" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.15.2/amy-1.15.2-jvm.tar.gz" + sha256 "db354a7e9994f0f15b9a9c55596fec0e3d3bc4294a81455d6f35ad5ccefb4191" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt index e3f97c2c67..d12c2206e2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt @@ -221,7 +221,9 @@ object BuzzAgentCommands { BuzzAgentCommands::class.java.getResourceAsStream("/buzz-agent/$name") ?: error("bundled wrapper /buzz-agent/$name missing from the amy jar") ).use { input -> out.outputStream().use { input.copyTo(it) } } - out.setExecutable(true) + // The runner launches the wrapper via `sh -c `, so a missing exec bit would only + // surface later as an opaque "permission denied" — fail here with the path instead. + check(out.setExecutable(true)) { "could not mark wrapper ${out.absolutePath} executable" } return out.absolutePath } return extract("workflow-agent.sh") to extract("workflow-ship.sh") diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 363484739c..53f48067cc 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -1,6 +1,7 @@ marmot/state/ marmot/state-headless/ dm/state-dm-headless/ +cache/state-cache-headless/ nests/state/ clink/state-clink-headless/ relaygroup/state-relaygroup-headless/ diff --git a/cli/tests/README.md b/cli/tests/README.md index cf2b314a64..caa8f88122 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -1,21 +1,24 @@ # amy CLI test harnesses -Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a -loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers, -or no relay at all, depending on the suite. Eleven directories: +Shell-based end-to-end harnesses that drive the `amy` CLI binary — against an +embedded relay (`amy serve`, i.e. **geode**, the relay this repo ships), live +public servers, or no relay at all, depending on the suite. No suite depends on +an external relay binary or a Rust toolchain for its relay: every relay-backed +harness boots geode from the `amy` binary it already built, so the relay under +test is the same server code that runs in production. Eleven directories: ``` cli/tests/ ├── lib.sh # shared logging, results, assertions ├── headless/ # shared bits used by every harness -│ └── helpers.sh +│ └── helpers.sh # amy wrappers, assertions, embedded relay boot ├── blossom/ # Blossom blob lifecycle vs LIVE public servers │ └── blossom-live.sh ├── cache/ # local-store-as-cache semantics (profile show -│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay +│ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve` ├── clink/ # CLINK pointer decode — local-only, no relay │ └── clink-headless.sh -├── dm/ # NIP-17 DM interop (amy ↔ amy) +├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve` │ ├── dm-interop-headless.sh │ ├── setup.sh # preflight + identities │ └── tests-dm.sh @@ -24,7 +27,7 @@ cli/tests/ ├── marmot/ # Marmot / MLS group-messaging interop │ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI │ ├── marmot-interop-headless.sh # zero-prompt -│ ├── setup.sh # preflight + wn + relay + identities +│ ├── setup.sh # preflight + wn + identities │ ├── tests-create.sh # tests 01–05 │ ├── tests-manage.sh # tests 06–08, 11 │ ├── tests-extras.sh # tests 09, 10, 12, 13 @@ -60,7 +63,7 @@ Suite notes: and mined-nonce round-trips through `pow check`. - **`cache/cache-headless.sh`** proves the local store is the source of truth for reads: `profile show` served from cache vs `--refresh`, and - `store stat` reporting the right histogram, vs a loopback nostr-rs-relay. + `store stat` reporting the right histogram, vs the embedded `amy serve` relay. - **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/ list/browse against an embedded relay (`amy serve`, which boots geode) — no external relay binary. geode doesn't sign 39000-39003, so browse/info @@ -124,10 +127,8 @@ The Marmot harnesses come in two flavours, same scenarios: A third, slimmer harness covers the NIP-17 DM surface: - **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and - Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay. - No MDK required — only `amy` and the relay binary (which - is shared with the Marmot harness's checkout at - `marmot/state-headless/nostr-rs-relay/`). + Identity D) exchange NIP-17 DMs through the embedded `amy serve` relay. + No MDK, no Rust — only `amy`. A harness covers Blossom blob storage (BUD-01/02/04/09) against **live** public servers rather than a loopback relay: @@ -212,15 +213,18 @@ at `desktopApp/src/jvmTest/kotlin/.../service/upload/`. On the machine that runs the harness: -- **Rust 1.90+** — install via https://rustup.rs +- **Rust 1.90+** — install via https://rustup.rs (for MDK's `wn`/`wnd` only; + the relay is `amy serve`, no Rust needed for it) - **git**, **curl**, **jq** — package manager - **~5 GB disk** for the first-run build of `wn` + `wnd` -- Public internet access (for the default relay set and fetching crates) +- Internet access for fetching crates on the first build. Test traffic + stays on the machine unless you pass `--public-relays`. On the Android side: - Amethyst installed on an **emulator** or a **physical device** -- The device must reach the same relays the harness uses (see below) +- The device must reach the harness's embedded relay over the network + (see below), or the public relays when running with `--public-relays` ## Quick start @@ -240,9 +244,11 @@ The script will, in order: 4. Create Nostr identities for B and C, persist their npubs in `state/run.env`. 5. Ask you to paste **your Amethyst account npub** (Identity A). This is cached for subsequent runs. -6. Add the default public relays to both daemons and run a sanity check - (publish a KP from B, fetch it from C). -7. Print an **Amethyst setup checklist** — add the same relays to Amethyst, +6. Boot the embedded relay (`amy serve`, i.e. geode, on `0.0.0.0:8080`), + add it to both daemons and run a sanity check (publish a KP from B, + fetch it from C). With `--public-relays` the default public set is used + instead and the relay is not started. +7. Print an **Amethyst setup checklist** — add the same relay to Amethyst, publish a KP, verify you are logged in with A. 8. Run all 13 tests sequentially. Each test either: - runs `wn` commands fully automatically and asserts on JSON output, **or** @@ -253,9 +259,10 @@ The script will, in order: ## Command-line flags ``` ---local-relays Use ws://localhost:8080 instead of the default public relays. - Required if the public relays reject kinds 444/445/30443. - Run 'just docker-up' inside the mdk checkout first. +--public-relays Use the public relay set below instead of the embedded relay. + The only mode whose test traffic leaves the machine; the + public relays may reject kinds 444/445/30443. +--port N Port for the embedded relay (default 8080). --transponder Run Test 14 (push notifications via the transponder service). --no-build Fail instead of rebuilding wn/wnd. Useful when iterating. -h, --help Show help. @@ -267,31 +274,31 @@ Environment overrides: WN_REPO=/some/path/mdk # use an existing checkout ``` -## Default relays +## Relays + +By default the harness owns the only relay: `amy serve` (geode) bound to +`0.0.0.0:8080`. The `wn` daemons reach it on loopback; Amethyst reaches it +over the network: + +- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays, + Settings → Key Package Relays and Settings → DM Inbox Relays. +- **Physical device on same Wi-Fi:** add `ws://:8080`. + +With `--public-relays` the daemons are bootstrapped on ``` wss://relay.damus.io wss://nos.lol wss://relay.primal.net +wss://nostr.bitcoiner.social +wss://nostr.mom ``` -These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable -events). If the **sanity check fails** — meaning C cannot read the KeyPackage -that B just published — the harness warns you and continues. In that case -re-run with `--local-relays` after starting the Docker stack: - -```bash -cd state/mdk -just docker-up -cd ../.. -./marmot-interop.sh --local-relays -``` - -For Amethyst with `--local-relays`: - -- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and - Settings → Key Package Relays. -- **Physical device on same Wi-Fi:** add `ws://:8080`. +instead and Amethyst is left on its own relay set, so the run surfaces +real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the +public relays drop). If the **sanity check fails** in that mode — meaning C +cannot read the KeyPackage that B just published — the harness warns you and +continues; re-run without `--public-relays` to rule the relays out. ## How human interaction works diff --git a/cli/tests/cache/cache-headless.sh b/cli/tests/cache/cache-headless.sh index b1944678ce..454c457a42 100755 --- a/cli/tests/cache/cache-headless.sh +++ b/cli/tests/cache/cache-headless.sh @@ -3,8 +3,8 @@ # cache-headless.sh — verifies the file-backed event store is the # source of truth for `amy` reads. # -# Two amy identities (A and B) talk to a local nostr-rs-relay. We -# assert that: +# Two amy identities (A and B) talk to a local embedded relay +# (`amy serve`, i.e. geode). We assert that: # # 1. After A runs `amy create`, A's local store contains the bootstrap # events (kind:0 / 3 / 10002 / 10050 / 10051 …). @@ -39,10 +39,13 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -# Reuse the relay binary the marmot harness builds. +# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by +# start_local_relay in headless/helpers.sh. 127.0.0.2 only for parity +# with the DM and Marmot harnesses: Quartz's isLocalHost() now covers all +# of 127.0.0.0/8, so it is stripped from parsed relay lists exactly like +# 127.0.0.1. Nothing here depends on that parse — amy publishes to and +# reads from the relay it was told about. RELAY_HOST="${RELAY_HOST:-127.0.0.2}" -RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}" -RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" RELAY_DATA="$STATE_DIR/relay" RELAY_PORT="${RELAY_PORT:-8092}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" @@ -72,14 +75,11 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" -# shellcheck source=../marmot/setup.sh — provides start_local_relay / stop_local_relay -source "$TESTS_DIR/marmot/setup.sh" -# shellcheck source=../headless/helpers.sh +# shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay source "$TESTS_DIR/headless/helpers.sh" -# Keep the dm setup's preflight (just checks for amy + the relay) but -# define our own identity bootstrap so we don't pull in DM-specific -# wiring. +# Keep the dm setup's preflight (just checks for amy) but define our own +# identity bootstrap so we don't pull in DM-specific wiring. # shellcheck source=../dm/setup.sh source "$TESTS_DIR/dm/setup.sh" diff --git a/cli/tests/dm/dm-interop-headless.sh b/cli/tests/dm/dm-interop-headless.sh index 687b9349cc..5d3ed45ab6 100755 --- a/cli/tests/dm/dm-interop-headless.sh +++ b/cli/tests/dm/dm-interop-headless.sh @@ -3,8 +3,9 @@ # dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness. # # Two `amy` processes (Identity A and Identity D) talk to each other -# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No -# whitenoise-rs, no Marmot, no public internet traffic. +# through a local embedded relay (`amy serve`, i.e. geode) on +# ws://127.0.0.2:$RELAY_PORT. No MDK, no Marmot, no Rust toolchain, no +# public internet traffic. # # Usage: ./dm-interop-headless.sh [--port N] [--no-build] # @@ -26,16 +27,16 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -# Share the nostr-rs-relay checkout with the Marmot harness to avoid -# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full -# isolation between runs. -# Bind the loopback relay to 127.0.0.2 rather than 127.0.0.1 so Quartz's -# `isLocalHost()` filter doesn't silently strip it out of the kind:10050 -# inbox events during recipient-relay resolution. 127.0.0.2 is still pure -# loopback — no network traffic, no config needed. +# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by +# start_local_relay in headless/helpers.sh. Override RELAY_DATA if you +# want full isolation between runs. +# 127.0.0.2 used to dodge Quartz's `isLocalHost()` strip of loopback +# relays in kind:10050 inbox lists. That filter now covers all of +# 127.0.0.0/8, so the strict-inbox sends (dm-01/02/05/06) fail with +# no_dm_relays regardless of which loopback address the relay binds; +# only the fallback-chain tests (dm-03/04) are unaffected. Kept for +# parity with the other harnesses until that routing rule is revisited. RELAY_HOST="${RELAY_HOST:-127.0.0.2}" -RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}" -RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" RELAY_DATA="$STATE_DIR/relay" RELAY_PORT="${RELAY_PORT:-8090}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" @@ -68,12 +69,9 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" -# Reuse start_local_relay / stop_local_relay from the Marmot harness's -# setup.sh — the relay lifecycle is identical. preflight() there also -# builds whitenoise-rs, which we don't need; setup.sh in this dir -# defines a slimmer preflight_dm(). -# shellcheck source=../marmot/setup.sh -source "$TESTS_DIR/marmot/setup.sh" +# setup.sh in this dir defines the slim preflight_dm() (amy only, no +# MDK); the relay lifecycle (start_local_relay / stop_local_relay, the +# embedded `amy serve`) comes from the shared headless helpers. # shellcheck source=setup.sh source "$SCRIPT_DIR/setup.sh" # shellcheck source=../headless/helpers.sh diff --git a/cli/tests/dm/setup.sh b/cli/tests/dm/setup.sh index 5af3b5605d..5fdd65fba5 100644 --- a/cli/tests/dm/setup.sh +++ b/cli/tests/dm/setup.sh @@ -3,20 +3,21 @@ # setup.sh — amy-only preflight + identity bootstrap for the # NIP-17 DM interop harness. Much slimmer than the Marmot setup: # -# - Builds `amy` (same retry-on-503 logic as setup.sh). -# - Builds nostr-rs-relay if missing. +# - Builds `amy` (same retry-on-503 logic as setup.sh). The loopback +# relay is `amy serve` (geode), so amy is the only binary needed. # - Bootstraps two fresh amy identities (A and D), each with its own # `--data-dir`, both pointed at the loopback relay. # - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict # recipient-inbox routing has something to resolve to. # -# The heavy `start_local_relay` / `stop_local_relay` helpers live in -# the Marmot harness's setup.sh and are sourced by the top-level harness. +# The `start_local_relay` / `stop_local_relay` helpers (embedded +# `amy serve`) live in ../headless/helpers.sh and are sourced by the +# top-level harness. -# --- preflight (amy + relay only, no wn / Marmot patches) ------------------- +# --- preflight (amy only, no wn / Marmot patches, no Rust) ------------------- preflight_dm() { banner "Preflight (DM harness)" - for cmd in jq git cargo; do + for cmd in jq git curl; do if ! command -v "$cmd" >/dev/null 2>&1; then fail_msg "missing required tool: $cmd" exit 1 @@ -43,29 +44,6 @@ preflight_dm() { [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } info "amy: $AMY_BIN" - # nostr-rs-relay (same build path as the Marmot harness). - if [[ ! -x "$RELAY_BIN" ]]; then - if [[ "$NO_BUILD" -eq 1 ]]; then - fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1 - fi - if [[ ! -d "$RELAY_REPO/.git" ]]; then - step "cloning nostr-rs-relay into $RELAY_REPO" - git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ - 2>&1 | tee -a "$LOG_FILE" - fi - local attempt max=4 - for attempt in $(seq 1 $max); do - step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)" - ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ - 2>&1 | tee -a "$LOG_FILE" - [[ -x "$RELAY_BIN" ]] && break - [[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying" - done - [[ -x "$RELAY_BIN" ]] || { - fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1 - } - fi - info "relay bin: $RELAY_BIN" } # --- amy identity wrappers --------------------------------------------------- diff --git a/cli/tests/dm/tests-dm.sh b/cli/tests/dm/tests-dm.sh index 4fff53fbe8..fe0f2bb393 100644 --- a/cli/tests/dm/tests-dm.sh +++ b/cli/tests/dm/tests-dm.sh @@ -3,7 +3,7 @@ # tests-dm.sh — NIP-17 DM interop tests for two `amy` clients. # # Identity A (sender) and Identity D (recipient) each live in their own -# --data-dir and share one loopback nostr-rs-relay. Tests cover: +# --data-dir and share one loopback embedded relay (`amy serve` / geode). Tests cover: # # dm-01 text round-trip (both directions) # dm-02 dm list surfaces prior exchange with type:text discriminator diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index e01069bb3f..ea45199223 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -66,5 +66,94 @@ assert_eq() { return 1 } +# --- embedded relay (amy serve → geode) -------------------------------------- +# Every relay-backed harness talks to ONE loopback relay, and that relay is +# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy +# binary the harness already built. No Rust toolchain, no clone, no cargo +# build, no external relay binary: the relay under test is part of the +# product, so a harness run exercises the same server code `amy serve` +# and the standalone geode distribution run in production. +# +# Callers set (before sourcing or at least before calling): +# AMY_BIN amy launcher (built via `./gradlew :cli:installDist`) +# RELAY_HOST host clients connect to. The harnesses use 127.0.0.2 for +# parity with each other; note that Quartz's isLocalHost() +# treats all of 127.0.0.0/8 as loopback, so it does NOT +# survive the NIP-17 / NIP-65 relay-list parsers any better +# than 127.0.0.1 does. +# RELAY_BIND optional bind address; defaults to $RELAY_HOST. Set to +# 0.0.0.0 when a device on the LAN must reach the relay. +# RELAY_PORT listen port +# RELAY_URL ws://$RELAY_HOST:$RELAY_PORT +# RELAY_DATA scratch dir for the relay's own $HOME, pid file and logs +# +# The relay process runs as its own amy account ("relay") inside its own +# $HOME under $RELAY_DATA, so its identity and store never mix with the +# test identities. The store is in-memory (amy serve's default): every run +# starts from an empty relay, matching the state wipe the harnesses do. +start_local_relay() { + banner "Starting embedded relay (amy serve / geode) on $RELAY_URL" + local relay_home="$RELAY_DATA/home" + local bind="${RELAY_BIND:-$RELAY_HOST}" + mkdir -p "$relay_home" "$RELAY_DATA/logs" + + [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; } + + # Abort early if something else is already bound to the port — failing + # with a clear error beats a mysterious-looking daemon stall later. + # bash's /dev/tcp probe needs no `ss`/`lsof`; a refused connect on + # loopback returns immediately. + if (exec 3<>"/dev/tcp/$RELAY_HOST/$RELAY_PORT") 2>/dev/null; then + fail_msg "port $RELAY_PORT already in use on $RELAY_HOST — pass --port N or free it" + exit 1 + fi + + # `amy serve` resolves its admin pubkey from the account, so the relay + # needs an identity of its own. Idempotent across --reuse-state runs. + if [[ ! -d "$relay_home/.amy/relay" ]]; then + HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext --json init \ + >"$RELAY_DATA/logs/init.log" 2>&1 \ + || { fail_msg "amy init failed for the relay account (see $RELAY_DATA/logs/init.log)"; exit 1; } + fi + + nohup env HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext \ + serve --host "$bind" --port "$RELAY_PORT" \ + >"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" & + echo "$!" > "$RELAY_DATA/pid" + step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …" + + # Readiness = the NIP-11 document answers on the same port. geode serves + # it on a plain GET with `Accept: application/nostr+json` (anything else + # gets a 426 hint, which curl -f would treat as failure). + local deadline=$(( $(date +%s) + 60 )) + while [[ $(date +%s) -lt $deadline ]]; do + if curl -sSf -m 1 -H 'Accept: application/nostr+json' \ + "http://$RELAY_HOST:$RELAY_PORT/" >/dev/null 2>&1; then + info "relay up" + return 0 + fi + if ! kill -0 "$(cat "$RELAY_DATA/pid")" 2>/dev/null; then + break + fi + sleep 0.5 + done + fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)" + tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true + exit 1 +} + +stop_local_relay() { + local pid_file="$RELAY_DATA/pid" + [[ -f "$pid_file" ]] || return 0 + local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "") + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + info "stopping relay pid $pid" + kill "$pid" 2>/dev/null || true + sleep 1 + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" +} + # --- wn-side pollers (delegates to lib.sh) ----------------------------------- # Both exist in lib.sh already; this file only adds headless-specific niceties. diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 5aa8a3786d..e1d7e8d2e8 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -3,9 +3,9 @@ # marmot-interop-headless.sh — zero-prompt, zero-internet interop harness. # # Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and -# Identities B/C via MDK's `wn`/`wnd`. Spins up a local -# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the -# machine. Matches the 13 test scenarios in marmot-interop.sh but without +# Identities B/C via MDK's `wn`/`wnd`. Spins up a local embedded relay +# (`amy serve`, i.e. geode) on ws://127.0.0.2:$RELAY_PORT so nothing ever +# leaves the machine. Matches the 13 test scenarios in marmot-interop.sh but without # any human prompts — all checks run to completion and the exit code # reflects pass/fail totals. # @@ -37,9 +37,10 @@ WN_BIN="$WN_REPO/target/release/wn" WND_BIN="$WN_REPO/target/release/wnd" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -# Local relay wiring — cloned + built during preflight, started on -# $RELAY_PORT. The harness never touches the public internet for test -# traffic; wn/wnd/amy all point at this one loopback endpoint. +# Local relay wiring — the embedded `amy serve` (geode), started on +# $RELAY_PORT by start_local_relay (../headless/helpers.sh). The harness +# never touches the public internet for test traffic; wn/wnd/amy all +# point at this one loopback endpoint. # # Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer # strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox @@ -48,8 +49,6 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" # Amethyst's public defaults instead of the loopback. 127.0.0.2 is # still pure loopback (no network traffic) but isn't on the strip list. RELAY_HOST="${RELAY_HOST:-127.0.0.2}" -RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}" -RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" RELAY_DATA="$STATE_DIR/relay" RELAY_PORT="${RELAY_PORT:-8080}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" @@ -73,7 +72,7 @@ BLOSSOM_PID="" NO_BUILD=0 # Every run starts from empty stores. wnd already wipes B's and C's data dirs -# on each start, but A's amy home and the relay's SQLite file used to survive, +# on each start, but A's amy home and the relay's state used to survive, # and the leftovers are not inert: a KeyPackage A published in an earlier run # is still on the relay for B to invite with, an old group's kind:445 events # still arrive and fail to decrypt, and A's cursors still say it has seen them. @@ -119,8 +118,8 @@ while [[ $# -gt 0 ]]; do done if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then - # Keep the relay checkout + its build (minutes to rebuild) and the log and - # results history; drop everything that holds protocol state. + # Keep the log and results history; drop everything that holds protocol + # state (the relay is in-memory, so wiping its dir just drops its identity). # # run.env counts as protocol state: it is where tests hand each other group # ids. Leaving it behind a wipe leaves ids naming groups nobody is in any diff --git a/cli/tests/marmot/marmot-interop.sh b/cli/tests/marmot/marmot-interop.sh index b7b9a44d25..d39481c7c3 100755 --- a/cli/tests/marmot/marmot-interop.sh +++ b/cli/tests/marmot/marmot-interop.sh @@ -5,12 +5,19 @@ # Sequential, all-or-nothing. Script drives the `wn` side automatically and # prompts the human operator at each step that requires Amethyst UI action. # -# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build] +# Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build] +# +# By default the harness boots its own relay — `amy serve`, i.e. geode — bound +# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone +# reaches it over the LAN (ws://:PORT, or ws://10.0.2.2:PORT from an +# emulator). Pass --public-relays to run the old real-world path against the +# public relay set instead; that is the only mode that touches the internet. # set -uo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" STATE_DIR="$SCRIPT_DIR/state" LOG_DIR="$STATE_DIR/logs" B_DIR="$STATE_DIR/B" @@ -27,6 +34,24 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" WN_REPO="${WN_REPO:-$STATE_DIR/mdk}" WN_BIN="" WND_BIN="" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +# Embedded relay (default mode). Bound on every interface so a device on the +# same network can reach it; the daemons connect over $RELAY_HOST. Loopback +# `ws://` relays are only accepted by MDK behind this explicit opt-in. +# +# Known limit, inherited from the old --local-relays mode: the URL wn +# advertises in its kind:10050/10051 lists is $RELAY_URL, and Amethyst's +# parsers drop loopback and RFC1918 relays from those lists, so A→B welcome +# delivery leans on Amethyst's fallback relays. Override RELAY_HOST with an +# address the device can dial (e.g. the laptop's LAN IP) to have wn +# advertise that instead; the daemons then connect to it too. +RELAY_HOST="${RELAY_HOST:-127.0.0.1}" +RELAY_BIND="${RELAY_BIND:-0.0.0.0}" +RELAY_PORT="${RELAY_PORT:-8080}" +RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" +RELAY_DATA="$STATE_DIR/relay" +export WN_ALLOW_LOOPBACK_RELAYS=1 B_NPUB="" B_HEX="" C_NPUB="" @@ -34,6 +59,7 @@ C_HEX="" A_NPUB="" A_HEX="" +# Only used with --public-relays. DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" @@ -41,7 +67,7 @@ DEFAULT_RELAYS=( "wss://nostr.bitcoiner.social" "wss://nostr.mom" ) -USE_LOCAL_RELAYS=0 +USE_PUBLIC_RELAYS=0 ENABLE_TRANSPONDER=0 NO_BUILD=0 @@ -50,7 +76,9 @@ usage() { marmot-interop.sh — Amethyst <-> MDK interop harness Options: - --local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up') + --public-relays Use the public relay set instead of the embedded relay + (amy serve / geode, the default). Only mode that leaves the machine. + --port N Port for the embedded relay (default 8080) --transponder Run Test 14 (MIP-05 push notifications) --no-build Don't rebuild wn/wnd if binaries are missing -h, --help Show this help @@ -62,8 +90,12 @@ EOF while [[ $# -gt 0 ]]; do case "$1" in - --local-relays) USE_LOCAL_RELAYS=1 ;; - --transponder) ENABLE_TRANSPONDER=1 ;; + --public-relays) USE_PUBLIC_RELAYS=1 ;; + --local-relays) printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;; + --port) + [[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for --port\n' >&2; usage; exit 2; } + RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; + --transponder) ENABLE_TRANSPONDER=1 ;; --no-build) NO_BUILD=1 ;; -h|--help) usage; exit 0 ;; *) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;; @@ -77,6 +109,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" +# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve) +source "$TESTS_DIR/headless/helpers.sh" # --- preflight --------------------------------------------------------------- preflight() { @@ -88,6 +122,26 @@ preflight() { printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE" done + # The embedded relay is `amy serve`, so amy has to exist unless the run + # goes to the public relays. Same transient-503 retry as the headless + # harness: one bad jitpack/dl.google.com roll must not abort the run. + if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 + fi + local attempt max_attempts=4 + for attempt in $(seq 1 $max_attempts); do + step "building :cli:installDist (attempt $attempt/$max_attempts)" + if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ + && [[ -x "$AMY_BIN" ]]; then + break + fi + [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying" + done + [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } + printf ' amy: %s\n' "$AMY_BIN" >>"$LOG_FILE" + fi + WN_BIN="$WN_REPO/target/release/wn" WND_BIN="$WN_REPO/target/release/wnd" @@ -285,7 +339,7 @@ discover_a_relays() { if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working" else - warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}." + warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}." warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach." warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)." fi @@ -300,7 +354,7 @@ discover_a_relays() { if ! command -v sqlite3 >/dev/null 2>&1; then warn "sqlite3 not installed — skipping wn user_relays cache probe." - warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays." + warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays." return fi @@ -383,12 +437,7 @@ discover_a_relays() { # --- relays ------------------------------------------------------------------ configure_relays() { banner "Configuring relays" - local relays=() - if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then - relays=( "ws://localhost:8080" ) - else - relays=( "${DEFAULT_RELAYS[@]}" ) - fi + local relays=( "${RELAY_LIST[@]}" ) # Each relay × 3 types × 2 daemons produces a lot of repetitive "ok" # lines — the happy path doesn't need any of it on screen. Quiet the # per-add logging into $LOG_FILE and only surface real failures as @@ -527,27 +576,28 @@ configure_relays() { info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)" else warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)" - warn "Consider rerunning with --local-relays." + warn "Consider rerunning without --public-relays (the embedded relay stores every kind)." fi # best-effort cleanup so re-runs don't accumulate dead sanity groups wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true else warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists" - warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)." + warn "Consider rerunning without --public-relays (the embedded relay stores every kind)." fi fi } instruct_amethyst_setup() { - if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then - # Offline/sandbox path: we own the only relay, so the harness DOES - # need to dictate Amethyst's relay config — nothing is discoverable - # via the public network. - prompt_human "Configure Amethyst to match this --local-relays harness: + if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then + # Offline/sandbox path (default): we own the only relay — the embedded + # `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need + # to dictate Amethyst's relay config; nothing is discoverable via the + # public network. + prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode): 1. Settings -> Relays: add as READ+WRITE - ws://10.0.2.2:8080 (Android emulator) - ws://:8080 (physical device on same Wi-Fi) + ws://10.0.2.2:$RELAY_PORT (Android emulator) + ws://:$RELAY_PORT (physical device on same Wi-Fi) 2. Settings -> Key Package Relays: add the SAME URL 3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL 4. Trigger key-package publish (toggle KP relay on/off if needed) @@ -555,7 +605,7 @@ instruct_amethyst_setup() { return fi - # Public-relay path: the harness should behave like any real Nostr + # --public-relays path: the harness should behave like any real Nostr # client — discover A's advertised relays via kind:10002 / 10050 / # 10051 and publish there, rather than forcing A to adopt the # harness's own relay set. That lets the tests surface real-world @@ -1301,6 +1351,7 @@ main() { local rc=$? trap - EXIT INT TERM HUP stop_daemons + stop_local_relay print_summary exit "$rc" } @@ -1311,6 +1362,12 @@ main() { banner "Amethyst <-> MDK interop harness ($RUN_TS)" preflight + if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then + RELAY_LIST=( "${DEFAULT_RELAYS[@]}" ) + else + RELAY_LIST=( "$RELAY_URL" ) + start_local_relay + fi start_daemon B "$B_DIR" "$B_SOCKET" start_daemon C "$C_DIR" "$C_SOCKET" ensure_identity B @@ -1327,7 +1384,7 @@ main() { # plane, then summarise what wn sees. Surfaces up front the kind of # failure (A's 10050 unreachable from wn, missing KP list, etc.) that # would otherwise bite as a silent Test 03 timeout. - if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then + if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then discover_a_relays fi diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 1bf5e32db4..3b1ceabafb 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -147,29 +147,8 @@ preflight() { info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))" info "wnd: $WND_BIN" - # Clone/build nostr-rs-relay — the harness's single loopback relay. - if [[ ! -x "$RELAY_BIN" ]]; then - if [[ "$NO_BUILD" -eq 1 ]]; then - fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1 - fi - if [[ ! -d "$RELAY_REPO/.git" ]]; then - step "cloning nostr-rs-relay into $RELAY_REPO" - git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ - 2>&1 | tee -a "$LOG_FILE" - fi - local attempt max=4 - for attempt in $(seq 1 $max); do - step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)" - ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ - 2>&1 | tee -a "$LOG_FILE" - [[ -x "$RELAY_BIN" ]] && break - [[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed (likely transient 503 from crates.io) — retrying" - done - [[ -x "$RELAY_BIN" ]] || { - fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1 - } - fi - info "relay bin: $RELAY_BIN" + # The loopback relay is `amy serve` (geode) — see start_local_relay in + # ../headless/helpers.sh. Nothing to clone or build beyond amy itself. } # --- local QUIC broker ------------------------------------------------------- @@ -262,75 +241,8 @@ stop_quic_broker() { } # --- local relay ------------------------------------------------------------- -# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test -# runs against this one loopback endpoint — no external network traffic. -start_local_relay() { - banner "Starting local nostr-rs-relay on $RELAY_URL" - mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs" - - # Render a minimal config file each run so port/limits come from the - # harness rather than whatever was left on disk from a previous session. - cat >"$RELAY_DATA/config.toml" </dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then - fail_msg "port $RELAY_PORT already in use — pass --port N or free it" - exit 1 - fi - - nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \ - >"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" & - echo "$!" > "$RELAY_DATA/pid" - step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …" - - local deadline=$(( $(date +%s) + 20 )) - while [[ $(date +%s) -lt $deadline ]]; do - if curl -sSf -m 1 "http://${RELAY_HOST:-127.0.0.1}:$RELAY_PORT/" >/dev/null 2>&1; then - info "relay up" - return 0 - fi - sleep 0.5 - done - fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)" - tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true - exit 1 -} - -stop_local_relay() { - local pid_file="$RELAY_DATA/pid" - [[ -f "$pid_file" ]] || return 0 - local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "") - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - info "stopping relay pid $pid" - kill "$pid" 2>/dev/null || true - sleep 1 - kill -9 "$pid" 2>/dev/null || true - fi - rm -f "$pid_file" -} +# start_local_relay / stop_local_relay live in ../headless/helpers.sh: the +# relay is the embedded `amy serve` (geode), shared by every harness. # --- daemons ----------------------------------------------------------------- start_daemon() { @@ -480,9 +392,9 @@ configure_relays() { step "publishing A's KeyPackage" amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed" - # Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443 + # Give the relay a breath to ingest the kind:10002 / 10050 / 30443 # writes and push them out on the discovery subscription so that the # first `wn keys check` that follows actually sees them instead of - # racing the relay's WAL flush. + # racing the relay's ingest queue. sleep 2 } diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 25c594b532..bda85c11a5 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -4,10 +4,11 @@ | Consumer | Kind | Uses from `commons` | |----------------|------------------------------|----------------------------------------------| -| `amethyst` | Android app (touch-first) | everything (models, state, ViewModels, UI) | -| `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels, UI) | -| `cli` (`amy`) | Headless JVM CLI (no UI) | **non-UI only** — models, actions, relay, services | -| iOS (future) | iOS app | everything; expected to share most UI with Android | +| `amethyst` | Android app (touch-first) | everything (models, state, ViewModels) + `commonsUI` | +| `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels) + `commonsUI` | +| `cli` (`amy`) | Headless JVM CLI (no UI) | everything — `commons` is headless by construction; it never sees `commonsUI` | +| `nappletHost` | Android WebView sandbox | napplet contract + `commonsUI` (for the shell/shim Compose resources) | +| iOS (future) | iOS app | everything + `commonsUI`; expected to share most UI with Android | `commons` sits **above** `quartz` (the protocol-only Nostr KMP library) and **below** the apps. The split between the three is: @@ -15,10 +16,13 @@ - **`quartz/`** — Nostr protocol: events, NIPs, crypto, relay framing. No app state, no UI, no caches of "what this user follows." - **`commons/`** — everything an Amethyst *client* needs that isn't a - platform-native screen or navigation shell: domain models (`Note`, `User`), - in-memory state holders, ViewModels, the relay-subscription client, shared - business services, **and** the Compose UI components that more than one front - end renders. + platform-native screen, navigation shell, **or Compose UI**: domain models + (`Note`, `User`), in-memory state holders, ViewModels, the relay-subscription + client, shared business services. +- **`commonsUI/`** — the Compose UI components that more than one front end + renders, plus everything only they need (icons, theme, Coil fetchers, + markdown, the `composeResources` strings/fonts and the generated `Res`). + Depends on `commons` as `api`. See `commonsUI/ARCHITECTURE.md`. - **`amethyst/` & `desktopApp/`** — platform-native screens, navigation (bottom-nav vs sidebar), gestures, system integration. They assemble `commons` pieces; they should not re-implement them. @@ -31,29 +35,32 @@ ## 1. The one rule that shapes the package tree: the **UI / non-UI boundary** -`commons` is a single module that contains **both** Compose UI and headless -logic. That is deliberate (it keeps a feature's model, state, and UI together — -see §3), but it creates one hard constraint, because **`cli` and any headless -consumer cannot use Compose**: +The shared layer is **two modules** with one package tree: -> **CLI-safe code** = does not depend on Compose UI. It may use the -> `androidx.compose.runtime` *annotations* `@Stable` / `@Immutable` (they are -> just stability tags) and snapshot state, but it must **not** import -> `androidx.compose.ui`, `androidx.compose.foundation`, -> `androidx.compose.material3`, declare `@Composable` functions, or build -> `ImageVector`s. +> **`commons` = CLI-safe code.** It does not depend on Compose UI. It may use +> the `androidx.compose.runtime` *annotations* `@Stable` / `@Immutable` (they +> are just stability tags) and snapshot state (`mutableStateOf`, `State`), but +> it must **not** import `androidx.compose.ui`, `androidx.compose.foundation`, +> `androidx.compose.material3`, Coil, the generated `Res`, declare +> `@Composable` functions, or build `ImageVector`s. Its `build.gradle.kts` +> simply has none of those dependencies, so a violation fails to compile. > -> **UI code** = anything that does. It is only usable by the GUI front ends -> (Android, Desktop, iOS), never by `cli`. +> **`commonsUI` = UI code.** Anything that does the above. It is only usable +> by the GUI front ends (Android, Desktop, iOS), never by `cli`. -Compose is an `implementation` dependency of `commonMain`, so `cli` pulling in -`commons` does **not** force it to render anything — but a `cli` command must -only reach for CLI-safe packages. When you add code, know which side of this -line it is on, and put it in a package that matches (§2). +Both modules share the **same `com.vitorpamplona.amethyst.commons.*` package +tree** — the split is a module boundary, not a package rename, so a file moves +between `commons/src/…` and `commonsUI/src/…` without changing its package or +any consumer's imports. Kotlin resolves same-package declarations across +modules without imports; the only thing that stops working across the boundary +is `internal` visibility (a UI file cannot see an `internal` declaration in +`commons` — make it public or move it). -This boundary is **not** a top-level `ui/` vs `logic/` partition of the whole -module (we chose to stay feature-oriented, §3). It is a property of each file -that you keep track of via package placement and the table in §2. +This boundary is **not** a top-level `ui/` vs `logic/` partition of the package +tree (we chose to stay feature-oriented, §3). It is a property of each file: +a feature keeps its logic in `commons/…//` and its composables in +`commonsUI/…//ui/` (or `commonsUI/…//` for the historical +flat packages), and the table in §2 says which module each package lives in. --- @@ -61,12 +68,14 @@ that you keep track of via package placement and the table in §2. Top-level packages under `commonMain/.../commons/`, grouped by concern. **UI?** marks whether the -package contains Compose UI (and is therefore *not* CLI-safe). +package contains Compose UI (and therefore lives in **`commonsUI`**, not +here). "mixed" means the feature's logic is in `commons` and its composables +in `commonsUI`, under the same package. ### Domain models & data | Package | UI? | Purpose | |----------------|-----|---------| -| `model` | no¹ | Core domain types (`Note`, `User`, `Channel`), thread assembly, and per-NIP event model extensions in `model/nipNN…` subpackages. `model/cache` holds the in-memory event-store interfaces + `UserMetadataCache`. `model/account`, `model/observables`. The largest package; keep it organized by NIP. | +| `model` | no¹ | Core domain types (`Note`, `User`, `Channel`), thread assembly (`ThreadAssembler`, `ThreadLevelCalculator`, `ReplyContext`, `replyingDirectlyTo`), and per-NIP event model extensions in `model/nipNN…` subpackages. `model/cache` holds the in-memory event-store interfaces + `UserMetadataCache`. `model/account`, `model/observables`. The largest package; keep it organized by NIP. | | `defaults` | no | Static bootstrap data (default relays, channels). | ¹ `model` uses only the `@Stable`/`@Immutable` runtime annotations — CLI-safe. @@ -91,17 +100,19 @@ package contains Compose UI (and is therefore *not* CLI-safe). | Package | UI? | Purpose | |----------------|-----|---------| | `state` | no² | Small feature `StateFlow` machines (`FollowState`, `UserMetadataState`, `LoadingState`). | -| `viewmodels` | no² | Larger list/feed-backed ViewModels (`androidx.lifecycle.ViewModel`). Shared by all GUI front ends; `cli` usually drives the layers below instead. | -| `feeds` | no | `FeedDefinitionRepository` — custom-feed definitions & ordering. | -| `profile` | mixed | `ProfileBroadcastStatus` (state) + `EditProfileFields` at the root; the `ProfileBroadcastBanner` composable lives in `profile/ui`. | +| `viewmodels` | no² | Larger list/feed-backed ViewModels (`androidx.lifecycle.ViewModel`). Shared by all GUI front ends; `cli` usually drives the layers below instead. The few that hold Compose UI state (`ChatNewMessageState` — `TextFieldValue`; `thread/LevelFeedViewModel` — `LazyListState`) live in `commonsUI` under the same package. | +| `feeds` | no | The feed data-access layer at the root (`FeedFilter`, `AdditiveFeedFilter`, `AdditiveComplexFeedFilter`, `ChangesFlowFilter`, `FeedContentState`, `FeedState`, `InvalidatableContent`, `DefaultFeedOrder`, `RepostRenderability`…) plus `feeds/custom` (`FeedDefinitionRepository` — custom-feed definitions & ordering) and `feeds/related`. See the `feed-patterns` skill. | +| `profile` | mixed | `ProfileBroadcastStatus` (state) + `EditProfileFields` at the root; the `ProfileBroadcastBanner` composable lives in `commonsUI` `profile/ui`. | +| `privacylock` | mixed | Lock state machine + settings here; `LocalPrivacyLockState`/`lockStateFor` (CompositionLocal accessor) in `commonsUI`. | -² may touch `compose.runtime`/`foundation` state types (e.g. `LazyListState`); -they are shared across the GUI apps. Treat as GUI-shared, not strictly headless. +² may touch `compose.runtime` state types (snapshot state, `@Stable`); they +are shared across the GUI apps. A state holder that needs a `foundation`/`ui` +type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`. ### Relay client | Package | UI? | Purpose | |----------------|-----|---------| -| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The canonical **per-visible loading** entry points live here: `relayClient/user/` (`observeUser*` — kind-0 metadata) and `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*` — reactions/zaps/reposts). See the `relay-client` skill. | +| `relayClient` | mixed | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The `@Composable` entry points — `relayClient/user/` (`observeUser*` — kind-0 metadata), `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*`), the other `*FilterAssemblerSubscription`s, `KeyDataSourceSubscription`, `auth/AuthApprovalBanner` — are in `commonsUI` under the same packages. See the `relay-client` skill. | | `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). | ### Platform abstractions (`expect`/`actual`) @@ -112,19 +123,23 @@ they are shared across the GUI apps. Treat as GUI-shared, not strictly headless. | `tor` | no | Tor manager interface + settings. | | `service` | no | Cross-cutting services: `BundledUpdate` batching (common); `service/upload` (JVM), `service/nwc`, `service/lnurl` (jvmAndroid). **Singular `service`** — there is no `services`. | -### UI (Compose — **not** CLI-safe) +### UI (Compose — lives in **`commonsUI`**) | Package | UI? | Purpose | |----------------|-----|---------| -| `ui` | yes | **Cross-cutting** shared composables only, organized by area: `ui/components`, `ui/theme`, `ui/signing`, `ui/thread`, `ui/feeds` (feed DAL + filters — see debt §4), `ui/notifications`, `ui/screens`, `ui/elements`, `ui/layouts`, `ui/markdown`, plus Compose helpers in `ui/state` (cached-state) and `ui/text` (TextField extensions). Feature-specific UI lives in `/ui`, **not** here. | -| `nip23LongContent` | yes | Long-form (NIP-23) article UI: `nip23LongContent/ui/article` (reader) + `…/ui/editor` (authoring). The model lives in `model/nip23LongContent`. | -| `icons` | yes | `ImageVector` icon definitions + builders. | +| `ui` | yes | **Cross-cutting** shared composables only, organized by area: `ui/components`, `ui/theme`, `ui/signing`, `ui/thread`, `ui/note`, `ui/richtext`, `ui/search`, `ui/notifications`, `ui/screens`, `ui/layouts`, `ui/markdown`, `ui/privacylock`, plus Compose helpers in `ui/state` (cached-state) and `ui/text` (TextField extensions). Feature-specific UI lives in `/ui`, **not** here. Nothing under `ui.*` lives in *this* module any more: the feed DAL that used to sit in `ui/feeds` is now `feeds/`, and the reply-context logic that sat in `ui/note` (`replyingDirectlyTo`, `ReplyContext`) is now in `model/` next to `ThreadAssembler`. | +| `nip23LongContent` | yes | Long-form (NIP-23) article UI: `nip23LongContent/ui/article` (reader) + `…/ui/editor` (authoring). The model lives in `model/nip23LongContent` (here). | +| `icons` | yes | `ImageVector` icon definitions + builders, Material Symbols codepoints, the icon-font glyph tables. | | `hashtags` | yes | Custom hashtag `ImageVector`s. | | `robohash` | yes | Procedural robohash avatar `ImageVector` assembly. | +| `audio` | mixed | Spectrum/visualizer *data* (`AudioSpectrum`, `SpectrumAnalyzer`…) here; the `VisualizerRenderer`s, `VisualizerRegistry` and the canvas composables in `commonsUI`. | +| `service/image` | mixed | `CoilImageBridge` + the BlurHash/ThumbHash/Base64/Blossom Coil fetchers are `commonsUI` (they are Coil); the headless image helpers stay here. | +| `napplet` | mixed | Protocol/permission logic here; `NappletWebContract` (serves the shell/shim from `composeResources`) in `commonsUI`. | +| `favorites`, `nip30CustomEmojis`, `nip34Git`, `nip85TrustedAssertions`, `nip53LiveActivities` | mixed | Logic here; each feature's `ui/` (or the flat `FavoriteAppIcon`, `EmojiSuggestionState`) in `commonsUI`. | ### Mixed (documented debt — see §4) | Package | UI? | Purpose | |----------------|-----|---------| -| `nip64Chess` | mixed | Live-chess feature: game/lobby/subscription logic **and** board/lobby composables in one flat package. Needs a `nip64Chess/ui` split. (Mirrors `quartz/.../nip64Chess`.) | +| `nip64Chess` | mixed | Live-chess feature: game/lobby/subscription logic here; board/lobby composables in `commonsUI` under `nip64Chess/ui`. (Mirrors `quartz/.../nip64Chess`.) | | `domain` | no | Currently only `domain/nip46` (Nostr Connect signer flows). Sparse; candidate to fold into a clearer home. | --- @@ -185,11 +200,15 @@ Instead, **layer is the primary axis, NIP is the secondary axis**: | Source set | For | |---------------|-----| | `commonMain` | KMP code for **all** targets (Android, JVM, iOS). Gated by `verifyKmpPurity` — no Jackson/OkHttp/`System.currentTimeMillis`/`java.util.UUID`/JVM `@Synchronized`/`@Volatile`. Use the KMP replacements. | -| `jvmAndroid` | Shared by Android + Desktop, **not** iOS. Where JVM-bound deps live (`nestsClient`, Coil-OkHttp, markdown, `viewModel()` helper, NWC/LNURL). | -| `jvmMain` | Desktop-only (keyring, EXIF, `service/upload`). `dependsOn(jvmAndroid)`. | -| `androidMain` | Android-only (Keystore, DataStore). `dependsOn(jvmAndroid)`. | +| `jvmAndroid` | Shared by Android + Desktop, **not** iOS. Where JVM-bound deps live (`nestsClient`, OkHttp, NWC/LNURL). | +| `jvmMain` | Desktop-only (keyring, EXIF, `service/upload`, OS notifications). `dependsOn(jvmAndroid)`. | +| `androidMain` | Android-only (Keystore, DataStore, the Android `R` string resources used by the napplet host). `dependsOn(jvmAndroid)`. | | `iosMain` | iOS `actual`s. Compile-only spike today. | +`commonsUI` mirrors the same source-set layout (plus `skikoMain`, shared by +desktop JVM + iOS for `org.jetbrains.skia` pixel helpers); Coil-OkHttp, +markdown and the `viewModel()` helper live in its `jvmAndroid`. + When adding platform code, prefer the **most common** source set that still compiles: `commonMain` → `jvmAndroid` → platform-specific. See `/kotlin-multiplatform`. @@ -197,7 +216,8 @@ compiles: `commonMain` → `jvmAndroid` → platform-specific. See ### Where does my code go? (quick guide) 1. **Pure Nostr protocol** (events/NIPs/crypto)? → not here, it's `quartz`. 2. **A composable** rendered by ≥2 front ends, or that you want iOS to share? → - `ui/` or `/ui`. Never in `cli`. + `commonsUI`, in `ui/` or `/ui` (same package tree as here). + Also anything that imports Coil, `Res`, or a `foundation`/`ui` state type. 3. **A ViewModel / `StateFlow` state holder**? → `viewmodels` or `state` (or `` if feature-scoped). Keep it CLI-safe where practical. 4. **Relay subscription / filter assembly**? → `relayClient`. @@ -212,14 +232,16 @@ compiles: `commonMain` → `jvmAndroid` → platform-specific. See These are intentionally *documented*, not silently tolerated. Fix opportunistically. -- **`nip64Chess` is UI+logic in one flat package.** `LiveChessGame.kt` mixes a - state class with composables. Split into `nip64Chess/` (logic) + - `nip64Chess/ui/` (composables); this needs file-level surgery (extracting - composables out of logic files), not just moves, so it is deferred. -- **`ui/feeds` holds the feed data-access layer** (`FeedFilter`, - `ChangesFlowFilter`, `FeedContentState`), which is logic, not UI, and overlaps - conceptually with the top-level `feeds` (custom-feed definitions). Consider - moving the DAL out of `ui/`. +- **`commons` applies the Compose *compiler* plugin without declaring any + composable.** Deliberate, not debt: the plugin's `@StabilityInferred` + stamps are what keep unannotated commons classes stable from the apps' + point of view. Measured on full recompiles (2026-09-12): without it, + unstable composable params go 20→28 in `commonsUI`, 33→65 in `desktopApp`, + 90→149 in `amethyst`. Don't remove it; if a class must be stable for a + hot path, annotate it explicitly as well. +- **Same package tree in two modules.** Intentional (zero-import-churn split), + but it means a package's module is not visible from its name. Rule of + thumb: if it imports Compose UI it is in `commonsUI`; check §2 when unsure. - **`domain` is sparse** (only `nip46`). Either grow it as the home for use-case/flow types or rename it to the matching `nip46RemoteSigner` per the NIP-second-axis rule. diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 50437bf088..b952fe67af 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -1,29 +1,24 @@ - import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable - -// Disables the Kotlin/Native compiler cache for an iOS test binary so the -// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt -// cache (see the call site in the `kotlin {}` block). The version guard makes -// Kotlin re-surface this workaround once we move past 2.4.20, so it can be -// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a -// helper because @OptIn only applies to declarations, not bare statements. -@OptIn(KotlinNativeCacheApi::class) -fun TestExecutable.disableUiKitPrebuiltCache() = - disableNativeCache( - DisableCacheInKotlinVersion.`2_4_20`, - "Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " + - "linking the iOS test binary fails under Xcode 16.4.", - ) +// `:commons` is the HEADLESS half of the shared layer: domain models, state +// holders, ViewModels, the relay client, services. It is consumed by every +// front end including the headless `:cli`, so it must never depend on Compose +// UI (ui / foundation / material3), Coil, Compose resources or Skiko — those +// live in `:commonsUI`, which sits on top of this module. Only the Compose +// *runtime* (@Stable/@Immutable + snapshot state) is allowed here. plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) + // Kept on purpose even though no @Composable lives here anymore: the + // Compose compiler stamps @StabilityInferred on every class it compiles, + // which is what lets the apps' composables treat unannotated commons + // classes (TopFilter, TorSettings, ProfileBroadcastStatus, …) as stable. + // Measured with Compose compiler reports on full recompiles (2026-09-12): + // removing this plugin turns 20→28 composable params unstable in + // :commonsUI, 33→65 in :desktopApp and 90→149 in :amethyst. Inference is + // also self-maintaining, unlike hand-written @Immutable annotations that + // silently lie once a `var` is added — so this stays. alias(libs.plugins.jetbrainsComposeCompiler) - alias(libs.plugins.composeMultiplatform) alias(libs.plugins.serialization) } @@ -70,61 +65,37 @@ kotlin { iosArm64() iosSimulatorArm64() - // Compose Multiplatform 1.11.x ships an `org.jetbrains.compose.ui:ui-uikit` - // prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references - // the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the - // iOS *test* executable against that cache under Xcode 16.4 fails with - // ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion - // because the cached object was built for a newer simulator SDK (18.5) than - // the test binary is being linked for (14.0). Disabling the native cache for - // the iOS test binaries makes ui-uikit recompile against the active SDK, - // where the symbol resolves. See disableUiKitPrebuiltCache() above and - // https://kotl.in/disable-native-cache - targets.withType().configureEach { - binaries.withType().configureEach { - disableUiKitPrebuiltCache() - } - } - sourceSets { commonMain { dependencies { implementation(project(":quartz")) - // Compose Multiplatform - implementation(libs.jetbrains.compose.ui) - implementation(libs.jetbrains.compose.foundation) + // Compose *runtime* only — @Stable/@Immutable annotations and + // snapshot state (mutableStateOf, State) used by state holders. + // No ui / foundation / material3 here: that is :commonsUI. implementation(libs.jetbrains.compose.runtime) - implementation(libs.jetbrains.compose.material3) - implementation(libs.jetbrains.compose.ui.tooling.preview) - // Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and - // lifecycle-runtime-compose ship iOS variants; - // lifecycle-viewmodel-compose (the viewModel() Composable - // helper) is Android-only and lives in jvmAndroid below. + // Lifecycle ViewModel (KMP since 2.8.0, ships iOS variants). + // The Compose-side helpers (lifecycle-runtime-compose, + // viewModel()) live in :commonsUI. implementation(libs.androidx.lifecycle.viewmodel) - implementation(libs.androidx.lifecycle.runtime.compose) - - // Image loading (Coil 3 - KMP). The okhttp network fetcher is - // JVM-only and lives in jvmAndroid; iOS will pull coil-ktor - // when that target wires its actual. - implementation(libs.coil.compose) // LruCache (KMP-ready) implementation(libs.androidx.collection) + // okio (KMP, Apache-2.0) for service/image/DeferredDeleteFileSystem, + // the ForwardingFileSystem the apps wrap Coil's disk cache in. It + // used to arrive transitively through Coil; with Coil in + // :commonsUI the Apple targets lost it (JVM still saw it via + // OkHttp), so declare the dependency the file actually has. + implementation(libs.okio) + // Immutable collections api(libs.kotlinx.collections.immutable) // JSON for custom-feed definitions (KMP — replaces Jackson // for the one commonMain serializer that was blocking iOS). implementation(libs.kotlinx.serialization.json) - - // Compose Multiplatform Resources - implementation(libs.jetbrains.compose.components.resources) - - // KMP syntax highlighter (Apache-2.0) for the git code browser. - implementation(libs.highlights) } } @@ -149,39 +120,17 @@ kotlin { // Phase 5 lands. implementation(project(":nestsClient")) - // Coil's OkHttp network fetcher (JVM-only). iOS will use - // coil-ktor when the iOS Compose UI ships. - implementation(libs.coil.okhttp) - // OkHttp (+ coroutines bridge) for the link-preview fetcher // (service/preview/UrlPreview). JVM-only; iOS will swap to // Ktor when its UI ships. implementation(libs.okhttp) implementation(libs.okhttpCoroutines) - - // Markdown rendering (richtext-commonmark). The single - // consumer (RenderMarkdown.kt) already lives in jvmAndroid. - // iOS support pending Phase 3 markdown decision. - implementation(libs.markdown.commonmark) - implementation(libs.markdown.ui) - implementation(libs.markdown.ui.material3) - - // viewModel() Compose helper. AndroidX publishes this - // artifact for android/jvmStubs/linuxx64Stubs but not iOS, - // so it stays in jvmAndroid until we either swap to the - // org.jetbrains.androidx.lifecycle variant or accept a - // platform-specific ViewModel access pattern on iOS. - implementation(libs.androidx.lifecycle.viewmodel.compose) } } jvmMain { dependsOn(jvmAndroid) dependencies { - // Desktop-specific Compose - implementation(compose.desktop.currentOs) - implementation(libs.jetbrains.compose.ui.tooling) - // Secure key storage via OS keychain (macOS/Windows/Linux) implementation(libs.java.keyring) @@ -205,8 +154,10 @@ kotlin { androidMain { dependsOn(jvmAndroid) dependencies { - // Android-specific Compose tooling - implementation(libs.androidx.ui.tooling.preview) + // androidx.core KTX (Bitmap.scale, prefs.edit {}) used by the + // Android actuals. Was reaching us transitively through the + // Compose UI artifacts before the :commonsUI split. + implementation(libs.androidx.core.ktx) // Secure key storage via Android Keystore implementation(libs.androidx.security.crypto.ktx) @@ -222,16 +173,6 @@ kotlin { getByName("iosArm64Main").dependsOn(iosMain) getByName("iosSimulatorArm64Main").dependsOn(iosMain) - // Skiko-backed targets (desktop JVM + iOS) share pixel-format helpers - // (org.jetbrains.skia.* resolves on both through Compose). Android is - // deliberately NOT in this set — it renders through android.graphics. - val skikoMain = - create("skikoMain") { - dependsOn(commonMain.get()) - } - getByName("jvmMain").dependsOn(skikoMain) - iosMain.dependsOn(skikoMain) - getByName("androidHostTest") { dependencies { implementation(libs.junit) @@ -259,12 +200,6 @@ kotlin { } } -compose.resources { - publicResClass = true - packageOfResClass = "com.vitorpamplona.amethyst.commons.resources" - generateResClass = always -} - // JVM tests run AWT-backed code (ImageIO, Thumbnailator, BufferedImage) — pin // headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep // never bounces the macOS Dock during CI/local test runs. @@ -274,72 +209,5 @@ tasks.withType().configureEach { } } -// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale -// there. Commons gains this gate once FeedDefinitionSerializer.kt has been -// migrated off Jackson; future commonMain code must not reintroduce JVM-only -// JSON / HTTP deps. -val verifyKmpPurity by tasks.registering { - group = "verification" - description = "Fails if iOS-targeted source sets import JVM-only deps." - val checkedDirs = - listOf( - "src/commonMain", "src/commonTest", - "src/appleMain", "src/appleTest", - "src/nativeMain", "src/nativeTest", - "src/iosMain", "src/iosTest", - "src/skikoMain", "src/skikoTest", - "src/iosArm64Main", "src/iosArm64Test", - "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", - "src/linuxMain", "src/linuxTest", - "src/linuxX64Main", "src/linuxX64Test", - "src/macosMain", "src/macosTest", - "src/macosArm64Main", "src/macosArm64Test", - ).map { layout.projectDirectory.dir(it).asFile } - .filter { it.exists() } - inputs.files(checkedDirs) - doLast { - // Each pattern is paired with a short hint so the failure message - // points at the canonical KMP replacement. - val forbidden = - listOf( - "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", - "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", - "System.currentTimeMillis" to "use TimeUtils.now()", - "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", - "java.util.UUID" to "use kotlin.uuid.Uuid", - "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", - // The bare call, not just the annotation: `synchronized(lock) {}` resolves - // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and - // only fails at the iOS compile step. Catch it here instead. - "synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}", - "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", - ) - val offenders = - checkedDirs.flatMap { dir -> - dir.walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .flatMap { file -> - file.readLines().withIndex().mapNotNull { (idx, line) -> - val trimmed = line.trimStart() - // Skip KDoc / line-comment lines — those legitimately - // mention forbidden names (migration notes, doc refs). - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { - return@mapNotNull null - } - forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" - } - } - } - } - if (offenders.isNotEmpty()) { - throw GradleException( - "iOS-targeted source sets must not reference JVM-only APIs. " + - "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + - offenders.joinToString("\n "), - ) - } - } -} - -tasks.named("check").configure { dependsOn(verifyKmpPurity) } +// iOS purity gate — shared task, see gradle/kmp-purity.gradle.kts. +apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) diff --git a/commons/plans/2026-09-12-audit-findings.md b/commons/plans/2026-09-12-audit-findings.md new file mode 100644 index 0000000000..1deb8c39be --- /dev/null +++ b/commons/plans/2026-09-12-audit-findings.md @@ -0,0 +1,75 @@ +# Audit: bugs and performance in `commons` / `commonsUI` (2026-09-12) + +**Method.** Three independent read-only sweeps (commons hot paths: feeds, +relayClient, model, viewmodels; commons services; commonsUI composables) +produced 44 candidate findings. Every one was re-read in the source before +acting; 38 held up, 6 were rejected (below). 34 are fixed on this branch, +4 are deferred because they need a design decision. + +## Fixed — correctness + +| Where | Defect | +|---|---| +| `keystorage/SecureKeyStorage.kt` (jvm) | AES-GCM initialised with `IvParameterSpec` → `InvalidAlgorithmParameterException`; the whole no-keyring fallback (headless Linux, containers) was dead. Now `GCMParameterSpec(128, iv)`; round-trip test added. | +| `service/http/OnionLocationInterceptor.kt` | Any `Onion-Location` header was cached and every Tor request re-pointed at it for 24 h, https→http allowed. Only `.onion` hosts are cached now. | +| `service/http/EncryptedBlobInterceptor.kt` | `peekBody(MAX)` + `bytes()` buffered the blob twice and never closed the original body (a network interceptor: the pool slot stayed open). Reads once, closes. | +| `service/BundledUpdate.kt` `BasicBundledInsert` | No `finally`: one throw out of `onUpdate` left `isProcessing = true` and every later `invalidateList` enqueued forever. Mirrors `BasicBundledUpdate`. | +| `richtext/CachedRichTextParser.kt` | Parse cache keyed on a 32-bit hash with no input check; a `String.hashCode` collision served one note's segments under another author. Hits now verify content/tags/uri/author. | +| `richtext/RichTextParser.kt` `noProtocolUrlValidator` | `(sep?[word]+)*` backtracks exponentially on a line terminator (13 s at 26 chars, run per keystroke in the composer). Separator made mandatory; timing test added. | +| `blurhash/Base83.kt` | `charMap[c.code]` on a 255-entry table → `ArrayIndexOutOfBounds` for any non-Latin-1 char in an attacker-controlled `imeta` blurhash. Bounds-checked. | +| `blurhash/CosineCache.kt` | Keyed on `size * components` so (50,4) and (100,2) shared a table; has()/get() pair could NPE under a concurrent eviction. Composite key + single lookup. | +| `service/upload/MediaCompressor.kt` | EXIF strip gated on the file *name* and failures swallowed silently; camera JPEGs could upload with GPS intact. Sniffs bytes, logs the throwable. | +| `service/upload/StaticSitePublisher.kt` | `walkTopDown()` followed symlinks out of the published root. Links are skipped and files must resolve under the root. | +| `preview/UrlPreview.kt` | `body.bytes()` on an attacker-controlled URL; a host streaming `text/html` forever OOMs the app. Reads at most 512 KB. | +| `service/namecoin/NamecoinNameService.kt` | `catch (e: Exception)` swallowed `CancellationException` and dropped the stack trace. | +| `relayClient/preload/MetadataRateLimiter.kt` | The "flush remaining" ran only after the channel closed, which never happens: fewer than 20 queued pubkeys were never requested (desktop DM list names stayed `npub1…`). Also an unguarded `HashSet` shared across threads. Timed flush + lock; tests added. | +| `relayClient/assemblers/FeedMetadataCoordinator.kt` | Six plain `HashSet`s mutated from Compose callbacks and IO coroutines; and `loadMetadataBatched` marked the whole list as asked while requesting only `take(100)`. One lock, chunked filters. | +| `model/Note.kt` | `isHiddenFor` computed and discarded the scoped-comment muted-word check; `relayHintUrl` computed and discarded live-activity relays; `flow()` check-then-`!!` could NPE against `clearFlow()`; `removeReport` left an empty bucket so deleted reports kept counting; reply/boost/edit/timestamp/reaction/report/label mutators did unlocked read-modify-write while zaps used `syncLock`. | +| `state/EventCollectionState.kt` | Cancel-and-restart on every insert (the KDoc promised the opposite): a feed busier than one event per 250 ms never flushed. Leading-edge throttle. | +| `model/privateChats/Chatroom.kt` | `pruneMessagesToTheLatestOnly` rewrote `messages` outside the lock its add/remove use; a just-decrypted DM could vanish. | +| `viewmodels/LiveStreamTopZappersViewModel.kt` | `publish()` iterated two maps outside the mutex their two writer coroutines hold. | +| `relayClient/assemblers/{Metadata,Reactions}FilterAssembler.kt`, `feeds/ChatroomFeedFilter.kt` | `hashCode()` used as the dedup / feed key; a collision dropped a whole pubkey set from the REQ or merged two rooms' feeds. Keys are the values themselves. | +| `model/ThreadAssembler.kt` `OnlyLatestVersionSet` | `addAll`/`removeAll` returned `elements.map{…}.any()` — always true for non-empty input. | +| `ui/components/ClickableTexts.kt` | `remember(text)` baked `onClick` into the link annotation; a recycled row fired the previous target. | +| `ui/components/ZonedSwipeModifier.kt` | `remember {}` captured the first `openDrawer` forever. | +| `ui/components/RobohashImage.kt` vs `UserAvatar.kt` | Two different "is light theme" predicates fed one global cache that evicts everything when the flag flips. Unified. | +| `nip64Chess/ui/InteractiveChessBoard.kt` | `remember(localMoveCount + positionVersion)` — a sum is not a composite key. | + +## Fixed — performance + +| Where | Change | +|---|---| +| `viewmodels/SearchBarState.kt`, `search/UserSearchEngine.kt` | Full user-cache scan ran on the Compose dispatcher per debounced keystroke; `flowOn(Dispatchers.Default)`. | +| `search/SearchResultSorter.kt` | 1+N `Regex` compiled per scored event; hoisted and cached. | +| `ui/components/ShimmerPlaceholder.kt`, `LoadingAnimation.kt`, `BunkerHeartbeatIndicator.kt` | Frame-rate animation values read in composition (whole composable recomposed every frame, brush/list re-allocated); moved into draw/layer lambdas. | +| `ui/components/GlowingCard.kt` | Brush, dash array and stroke allocated on every draw; `drawWithCache`. | +| `ui/richtext/CustomEmojiRenderer.kt` | `inlineContent` map rebuilt per recomposition on every emoji-bearing name/note; remembered. | +| `ui/search/SearchPeoplePicker.kt` | `indexOf` inside `items` (O(n²) per arrow key, wrong for duplicates); `itemsIndexed`. | +| `nip64Chess/ui/LiveChessGame.kt` | `moves.chunked(2)` per recomposition; remembered. | + +## Deferred (need a design call) + +- **`feeds/FeedContentState.kt`** — the full-refresh bundler and the additive-insert + bundler both end in `updateFeed`, a read-modify-write on `_feedContent` with + separate mutexes. A slow `loadTop()` can overwrite an additive emission that + landed in between. Fix is a single mutex/bundler or a CAS `update {}`; the + choice affects feed latency, so it is left to the maintainer. +- **`model/Note.kt` child lists** — `replies`/`boosts`/`edits` are `List`s, so + every insert is an O(n) `contains` plus a full copy (O(n²) per thread load). + `Chatroom.messages` already uses a `Set`; switching these changes a public type. +- **`ui/note/GitDiffView.kt`** — every line of every file in a patch is composed + eagerly in a non-lazy `Column` (expanded by default). Needs a `LazyColumn` + restructure or a collapse-past-N-lines default. +- **`ui/components/UserAvatar.kt`** — the robohash fallback `ImageVector` is + assembled synchronously in composition even when Coil already has the image; + past 100 distinct authors every one is a cache miss on the UI thread. Needs + the fallback to be produced lazily/off-thread (`produceCachedStateAsync`). + +## Rejected after verification + +- `NewPostsChipState`: "chip can never appear" — `val isAtTop by derivedStateOf` + routes reads through the State, so the outer `derivedStateOf` does track it. +- `EditNicknameDialog`: the initial `snapshotFlow` emission mis-targets the + emoji field, but typing retargets before any suggestion can exist. +- Three "internal visibility" and "same-package reference" hits were + false positives of the name scan. diff --git a/commons/plans/2026-09-12-commons-ui-split.md b/commons/plans/2026-09-12-commons-ui-split.md new file mode 100644 index 0000000000..e6deffb590 --- /dev/null +++ b/commons/plans/2026-09-12-commons-ui-split.md @@ -0,0 +1,112 @@ +# Split `commons` into `commons` (headless) + `commonsUI` (Compose) + +**Date:** 2026-09-12 +**Status:** shipped on this branch + +## Why + +`cli` (`amy`) depends on `:commons`. Until now `:commons` declared Compose +UI, Coil, Compose resources, markdown and (on JVM) `compose.desktop.currentOs` +as dependencies, so every CLI distribution dragged ~40 MB of Compose + Skiko +jars it never loads (`create-release.yml` had a 200 MB budget "until commons +is split into core + ui modules"; `BUILDING.md` flagged the same for the +Homebrew bundle). The UI / non-UI boundary documented in +`commons/ARCHITECTURE.md` §1 was a convention enforced by nothing. + +## What + +A new KMP module **`:commonsUI`** (same targets and source-set layout as +`:commons`, plus `skikoMain`) that `api`-depends on `:commons` and owns every +Compose-dependent file. `:commons` keeps only the Compose *runtime* +(`@Stable`/`@Immutable`, snapshot state) and `lifecycle-viewmodel`; it no +longer applies the `org.jetbrains.compose` plugin, has no `composeResources`, +no Coil, no markdown, no `highlights`, no desktop Compose. + +**Files keep their packages.** The split is a build-graph boundary, not a +package rename: `231` files were `git mv`'d from `commons/src/…` to +`commonsUI/src/…` and not a single import changed in `amethyst`, +`desktopApp`, `nappletHost` or `cli`. The generated `Res` class stays at +`com.vitorpamplona.amethyst.commons.resources` for the same reason. + +Consumers: `amethyst`, `desktopApp`, `nappletHost` (for +`NappletWebContract`, which reads the shell/shim from `composeResources`) and +`benchmark` (robohash) gained `project(":commonsUI")`. `cli`, `geode`, +`marmotBench` are untouched and must stay that way. + +## Method (reproducible) + +1. Classify every `.kt` under `commons/src` as **UI** if it imports + `androidx.compose.{ui,foundation,material3,animation}`, + `org.jetbrains.compose.*`, `coil3`, `org.jetbrains.skia`, + `androidx.lifecycle.compose`, `…commons.resources`, or declares + `@Composable`. +2. Build the intra-module reference graph (imports + same-package simple-name + hits) and verify **no headless file references a UI file**. Two real hits + surfaced and were fixed by surgery rather than by moving logic into the UI + module: + - `richtext/GalleryParser` carried a vestigial + `@OptIn(ExperimentalLayoutApi::class)` — dropped; it is pure logic. + - `privacylock/PrivacyLockState` declared the `LocalPrivacyLockState` + CompositionLocal + `lockStateFor()` next to the state machine — the two + Compose members moved to `commonsUI/…/privacylock/LocalPrivacyLockState.kt` + (same package). +3. Additionally move the rest of the UI-only packages (`ui/**`, `icons`, + `hashtags`, `robohash`, `/ui`) **unless a headless file depends + on the file**. That rule keeps the feed DAL (`ui/feeds/FeedFilter`, + `AdditiveFeedFilter`, `ChangesFlowFilter`, `FeedContentState`, + `RepostRenderability`, `AdditiveComplexFeedFilter`…) and + `ui/note/ParentNote`+`ReplyContext` in `commons` (ViewModels use them). +4. Tests follow their subject; test fixtures shared with headless tests + (`ui/note/StubCache`) stay in `commons`. +5. Check `expect`/`actual` pairs never straddle the boundary (none did) and + that no `internal` declaration is used across it (none was). + +## Verified + +- `:commons:compileKotlinJvm`, `:commonsUI:compileKotlinJvm`, + `:cli:compileKotlin`, `:desktopApp:compileKotlin`, + `:nappletHost:compileDebugKotlin`, `:amethyst:compileFdroidDebugKotlin`. +- `:commons:jvmTest`, `:commonsUI:jvmTest`, `:cli:test`, + `:commons:verifyKmpPurity`, `:commonsUI:verifyKmpPurity`. +- `:cli` runtime classpath no longer contains `org.jetbrains.compose.ui`, + `foundation`, `material3`, `skiko` or `coil`. + +iOS targets could not be linked in the Linux CI container; the workflow runs +`:commonsUI:iosSimulatorArm64Test` + `:commonsUI:compileTestKotlinIosArm64` +next to the `:commons` ones on macOS. + +## Follow-ups (done in the same branch) + +- **CLI size budget tightened to 120 MB** in `create-release.yml`. Measured + after the split (1.15.2, Linux x64): JVM tarball 55 MB, jlink image tarball + 80 MB, `lib/` 60 MB on disk — vs ~70 MB JVM tarball before. +- **Feed DAL moved out of `ui.feeds`** into `commons/…/feeds/` (root of the + existing `feeds` package, next to `feeds/custom`). Consumer imports rewritten. +- **Chess composables moved to `nip64Chess/ui`** in `commonsUI`; the logic + stays in `commons/…/nip64Chess/`. +- **`ui/note/ParentNote` + `ReplyContext` moved to `commons/…/model/`** next to + `ThreadAssembler` (with the `StubCache` test fixture). No `ui.*` package is + left in `commons`. + +## Decided: `commons` keeps the Compose compiler plugin + +Measured rather than guessed. With `composeCompiler { reportsDestination }` +on the three GUI modules and full (non-incremental, `--rerun`) compiles in +both configurations, removing the plugin from `commons` flips composable +parameters typed with unannotated commons classes from runtime-stable to +unstable: + +| Module | unstable params, plugin on | plugin off | +|---|---|---| +| `commonsUI` | 20 | 28 | +| `desktopApp` | 33 | 65 | +| `amethyst` (fdroidDebug) | 90 | 149 | + +The classes involved (`TopFilter`, `TorSettings`, `TorServiceStatus`, +`ProfileBroadcastStatus`, `ScheduledPost`, `EmojiPackState`, +`Nip65RelayListState`, `PendingAuthApproval`, `UserSearchEngine`, …) are +all-`val` classes with no `@Stable`/`@Immutable` annotation; the plugin infers +their stability and keeps inferring it as they evolve. Hand-annotating them +would reproduce today's result but rot silently the first time a `var` is +added, so the plugin stays. Recorded in `commons/build.gradle.kts`. + diff --git a/commons/plans/README.md b/commons/plans/README.md index cf0a4a40df..01bc81b0cf 100644 --- a/commons/plans/README.md +++ b/commons/plans/README.md @@ -1,6 +1,6 @@ # commons plans -_Audited 2026-06-30. 6 plans: 2 shipped (archived), 2 in-progress, 2 queued, 0 abandoned._ +_Audited 2026-06-30 (+ 2026-09-12 split entry). 7 plans: 3 shipped, 2 in-progress, 2 queued, 0 abandoned._ ## In progress | Plan | Summary | @@ -15,6 +15,12 @@ _Audited 2026-06-30. 6 plans: 2 shipped (archived), 2 in-progress, 2 queued, 0 a | [2026-05-30-amethyst-to-commons-migration.md](2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. | | [2026-08-03-poll-results-page.md](2026-08-03-poll-results-page.md) | Extended NIP-88 poll results page (per-option counts + who voted for what) for Android and Desktop; also specifies four tally-correctness fixes and the missing poll-relay subscription. Proposed, not started. | +## Shipped +| Plan | Summary | +| ---- | ------- | +| [2026-09-12-audit-findings.md](2026-09-12-audit-findings.md) | Bug/performance audit of `commons` + `commonsUI` after the split: 34 verified fixes shipped, 4 deferred with rationale, 6 rejected. | +| [2026-09-12-commons-ui-split.md](2026-09-12-commons-ui-split.md) | Split the Compose half of `commons` into the new `:commonsUI` module (same packages, `api(:commons)`), so `cli` no longer carries Compose/Skiko; records the classification method and follow-ups. | + ## Archived (shipped) | Plan | Summary | | ---- | ------- | diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 74b279d621..dbb4efb401 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -107,6 +107,26 @@ actual class SecureKeyStorage private actual constructor() { } } + /** + * Android backend: EncryptedSharedPreferences.contains + getString has no + * ambiguous-error state comparable to macOS Keychain user-cancel/deny, so + * "key not present" and "key present" are the only two null outcomes. + * Any thrown exception is a genuine failure and propagates. + */ + actual suspend fun getPrivateKeyOrThrow(npub: String): String? = + withContext(Dispatchers.IO) { + try { + val key = KEY_PREFIX + npub + if (!encryptedPrefs.contains(key)) { + null + } else { + encryptedPrefs.getString(key, null) + } + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) + } + } + actual suspend fun deletePrivateKey(npub: String): Boolean = withContext(Dispatchers.IO) { try { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt index ec75e7f01f..081810762b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt @@ -57,12 +57,12 @@ object Base83 { fun decodeAt( str: String, at: Int = 0, - ): Int = charMap[str[at].code] + ): Int = valueOf(str[at]) fun decodeFixed2( str: String, from: Int = 0, - ): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code] + ): Int = valueOf(str[from]) * 83 + valueOf(str[from + 1]) fun decode( str: String, @@ -71,13 +71,24 @@ object Base83 { ): Int { var result = 0 for (i in from until to) { - result = result * 83 + charMap[str[i].code] + result = result * 83 + valueOf(str[i]) } return result } val ALPHABET: CharArray = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray() + /** + * Value of [c] in the base-83 alphabet, or 0 for any character outside it. + * Blurhashes come from attacker-controlled `imeta` tags, so a non-Latin-1 + * character must degrade to a wrong colour, not an + * ArrayIndexOutOfBoundsException out of the image fetcher. + */ + private fun valueOf(c: Char): Int { + val code = c.code + return if (code < charMap.size) charMap[code] else 0 + } + private val charMap = ALPHABET .mapIndexed { i, c -> c.code to i } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index 52a299a1ac..46abcd162f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -122,10 +122,8 @@ object BlurHashDecoder { ): IntArray { // use an array for better performance when writing pixel colors val imageArray = IntArray(width * height) - val calculateCosX = !useCache || !CosineCache.hasX(width * numCompX) - val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, numCompX) - val calculateCosY = !useCache || !CosineCache.hasY(height * numCompY) - val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, numCompY) + val cosinesX = CosineCache.getArrayForCosinesX(useCache, width, numCompX) + val cosinesY = CosineCache.getArrayForCosinesY(useCache, height, numCompY) var r = 0.0f var g = 0.0f diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt index e1c299749a..57d8e97076 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt @@ -83,10 +83,8 @@ class BlurHashEncoder { val factors = Array(componentX * componentY) { DoubleArray(3) } - val calculateCosX = !useCache || !CosineCache.hasX(width * componentX) - val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, componentX) - val calculateCosY = !useCache || !CosineCache.hasY(height * componentY) - val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, componentY) + val cosinesX = CosineCache.getArrayForCosinesX(useCache, width, componentX) + val cosinesY = CosineCache.getArrayForCosinesY(useCache, height, componentY) val scale = 1.0 / (width * height) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt index 5510c69ef0..e61e0aa2a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt @@ -30,8 +30,15 @@ object CosineCache { // 2 * nBitmaps // the cache is enabled by default, it is recommended to disable it only when just a few images // are displayed - private val cacheCosinesX = LruCache(20) - private val cacheCosinesY = LruCache(20) + // Keyed on (size, components) as a pair, not their product: (50, 4) and + // (100, 2) both produce a 200-entry table with different contents. + private val cacheCosinesX = LruCache(20) + private val cacheCosinesY = LruCache(20) + + private fun key( + size: Int, + components: Int, + ): Long = (size.toLong() shl 32) or components.toLong() /** * Clear calculations stored in memory cache. The cache is not big, but will increase when many @@ -42,45 +49,46 @@ object CosineCache { cacheCosinesY.evictAll() } - fun hasX(idx: Int) = cacheCosinesX.get(idx) != null - - fun hasY(idx: Int) = cacheCosinesY.get(idx) != null - - fun getArrayForCosinesY( - calculate: Boolean, + private fun computeY( height: Int, numCompY: Int, - ) = when { - calculate -> { - DoubleArray(height * numCompY) { - val y = it / numCompY - val j = it % numCompY - cos(PI * y * j / height) - }.also { - cacheCosinesY.put(height * numCompY, it) - } - } + ) = DoubleArray(height * numCompY) { + val y = it / numCompY + val j = it % numCompY + cos(PI * y * j / height) + } - else -> { - cacheCosinesY[height * numCompY]!! - } + private fun computeX( + width: Int, + numCompX: Int, + ) = DoubleArray(width * numCompX) { + val x = it / numCompX + val i = it % numCompX + cos(PI * x * i / width) + } + + /** + * The Y cosine table for ([height], [numCompY]). With [useCache] the lookup + * and the insert are one operation, so a concurrent decoder evicting the + * entry between a has()/get() pair can no longer produce a null. + */ + fun getArrayForCosinesY( + useCache: Boolean, + height: Int, + numCompY: Int, + ): DoubleArray { + if (!useCache) return computeY(height, numCompY) + val k = key(height, numCompY) + return cacheCosinesY[k] ?: computeY(height, numCompY).also { cacheCosinesY.put(k, it) } } fun getArrayForCosinesX( - calculate: Boolean, + useCache: Boolean, width: Int, numCompX: Int, - ) = when { - calculate -> { - DoubleArray(width * numCompX) { - val x = it / numCompX - val i = it % numCompX - cos(PI * x * i / width) - }.also { cacheCosinesX.put(width * numCompX, it) } - } - - else -> { - cacheCosinesX[width * numCompX]!! - } + ): DoubleArray { + if (!useCache) return computeX(width, numCompX) + val k = key(width, numCompX) + return cacheCosinesX[k] ?: computeX(width, numCompX).also { cacheCosinesX.put(k, it) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveComplexFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt similarity index 91% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveComplexFeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt index 3d7d06bb0f..d2003dd179 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveComplexFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt @@ -18,9 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds - -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +package com.vitorpamplona.amethyst.commons.feeds abstract class AdditiveComplexFeedFilter : FeedFilter() { abstract fun updateListWith( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveFeedFilter.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveFeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveFeedFilter.kt index 4807810ed0..1dfe0a970b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.amethyst.commons.util.logTime diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChangesFlowFilter.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChangesFlowFilter.kt index 419cc268d7..6da136d514 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChangesFlowFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.amethyst.commons.model.ListChange import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChatroomFeedFilter.kt similarity index 94% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChatroomFeedFilter.kt index cf2674e9e9..75865e839e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChatroomFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note @@ -34,7 +34,7 @@ class ChatroomFeedFilter( override fun changesFlow() = chatroom().changesFlow() // returns the last Note of each user. - override fun feedKey(): String = withUser.hashCode().toString() + override fun feedKey(): String = withUser.users.sorted().joinToString(",") override fun feed(): List = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/DefaultFeedOrder.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/DefaultFeedOrder.kt index 47bd192eab..c56a7ab6c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/DefaultFeedOrder.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.amethyst.commons.model.Note diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt index 9945c77fd0..02767f31d0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedFilter.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedFilter.kt index 39cf900035..961ffc5406 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.amethyst.commons.util.logTime diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedState.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedState.kt index f46ac43ea8..de0b36e3a9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/IAdditiveFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/IAdditiveFeedFilter.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/IAdditiveFeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/IAdditiveFeedFilter.kt index e695139a1d..63f9e8e88e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/IAdditiveFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/IAdditiveFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds interface IAdditiveFeedFilter : IFeedFilter { fun applyFilter(newItems: Set): Set diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/IFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/IFeedFilter.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/IFeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/IFeedFilter.kt index 87fe0d9f92..fd25af0d35 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/IFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/IFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds interface IFeedFilter { fun loadTop(): List diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/InvalidatableContent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/InvalidatableContent.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/InvalidatableContent.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/InvalidatableContent.kt index 332641fefe..f20c22412a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/InvalidatableContent.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/InvalidatableContent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.State diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/MarmotGroupFeedFilter.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/MarmotGroupFeedFilter.kt index 52c29d7ba4..28673d46bd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/MarmotGroupFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RepostRenderability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/RepostRenderability.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RepostRenderability.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/RepostRenderability.kt index 0ca86db575..a5b9a57175 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RepostRenderability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/RepostRenderability.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index efb9d7342e..2ee7e124cf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -76,12 +76,34 @@ expect class SecureKeyStorage private constructor() { * **Security Warning:** The returned String cannot be securely zeroed from memory (JVM limitation). * Dereference the returned value immediately after use to minimize exposure time. * + * Callers that MUST distinguish "key does not exist" from "backend refused/locked/failed" + * (for example, before generating a replacement key on disk) should use + * [getPrivateKeyOrThrow] instead. This method returns null on any error and cannot + * safely be used as an "is this the first launch?" probe. + * * @param npub The public key in npub (Bech32) format * @return The private key in hexadecimal format, or null if not found * @throws SecureStorageException if retrieval operation fails */ suspend fun getPrivateKey(npub: String): String? + /** + * Retrieves a private key for the given npub, distinguishing "definitively absent" + * from any other failure mode. + * + * On success, returns the key. When the backend confirms the item does not exist, + * returns null. Any other outcome (backend unavailable, user denied the OS prompt, + * keychain locked, I/O error) throws [SecureStorageException]. This is the safe + * primitive for compare-and-swap style flows where a null must not be interpreted + * as permission to generate and persist a replacement. + * + * @param npub The public key in npub (Bech32) format + * @return The private key in hexadecimal format, or null only when the backend + * confirms the item does not exist + * @throws SecureStorageException on any ambiguous or transient failure + */ + suspend fun getPrivateKeyOrThrow(npub: String): String? + /** * Deletes a private key for the given npub. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt index 3b4a1e1381..0444058aad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException /** * What a front end renders for one live agent text stream. @@ -203,6 +204,9 @@ class MarmotAgentStreamWatcher( try { quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray()) } catch (e: Exception) { + // Without this a cancelled watcher keeps dialling the remaining + // candidates instead of stopping. + if (e is CancellationException) throw e Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" } continue } @@ -222,6 +226,7 @@ class MarmotAgentStreamWatcher( ) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" } } finally { runCatching { stream.close() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index be6da5ffa0..c7e11dd6d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -194,12 +194,15 @@ open class Note( var otsVerification: VerificationState? = null // These fields are updated every time an event related to this note is received. + @Volatile var replies = listOf() private set + @Volatile var reactions = mapOf>() private set + @Volatile var boosts = listOf() private set @@ -211,6 +214,7 @@ open class Note( * (channel-retained) message keeps it strongly reachable. The chat bubble overlays the latest * author-matching edit. */ + @Volatile var edits = listOf() private set @@ -221,9 +225,11 @@ open class Note( * delete). Anchoring them here also replaces the old full-cache scan: the OTS pill folds this * list directly. Each attestation memoizes its own blockchain verdict in [Note.otsVerification]. */ + @Volatile var timestamps = listOf() private set + @Volatile var reports = mapOf>() private set @@ -234,6 +240,7 @@ open class Note( * so `source.author` identifies who labeled it. Used by the hashtag feed to * surface posts a follow has tagged and to attribute the label in the UI. */ + @Volatile var labels = mapOf>() private set @@ -358,7 +365,7 @@ open class Note( } is LiveActivitiesEvent -> { - noteEvent.relays().ifEmpty { null }?.toSet() + noteEvent.relays().firstOrNull()?.let { return it } } is LiveActivitiesChatMessageEvent -> { @@ -449,51 +456,65 @@ open class Note( } fun addReply(note: Note) { - if (note !in replies) { - replies = replies + note - flowSet?.replies?.invalidateData() + syncLock.withLock { + if (note !in replies) { + replies = replies + note + flowSet?.replies?.invalidateData() + } } } fun removeReply(note: Note) { - if (note in replies) { - replies = replies - note - flowSet?.replies?.invalidateData() + syncLock.withLock { + if (note in replies) { + replies = replies - note + flowSet?.replies?.invalidateData() + } } } fun addEdit(note: Note) { - if (note !in edits) { - edits = edits + note - flowSet?.edits?.invalidateData() + syncLock.withLock { + if (note !in edits) { + edits = edits + note + flowSet?.edits?.invalidateData() + } } } fun removeEdit(note: Note) { - if (note in edits) { - edits = edits - note - flowSet?.edits?.invalidateData() + syncLock.withLock { + if (note in edits) { + edits = edits - note + flowSet?.edits?.invalidateData() + } } } fun addTimestamp(note: Note) { - if (note !in timestamps) { - timestamps = timestamps + note - flowSet?.ots?.invalidateData() + syncLock.withLock { + if (note !in timestamps) { + timestamps = timestamps + note + flowSet?.ots?.invalidateData() + } } } fun removeTimestamp(note: Note) { - if (note in timestamps) { - timestamps = timestamps - note - flowSet?.ots?.invalidateData() + syncLock.withLock { + if (note in timestamps) { + timestamps = timestamps - note + flowSet?.ots?.invalidateData() + } } } fun removeBoost(note: Note) { - if (note in boosts) { - boosts = boosts - note - flowSet?.boosts?.invalidateData() + syncLock.withLock { + if (note in boosts) { + boosts = boosts - note + flowSet?.boosts?.invalidateData() + } } } @@ -577,32 +598,44 @@ open class Note( } fun removeReaction(note: Note) { - val tags = note.event?.tags ?: emptyArray() - val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + syncLock.withLock { + val tags = note.event?.tags ?: emptyArray() + val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - if (reactions[reaction]?.contains(note) == true) { - reactions[reaction]?.let { - if (note in it) { - val newList = it.minus(note) - if (newList.isEmpty()) { - reactions = reactions.minus(reaction) - } else { - reactions = reactions + Pair(reaction, newList) + if (reactions[reaction]?.contains(note) == true) { + reactions[reaction]?.let { + if (note in it) { + val newList = it.minus(note) + if (newList.isEmpty()) { + reactions = reactions.minus(reaction) + } else { + reactions = reactions + Pair(reaction, newList) + } + + flowSet?.reactions?.invalidateData() } - - flowSet?.reactions?.invalidateData() } } } } fun removeReport(deleteNote: Note) { - val author = deleteNote.author ?: return + syncLock.withLock { + val author = deleteNote.author ?: return - if (reports[author]?.contains(deleteNote) == true) { - reports[author]?.let { - reports = reports + Pair(author, it.minus(deleteNote)) - flowSet?.reports?.invalidateData() + if (reports[author]?.contains(deleteNote) == true) { + reports[author]?.let { + val newList = it.minus(deleteNote) + // Drop the author key when their last report goes, otherwise + // countReportAuthorsBy() keeps counting a deleted report. + reports = + if (newList.isEmpty()) { + reports.minus(author) + } else { + reports + Pair(author, newList) + } + flowSet?.reports?.invalidateData() + } } } } @@ -630,9 +663,11 @@ open class Note( } fun addBoost(note: Note) { - if (note !in boosts) { - boosts = boosts + note - flowSet?.boosts?.invalidateData() + syncLock.withLock { + if (note !in boosts) { + boosts = boosts + note + flowSet?.boosts?.invalidateData() + } } } @@ -908,30 +943,34 @@ open class Note( } fun addReaction(note: Note) { - val tags = note.event?.tags ?: emptyArray() - val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + syncLock.withLock { + val tags = note.event?.tags ?: emptyArray() + val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - val listOfAuthors = reactions[reaction] - if (listOfAuthors == null) { - reactions = reactions + Pair(reaction, listOf(note)) - flowSet?.reactions?.invalidateData() - } else if (!listOfAuthors.contains(note)) { - reactions = reactions + Pair(reaction, listOfAuthors + note) - flowSet?.reactions?.invalidateData() + val listOfAuthors = reactions[reaction] + if (listOfAuthors == null) { + reactions = reactions + Pair(reaction, listOf(note)) + flowSet?.reactions?.invalidateData() + } else if (!listOfAuthors.contains(note)) { + reactions = reactions + Pair(reaction, listOfAuthors + note) + flowSet?.reactions?.invalidateData() + } } } fun addReport(note: Note) { - val author = note.author ?: return + syncLock.withLock { + val author = note.author ?: return - val reportsByAuthor = reports[author] + val reportsByAuthor = reports[author] - if (reportsByAuthor == null) { - reports = reports + Pair(author, listOf(note)) - flowSet?.reports?.invalidateData() - } else if (!reportsByAuthor.contains(note)) { - reports = reports + Pair(author, reportsByAuthor + note) - flowSet?.reports?.invalidateData() + if (reportsByAuthor == null) { + reports = reports + Pair(author, listOf(note)) + flowSet?.reports?.invalidateData() + } else if (!reportsByAuthor.contains(note)) { + reports = reports + Pair(author, reportsByAuthor + note) + flowSet?.reports?.invalidateData() + } } } @@ -940,25 +979,29 @@ open class Note( hashtag: String, note: Note, ) { - val listOfLabelers = labels[hashtag] - if (listOfLabelers == null) { - labels = labels + Pair(hashtag, listOf(note)) - flowSet?.labels?.invalidateData() - } else if (!listOfLabelers.contains(note)) { - labels = labels + Pair(hashtag, listOfLabelers + note) - flowSet?.labels?.invalidateData() + syncLock.withLock { + val listOfLabelers = labels[hashtag] + if (listOfLabelers == null) { + labels = labels + Pair(hashtag, listOf(note)) + flowSet?.labels?.invalidateData() + } else if (!listOfLabelers.contains(note)) { + labels = labels + Pair(hashtag, listOfLabelers + note) + flowSet?.labels?.invalidateData() + } } } /** Detach a LabelEvent note (e.g. deleted) from every hashtag bucket it was in. */ fun removeLabel(note: Note) { - if (labels.none { it.value.contains(note) }) return + syncLock.withLock { + if (labels.none { it.value.contains(note) }) return - labels = - labels - .mapValues { it.value - note } - .filterValues { it.isNotEmpty() } - flowSet?.labels?.invalidateData() + labels = + labels + .mapValues { it.value - note } + .filterValues { it.isNotEmpty() } + flowSet?.labels?.invalidateData() + } } fun addRelaySync(relay: NormalizedRelayUrl) = @@ -1518,8 +1561,8 @@ open class Note( return true } - if (thisEvent is CommentEvent) { - thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) } + if (thisEvent is CommentEvent && thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) }) { + return true } if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) { @@ -1550,12 +1593,13 @@ open class Note( } } - fun flow(): NoteFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) + fun flow(): NoteFlowSet = + // Fast path reads the @Volatile field once; the slow path re-checks under + // the same lock createOrDestroyFlowSync() uses, so a concurrent clearFlow() + // between the two can't leave us dereferencing a just-nulled set. + flowSet ?: syncLock.withLock { + flowSet ?: NoteFlowSet(this).also { flowSet = it } } - return flowSet!! - } fun clearFlow() { if (flowSet != null && flowSet?.isInUse() == false) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNote.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt index 3c1df2afd3..62ba73e534 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNote.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.note +package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContext.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContext.kt index 92ea646504..7dca3be782 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContext.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.note +package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index 51bff82663..e07c1c98f9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -223,7 +223,7 @@ class OnlyLatestVersionSet : MutableSet { } } - override fun addAll(elements: Collection): Boolean = elements.map { add(it) }.any() + override fun addAll(elements: Collection): Boolean = elements.fold(false) { changed, it -> add(it) || changed } override val size: Int get() = set.size @@ -243,7 +243,7 @@ class OnlyLatestVersionSet : MutableSet { override fun retainAll(elements: Collection): Boolean = set.retainAll(elements) - override fun removeAll(elements: Collection): Boolean = elements.map { remove(it) }.any() + override fun removeAll(elements: Collection): Boolean = elements.fold(false) { changed, it -> remove(it) || changed } override fun remove(element: Note): Boolean { element.address()?.let { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt index 51c1c1e6df..3782bdfe66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt @@ -30,6 +30,10 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -75,6 +79,16 @@ class NwcInfoCache( private val cache = ConcurrentMap() + private val updatesState = MutableStateFlow(0) + + /** + * Bumps every time a fetch stores an entry. [current] is a plain map read, so a + * composable that derives state from it (the zap picker's "can our wallet pay a + * BOLT12 offer" check) has nothing to recompose on when the info arrives after + * it opened; keying on this flow closes that gap. + */ + val updates: StateFlow = updatesState.asStateFlow() + // Fetches in progress, keyed like [cache]. Every fetching path goes through [fetchOnce]. private val inFlight = ConcurrentMap>() @@ -182,6 +196,7 @@ class NwcInfoCache( } cache[uri.pubKeyHex] = Entry(info, now()) + updatesState.update { it + 1 } return info } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 0101161787..b197e44971 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -135,7 +135,13 @@ class Chatroom : NotesGatherer { fun senderIntersects(keySet: Set): Boolean = activeSenders.any { it.pubkeyHex in keySet } - fun pruneMessagesToTheLatestOnly(): Set { + fun pruneMessagesToTheLatestOnly(): Set = + // Same lock as addMessageSync/removeMessageSync: the snapshot, the + // rewrite of `messages` and the change emission must not interleave + // with a gift wrap being decrypted into this room. + syncLock.withLock { pruneMessagesToTheLatestOnlyLocked() } + + private fun pruneMessagesToTheLatestOnlyLocked(): Set { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt index 613690f427..9e4714756b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt @@ -25,6 +25,16 @@ class OpenGraphParser { val title: String, val description: String, val image: String, + /** The `og:audio` URL the page declares for itself, verbatim (may be relative). */ + val audio: String = "", + /** The `og:audio:type` MIME the page declares for [audio], e.g. `audio/mpeg`. */ + val audioType: String = "", + /** The `og:video` URL the page declares for itself, verbatim (may be relative). */ + val video: String = "", + /** The `og:video:type` MIME the page declares for [video], e.g. `video/mp4`. */ + val videoType: String = "", + /** `og:type` — what the page says it *is*, e.g. `music.song`, `video.other`, `article`. */ + val type: String = "", ) companion object { @@ -56,79 +66,119 @@ class OpenGraphParser { "image", ) + // The media file a host page is *about*. Media hosts publish a human-facing HTML player + // page and point at the real file from here. nostr.build does exactly this, in both + // families: `e.nostr.build/a__mp3` carries `og:audio` = `a.nostr.build/.mp3`, and + // `e.nostr.build/v__mp4` carries `og:video` = `v.nostr.build/.mp4`. Without these + // such a page can only be rendered as a card that links out. + // + // `:url` and `:secure_url` are the spec's aliases for the bare key and are read because + // hosts disagree on which to emit -- nostr.build writes the bare `og:video`, YouTube only + // `og:video:url`. Reading a key is not the same as trusting it: what a page declares here + // is filtered by the `:type` below before anything is handed to a player. + // + // Deliberately `og:` only. `twitter:player:stream` is not included because it is usually + // an embed, and a player pointed at the wrong thing is worse than a link. + private val META_X_AUDIO = + arrayOf( + "og:audio", + "og:audio:url", + "og:audio:secure_url", + ) + + private val META_X_AUDIO_TYPE = + arrayOf( + "og:audio:type", + ) + + private val META_X_VIDEO = + arrayOf( + "og:video", + "og:video:url", + "og:video:secure_url", + ) + + private val META_X_VIDEO_TYPE = + arrayOf( + "og:video:type", + ) + + // What the page says it is. A media host's player page declares `music.*` or `video.*`; an + // article that merely embeds a clip declares `article` and keeps its link card. Without + // this an `og:video` on a news story would replace the whole card with a bare player. + private val META_X_TYPE = + arrayOf( + "og:type", + ) + private val CONTENT = "content" } + /** Which field of [Result] a meta tag's key fills, or null when the key is not one we read. */ + private enum class Field { + TITLE, + DESCRIPTION, + IMAGE, + AUDIO, + AUDIO_TYPE, + VIDEO, + VIDEO_TYPE, + TYPE, + } + + private fun fieldFor(key: String): Field? = + when (key) { + in META_X_TITLE -> Field.TITLE + in META_X_DESCRIPTION -> Field.DESCRIPTION + in META_X_IMAGE -> Field.IMAGE + in META_X_AUDIO -> Field.AUDIO + in META_X_AUDIO_TYPE -> Field.AUDIO_TYPE + in META_X_VIDEO -> Field.VIDEO + in META_X_VIDEO_TYPE -> Field.VIDEO_TYPE + in META_X_TYPE -> Field.TYPE + else -> null + } + + /** + * Reads every `` the scanner yields, keeping the first value seen for each field. + * + * There is deliberately no early exit once the fields are filled. The one that used to be here + * stopped at title+description+image, which is a set every page completes before it reaches + * `og:audio`/`og:video` -- so on nostr.build's player pages it skipped the single field that + * makes the page playable. The scan is bounded anyway: [MetaTagsParser] stops at ``. + */ fun extractUrlInfo(metaTags: Sequence): Result { - var title: String = "" - var description: String = "" - var image: String = "" + var title = "" + var description = "" + var image = "" + var audio = "" + var audioType = "" + var video = "" + var videoType = "" + var type = "" metaTags.forEach { - when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) { - in META_X_TITLE -> { - if (title.isEmpty()) { - title = it.attr(CONTENT) - } - } + // A meta tag names its key in exactly one of these three attributes, but which one + // varies by site, so each is tried in turn until one is a key we read. + val field = + fieldFor(it.attr(ATTRIBUTE_VALUE_PROPERTY)) + ?: fieldFor(it.attr(ATTRIBUTE_VALUE_NAME)) + ?: fieldFor(it.attr(ATTRIBUTE_VALUE_ITEMPROP)) - in META_X_DESCRIPTION -> { - if (description.isEmpty()) { - description = it.attr(CONTENT) - } - } - - in META_X_IMAGE -> { - if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } - } - - when (it.attr(ATTRIBUTE_VALUE_NAME)) { - in META_X_TITLE -> { - if (title.isEmpty()) { - title = it.attr(CONTENT) - } - } - - in META_X_DESCRIPTION -> { - if (description.isEmpty()) { - description = it.attr(CONTENT) - } - } - - in META_X_IMAGE -> { - if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } - } - - when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) { - in META_X_TITLE -> { - if (title.isEmpty()) { - title = it.attr(CONTENT) - } - } - - in META_X_DESCRIPTION -> { - if (description.isEmpty()) { - description = it.attr(CONTENT) - } - } - - in META_X_IMAGE -> { - if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } - } - - if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) { - return Result(title, description, image) + // First value wins, so `og:audio` is preferred over the `og:audio:secure_url` that + // follows it, and `og:title` over `twitter:title`. + when (field) { + Field.TITLE -> if (title.isEmpty()) title = it.attr(CONTENT) + Field.DESCRIPTION -> if (description.isEmpty()) description = it.attr(CONTENT) + Field.IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT) + Field.AUDIO -> if (audio.isEmpty()) audio = it.attr(CONTENT) + Field.AUDIO_TYPE -> if (audioType.isEmpty()) audioType = it.attr(CONTENT) + Field.VIDEO -> if (video.isEmpty()) video = it.attr(CONTENT) + Field.VIDEO_TYPE -> if (videoType.isEmpty()) videoType = it.attr(CONTENT) + Field.TYPE -> if (type.isEmpty()) type = it.attr(CONTENT) + null -> Unit } } - return Result(title, description, image) + return Result(title, description, image, audio, audioType, video, videoType, type) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt index 23648af539..c0b2826c9b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.privacylock -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -174,23 +171,3 @@ class PrivacyLockState( idleTimerJob = null } } - -/** - * Provided once at the App composition root. Map keyed by [LockScope]; every - * scope must have an entry (see [lockStateFor] which throws when missing). - */ -val LocalPrivacyLockState = - compositionLocalOf> { - error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider") - } - -/** - * Convenience accessor used inside gate composables. Reads the map from the - * ambient [LocalPrivacyLockState] and returns the state holder for [scope]. - * Throws if the scope was not registered at the App root. - */ -@Composable -@ReadOnlyComposable -fun lockStateFor(scope: LockScope): PrivacyLockState = - LocalPrivacyLockState.current[scope] - ?: error("PrivacyLockState for $scope not registered at App root") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index 2260b279a5..8f97e89244 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -86,6 +88,26 @@ class FeedMetadataCoordinator( private val inFlightBatchedMetadata = mutableSetOf() private val inFlightBatchedKind3 = mutableSetOf() + // The six sets above are touched from the caller's thread (Compose scroll + // callbacks) and from the batched REQ coroutines on IO; a plain HashSet + // corrupts under that interleaving. Every read-filter-then-add goes + // through [claim] or an explicit lock. + private val queueLock = KmpLock() + + /** + * Atomically returns the members of [candidates] not yet in [set] and marks + * them, so two callers racing on the same pubkeys cannot both claim them. + */ + private fun claim( + set: MutableSet, + candidates: Collection, + ): List = + queueLock.withLock { + val fresh = candidates.filter { it !in set }.distinct() + set.addAll(fresh) + fresh + } + /** * Start processing the subscription queue. * Call once when coordinator is created. @@ -142,13 +164,9 @@ class FeedMetadataCoordinator( .mapNotNull { it.event } .flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } } - val allReferencedIds = - (repostBoostedIds + quotedNoteIds) - .filter { it !in queuedBoostedIds } - .distinct() + val allReferencedIds = claim(queuedBoostedIds, repostBoostedIds + quotedNoteIds) if (allReferencedIds.isNotEmpty()) { - queuedBoostedIds.addAll(allReferencedIds) val referencedFilter = Filter( ids = allReferencedIds, @@ -161,23 +179,13 @@ class FeedMetadataCoordinator( } // Extract unique authors that we haven't already queued - val authors = - notes - .mapNotNull { it.author?.pubkeyHex } - .filter { it !in queuedPubkeys } - .distinct() + val authors = claim(queuedPubkeys, notes.mapNotNull { it.author?.pubkeyHex }) // Extract unique note IDs that we haven't already queued - val noteIds = - notes - .map { it.idHex } - .filter { it !in queuedNoteIds } - .distinct() + val noteIds = claim(queuedNoteIds, notes.map { it.idHex }) // Queue metadata first (highest priority) if (authors.isNotEmpty()) { - queuedPubkeys.addAll(authors) - // Use preloader if available for rate-limited loading if (preloader != null) { notes.mapNotNull { it.author }.forEach { user -> @@ -201,8 +209,6 @@ class FeedMetadataCoordinator( // Queue reactions second (lower priority) if (noteIds.isNotEmpty()) { - queuedNoteIds.addAll(noteIds) - val reactionsFilter = Filter( kinds = listOf(ReactionEvent.KIND), @@ -221,11 +227,9 @@ class FeedMetadataCoordinator( * Useful for loading follower/following metadata. */ fun loadMetadataForPubkeys(pubkeys: List) { - val newPubkeys = pubkeys.filter { it !in queuedPubkeys } + val newPubkeys = claim(queuedPubkeys, pubkeys) if (newPubkeys.isEmpty()) return - queuedPubkeys.addAll(newPubkeys) - val filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -243,11 +247,9 @@ class FeedMetadataCoordinator( * Load reactions for specific note IDs. */ fun loadReactionsForNotes(noteIds: List) { - val newNoteIds = noteIds.filter { it !in queuedNoteIds } + val newNoteIds = claim(queuedNoteIds, noteIds) if (newNoteIds.isEmpty()) return - queuedNoteIds.addAll(newNoteIds) - val filter = Filter( kinds = listOf(ReactionEvent.KIND), @@ -274,22 +276,29 @@ class FeedMetadataCoordinator( timeoutMs: Long = 5_000L, ) { val newPubkeys = - pubkeys - .asSequence() - .filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata } - .distinct() - .toList() + queueLock.withLock { + pubkeys + .asSequence() + .filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata } + .distinct() + .toList() + .also { inFlightBatchedMetadata.addAll(it) } + } if (newPubkeys.isEmpty()) return - inFlightBatchedMetadata.addAll(newPubkeys) scope.launch { - val filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = newPubkeys.take(100), - limit = newPubkeys.size, - ) - val filterMap = indexRelays.associateWith { listOf(filter) } + // One filter per 100 authors, like loadKind3Batched: a single + // `take(100)` filter used to mark the whole list as asked-for while + // only ever requesting the first hundred. + val filters = + newPubkeys.chunked(100).map { chunk -> + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = chunk, + limit = chunk.size, + ) + } + val filterMap = indexRelays.associateWith { filters } val subId = newSubId() val gate = BatchEoseGate(scope, target = indexRelays.size) @@ -316,10 +325,12 @@ class FeedMetadataCoordinator( val eosedRelays = gate.awaitAll(timeoutMs) client.unsubscribe(subId) - if (eosedRelays > 0) { - queuedPubkeys.addAll(newPubkeys) + queueLock.withLock { + if (eosedRelays > 0) { + queuedPubkeys.addAll(newPubkeys) + } + inFlightBatchedMetadata.removeAll(newPubkeys.toSet()) } - inFlightBatchedMetadata.removeAll(newPubkeys.toSet()) } } @@ -345,16 +356,18 @@ class FeedMetadataCoordinator( onEose: () -> Unit = {}, ) { val newPubkeys = - pubkeys - .asSequence() - .filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 } - .distinct() - .toList() + queueLock.withLock { + pubkeys + .asSequence() + .filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 } + .distinct() + .toList() + .also { inFlightBatchedKind3.addAll(it) } + } if (newPubkeys.isEmpty()) { onEose() return } - inFlightBatchedKind3.addAll(newPubkeys) scope.launch { val filters = @@ -392,10 +405,12 @@ class FeedMetadataCoordinator( val eosedRelays = gate.awaitAll(timeoutMs) client.unsubscribe(subId) - if (eosedRelays > 0) { - queuedKind3Pubkeys.addAll(newPubkeys) + queueLock.withLock { + if (eosedRelays > 0) { + queuedKind3Pubkeys.addAll(newPubkeys) + } + inFlightBatchedKind3.removeAll(newPubkeys.toSet()) } - inFlightBatchedKind3.removeAll(newPubkeys.toSet()) onEose() } @@ -406,11 +421,14 @@ class FeedMetadataCoordinator( */ fun clear() { priorityQueue.clear() - queuedPubkeys.clear() - queuedNoteIds.clear() - queuedKind3Pubkeys.clear() - inFlightBatchedMetadata.clear() - inFlightBatchedKind3.clear() + queueLock.withLock { + queuedPubkeys.clear() + queuedNoteIds.clear() + queuedBoostedIds.clear() + queuedKind3Pubkeys.clear() + inFlightBatchedMetadata.clear() + inFlightBatchedKind3.clear() + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt index 12bb81893d..9f8b1a9d1b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -54,7 +54,7 @@ class MetadataFilterAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode() + override fun distinct(key: MetadataQueryState): Any = key.pubkeys override fun updateFilter( keys: List, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt index d897f238e8..66ee002cfc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt @@ -52,7 +52,7 @@ class ReactionsFilterAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode() + override fun distinct(key: ReactionsQueryState): Any = key.noteIds override fun updateFilter( keys: List, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt index fefc949992..dbdd18880b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.discover +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter @@ -27,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.Discove import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DvmHeartbeatSources import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 16e0f9f435..133741660c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -63,6 +63,18 @@ val PostsAndChatMessagesToAddresses = LiveActivitiesChatMessageEvent.KIND, ) +/** + * NIP-22 keeps the conversation root in the **uppercase** `A` tag and only the direct + * parent in lowercase `a`. A comment on a comment of an article therefore never carries + * `a`=
, so an `a`-only engagement filter sees only the first level of that + * 1111 thread — the rest used to appear only once ThreadScreen opened its own `A` + * subscription. + */ +val RootScopedRepliesToAddressesKinds = + listOf( + CommentEvent.KIND, + ) + val DeletionKindList = listOf( DeletionEvent.KIND, @@ -142,6 +154,22 @@ fun filterRepliesAndReactionsToAddresses( limit = 1000, ), ), + // Its own filter on purpose: tag names inside one filter are ANDed, so folding + // "A" into the "a" filter above would only match comments carrying both. + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.ENGAGEMENT, + accountPubKeys = listOfNotNull(accountPubKey), + kinds = RootScopedRepliesToAddressesKinds, + tags = mapOf("A" to sortedList), + since = since, + // Matches the ceiling of the direct-reply filter above: this is the + // rest of the same thread, not a second conversation. + limit = 1000, + ), + ), ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt index e6203a58c6..11c723a879 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -71,6 +71,22 @@ val RepliesAndReactionsKinds = AttestationEvent.KIND, ) +/** + * NIP-22 keeps the conversation root in the **uppercase** `E` tag and only the direct + * parent in lowercase `e`. A comment two or more levels deep therefore never carries + * `e`=, so an `e`-only engagement filter sees only the first level of a + * 1111 thread — the rest used to appear only once ThreadScreen opened its own `E` + * subscription. These kinds are the ones that anchor themselves with `E`. + */ +val RootScopedRepliesKinds = + listOf( + CommentEvent.KIND, + // NIP-34 kind 1619 carries the PR it revises in `E` and has no lowercase + // `e` at all (see the spec's PR Update example), so this filter is the + // only engagement route to a PR's revision chain. + GitPullRequestUpdateEvent.KIND, + ) + val RepliesAndReactionsKinds2 = listOf( DeletionEvent.KIND, @@ -78,12 +94,12 @@ val RepliesAndReactionsKinds2 = NIP90StatusEvent.KIND, TorrentCommentEvent.KIND, GitReplyEvent.KIND, - // NIP-34 PR revision (1619) and status events (1630/1631/1632/1633). - // Rooted at the target patch/PR/issue via a `root`-marked `e` tag, so - // an `e=` engagement fetch surfaces the PR's revision chain - // and every open/applied/closed/draft transition — the signal - // GitStatusIndex needs to answer isClosedOrResolved() for repo rows. - GitPullRequestUpdateEvent.KIND, + // NIP-34 status events (1630/1631/1632/1633). Rooted at the target + // patch/PR/issue via a `root`-marked `e` tag, so an `e=` + // engagement fetch surfaces every open/applied/closed/draft transition — + // the signal GitStatusIndex needs to answer isClosedOrResolved() for + // repo rows. PR revisions (1619) anchor with `E` instead and are pulled + // by [RootScopedRepliesKinds]; listing them here matched nothing. GitStatusOpenEvent.KIND, GitStatusAppliedEvent.KIND, GitStatusClosedEvent.KIND, @@ -147,6 +163,22 @@ fun filterRepliesAndReactionsToNotes( limit = 100, ), ), + // Its own filter on purpose: tag names inside one filter are ANDed, so folding + // "E" into the "e" filter above would only match comments carrying both. + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.ENGAGEMENT, + accountPubKeys = listOfNotNull(accountPubKey), + kinds = RootScopedRepliesKinds, + tags = mapOf("E" to sortedList), + since = since, + // Matches the ceiling of the direct-reply filter above: this is the + // rest of the same thread, not a second conversation. + limit = 1000, + ), + ), RelayBasedFilter( relay = relay, filter = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeFilterAssembler.kt index 388be70d53..ff0319f90f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeFilterAssembler.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.home import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.HomeFeedType import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet @@ -28,7 +29,6 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.home.nip65Follows.HomeOutboxEventsEoseManager import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt index a7469a2cb4..7e8274121c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.preload +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * Global rate limiter for metadata requests to prevent thundering herd on fast scroll. @@ -36,16 +38,25 @@ import kotlinx.coroutines.launch class MetadataRateLimiter( private val maxRequestsPerSecond: Int = 20, private val scope: CoroutineScope, + /** How long a partial batch may wait for more pubkeys before it is flushed. */ + private val flushDelayMs: Long = 250, ) { private val queue = Channel(Channel.BUFFERED) + + // Written from enqueue() (feed/scroll path, any thread) and from the + // collector coroutine — a plain HashSet corrupts under that. + private val processedLock = KmpLock() private val processed = mutableSetOf() + /** Returns true when [pubkey] was not processed before (and marks it). */ + private fun markProcessed(pubkey: String) = processedLock.withLock { processed.add(pubkey) } + /** * Enqueue a pubkey for metadata fetching. * Duplicates within the same batch are automatically filtered. */ fun enqueue(pubkey: String) { - if (pubkey !in processed) { + if (!isProcessed(pubkey)) { queue.trySend(pubkey) } } @@ -64,24 +75,28 @@ class MetadataRateLimiter( fun start(onRequest: suspend (String) -> Unit) { scope.launch { val batch = mutableListOf() - queue.consumeAsFlow().collect { pubkey -> - if (pubkey !in processed) { + while (true) { + // A partial batch must not wait for the channel to close (it + // never does): flush it once the queue has been quiet for + // flushDelayMs, otherwise fewer than maxRequestsPerSecond + // pubkeys would never be requested at all. + val pubkey = + if (batch.isEmpty()) { + queue.receive() + } else { + withTimeoutOrNull(flushDelayMs) { queue.receive() } + } + + if (pubkey != null && markProcessed(pubkey)) { batch.add(pubkey) - processed.add(pubkey) } - // Process batch when we hit the limit or queue is empty - if (batch.size >= maxRequestsPerSecond) { + if (batch.size >= maxRequestsPerSecond || (pubkey == null && batch.isNotEmpty())) { processBatch(batch, onRequest) batch.clear() delay(1000) // Wait 1 second before next batch } } - - // Process remaining - if (batch.isNotEmpty()) { - processBatch(batch, onRequest) - } } } @@ -99,11 +114,11 @@ class MetadataRateLimiter( * Call this when switching accounts or clearing cache. */ fun reset() { - processed.clear() + processedLock.withLock { processed.clear() } } /** * Check if a pubkey has already been processed. */ - fun isProcessed(pubkey: String): Boolean = pubkey in processed + fun isProcessed(pubkey: String): Boolean = processedLock.withLock { pubkey in processed } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/SoftwareAppsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/SoftwareAppsFilterAssembler.kt index 556b379a9a..a232b646d8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/SoftwareAppsFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/SoftwareAppsFilterAssembler.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.softwareapps +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/topNavFeeds/TopNavFeedQueryState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/topNavFeeds/TopNavFeedQueryState.kt index cccea29ddd..579aa2c05b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/topNavFeeds/TopNavFeedQueryState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/topNavFeeds/TopNavFeedQueryState.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.AccountScopedQuery -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt index e72592cb44..b66ee57ab4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import androidx.compose.foundation.layout.ExperimentalLayoutApi import kotlinx.collections.immutable.toImmutableList data class ParagraphImageAnalysis( @@ -106,7 +105,6 @@ class GalleryParser { return imageParagraphs to j } - @OptIn(ExperimentalLayoutApi::class) fun processParagraphs(paragraphs: List): List { val result = mutableListOf() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index f4ec7e1d68..bc7b83c218 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -479,7 +479,7 @@ class RichTextParser { val noProtocolUrlValidator = Regex( - "(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)", + "(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.][a-zA-Z0-9_-]+)*\\/?)(.*)", ) val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") @@ -498,6 +498,10 @@ class RichTextParser { val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "m3u8") + audioExt val pdfExt = listOf("pdf") + // The lower+UPPER doubled forms, kept for callers that need the literal spellings — the + // `data:image/(png|PNG|...)` alternation in [Patterns.BASE64_IMAGE], for one. Do NOT reach + // for these to match a URL: [hasExtensionIn] compares case-insensitively, so the doubled + // list only doubles the work, and it still misses mixed case like `.Mp3`. val imageExtensions = imageExt + imageExt.map { it.uppercase() } val videoExtensions = videoExt + videoExt.map { it.uppercase() } val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() } @@ -513,39 +517,72 @@ class RichTextParser { it.uppercase() } - private fun removeQueryParamsForExtensionComparison(fullUrl: String): String { - // Called per URL during feed render — substringBefore allocates nothing when the - // separator is absent, unlike split(). - val queryStart = fullUrl.indexOf('?') - if (queryStart >= 0) return fullUrl.substring(0, queryStart) - return fullUrl.substringBefore('#') + /** Index just past the URL's path — the first `?` or `#`, or the whole length. */ + private fun pathEndIndex(url: String): Int { + val queryStart = url.indexOf('?') + if (queryStart >= 0) return queryStart + val fragmentStart = url.indexOf('#') + return if (fragmentStart >= 0) fragmentStart else url.length } - fun isImageExtension(ext: String) = imageExtensions.any { it == ext } - - fun isImageOrVideoExtension(ext: String) = imageExtensions.any { it == ext } || videoExtensions.any { it == ext } - - fun isImageOrVideoUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - - return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || - videoExtensions.any { removedParamsFromUrl.endsWith(it) } + /** + * Whether the last path segment of [url] ends in one of [extensions], as a real + * dot-introduced file extension. + * + * **The dot is mandatory**, and that is the whole point of this function. The substring + * test it replaced (`url.endsWith("mp3")` after the query was trimmed) matched any path + * that merely ended in an extension's letters. nostr.build serves an HTML *player page* at + * `https://e.nostr.build/a__mp3?t=...` — underscore, not dot — and that page was + * classified as audio and handed to ExoPlayer, which can only fail on `text/html`; the + * accidental match also stole the link preview that would otherwise have rendered the + * page's OpenGraph card. Ordinary prose slugs (`.../my-thoughts-on-mp3`) were misrouted + * the same way. + * + * Matching is case-insensitive, so a mixed-case `.Mp3` — which the doubled + * lower/UPPER extension lists never covered — now resolves too. [extensions] may be + * spelled with or without the leading dot (`"mp4"` and `".mp4"` both work), because both + * spellings reach here: this module's lists are bare, while the video-feed filters' + * `SupportedContent` has historically been handed either. + * + * Called per URL during feed render, so it allocates nothing: the path end and the + * extension boundary are located by index and compared in place. + */ + fun hasExtensionIn( + url: String, + extensions: Collection, + ): Boolean { + val end = pathEndIndex(url) + var i = end - 1 + while (i >= 0) { + val c = url[i] + // A `/` before any `.` means the last path segment has no extension. This is also + // what keeps the dot in a host name (`https://e.nostr.build/a_x_mp3`) from being + // read as the start of one. + if (c == '/') return false + if (c == '.') { + val start = i + 1 + val length = end - start + return extensions.any { + val from = if (it.startsWith('.')) 1 else 0 + it.length - from == length && url.regionMatches(start, it, from, length, ignoreCase = true) + } + } + i-- + } + return false } - fun isImageUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - return imageExtensions.any { removedParamsFromUrl.endsWith(it) } - } + fun isImageExtension(ext: String) = imageExt.any { it.equals(ext, ignoreCase = true) } - fun isVideoUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - return videoExtensions.any { removedParamsFromUrl.endsWith(it) } - } + fun isImageOrVideoExtension(ext: String) = isImageExtension(ext) || videoExt.any { it.equals(ext, ignoreCase = true) } - fun isAudioUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - return audioExtensions.any { removedParamsFromUrl.endsWith(it) } - } + fun isImageOrVideoUrl(url: String): Boolean = isImageUrl(url) || isVideoUrl(url) + + fun isImageUrl(url: String): Boolean = hasExtensionIn(url, imageExt) + + fun isVideoUrl(url: String): Boolean = hasExtensionIn(url, videoExt) + + fun isAudioUrl(url: String): Boolean = hasExtensionIn(url, audioExt) // A declared MIME type is authoritative when present; the URL extension is only a fallback // for the common bare-URL case. @@ -565,10 +602,7 @@ class RichTextParser { mimeType.equals("audio/mpegurl", ignoreCase = true) } - fun isPdfUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } - } + fun isPdfUrl(url: String): Boolean = hasExtensionIn(url, pdfExt) /** * Resolves which renderer can display a declared blob — the single decision every media @@ -603,10 +637,9 @@ class RichTextParser { if (url.startsWith("data:")) return null - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.IMAGE - if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.VIDEO - if (pdfExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.PDF + if (hasExtensionIn(url, imageExt)) return MediaContentKind.IMAGE + if (hasExtensionIn(url, videoExt)) return MediaContentKind.VIDEO + if (hasExtensionIn(url, pdfExt)) return MediaContentKind.PDF return null } @@ -614,9 +647,8 @@ class RichTextParser { fun isValidURL(url: String?): Boolean = isValidUrl(url) fun parseImageOrVideo(fullUrl: String): BaseMediaContent { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + val isImage = hasExtensionIn(fullUrl, imageExt) + val isVideo = hasExtensionIn(fullUrl, videoExt) return if (isImage) { MediaUrlImage(fullUrl) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index 54425f800f..0c1884976f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.commons.search import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.currentTimeSeconds @@ -46,7 +48,7 @@ object SearchResultSorter { var score = 0.0 val content = event.content.lowercase() - val tokens = query.split("\\s+".toRegex()) + val tokens = query.split(WHITESPACE) // Exact phrase match in content if (content.contains(query)) { @@ -56,8 +58,7 @@ object SearchResultSorter { // Per-token scoring for (token in tokens) { if (token.isEmpty()) continue - val wordBoundary = "\\b${Regex.escape(token)}\\b".toRegex() - if (wordBoundary.containsMatchIn(content)) { + if (wordBoundaryRegex(token).containsMatchIn(content)) { score += 5.0 } else if (content.contains(token)) { score += 2.0 @@ -88,4 +89,20 @@ object SearchResultSorter { return score } + + private val WHITESPACE = Regex("\\s+") + + // scoreEvent runs once per result with the same query tokens; compiling a + // Pattern per (event, token) pair made a 500-result sort build thousands. + // Small, lock-guarded (sorts can run from several search coroutines). + private val cacheLock = KmpLock() + private val wordBoundaryCache = mutableMapOf() + + private fun wordBoundaryRegex(token: String): Regex = + cacheLock.withLock { + wordBoundaryCache[token] ?: Regex("\\b${Regex.escape(token)}\\b").also { + if (wordBoundaryCache.size > 64) wordBoundaryCache.clear() + wordBoundaryCache[token] = it + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt index d094da60ab..a5d38a3742 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt @@ -24,12 +24,14 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -119,7 +121,11 @@ class UserSearchEngine( _localResults.value = emptyList() _isSearching.value = false } - }.launchIn(scope) + } + // findUsersStartingWith walks the whole user cache; callers pass a + // Compose scope, so keep the scan off the main dispatcher. + .flowOn(Dispatchers.Default) + .launchIn(scope) } private fun startRelaySearch(text: String) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt index 79ff14f824..773b0544c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt @@ -152,26 +152,35 @@ class BasicBundledInsert( isProcessing = true } - processLoop@ while (true) { - val batch = - mutex.withLock { - if (queue.isEmpty()) { - isProcessing = false - null - } else { - val items = queue.toSet() - queue.clear() - items + try { + processLoop@ while (true) { + val batch = + mutex.withLock { + if (queue.isEmpty()) { + isProcessing = false + null + } else { + val items = queue.toSet() + queue.clear() + items + } } + + if (batch == null) break@processLoop + + if (batch.isNotEmpty()) { + onUpdate(batch) } - if (batch == null) break@processLoop - - if (batch.isNotEmpty()) { - onUpdate(batch) + delay(delay) + } + } finally { + // Mirrors BasicBundledUpdate: a throw out of onUpdate (or a + // cancellation) must not leave isProcessing stuck at true, or + // every later invalidateList() would enqueue and return forever. + withContext(NonCancellable) { + mutex.withLock { isProcessing = false } } - - delay(delay) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt index e15935bfee..2f75de6084 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt @@ -103,7 +103,9 @@ class BlossomReadAuthTokenProvider( /** * Returns the in-flight signature for [host], starting one if this caller - * wins the race. Null when there is no signer to sign with. + * wins the race — or an already-completed one carrying the token a leader + * cached while this caller was on its way in. Null when there is no signer + * to sign with. * * Leader/follower over [ConcurrentHashMap.putIfAbsent] rather than * `computeIfAbsent`: the completion handler removes the map entry, and a job @@ -113,11 +115,31 @@ class BlossomReadAuthTokenProvider( private fun signOnce(host: String): CompletableDeferred? { inFlight[host]?.let { return it } + // Second look at the cache, because the caller's own miss happened before this + // read and a leader caches its token *before* retiring its [inFlight] entry — so + // an absent entry here means any token that leader minted is already visible. + // Without this look, a signature fast enough to finish inside that gap (a local + // key signs in microseconds) loses the single-flight guarantee: every straggler + // still between its cache miss and this read finds both empty and signs again, + // which is the N-signatures burst [inFlight] exists to collapse. + cachedHeader(host)?.let { return CompletableDeferred(it) } + val signer = signerProvider() ?: return null val fresh = CompletableDeferred() inFlight.putIfAbsent(host, fresh)?.let { return it } + // Third look, now that this caller owns the slot. The look above still leaves a + // gap: a leader can insert, sign, cache and retire its entry entirely between + // that read and the putIfAbsent, so the map is empty again and this caller wins + // it. Any leader that retired before this insert cached first, so a token + // present now is theirs — take it and give the slot back instead of re-signing. + cachedHeader(host)?.let { + inFlight.remove(host, fresh) + fresh.complete(it) + return fresh + } + scope .launch { val header = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt index c357e72d0d..6a7a8e6235 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt @@ -173,11 +173,14 @@ class EventCollectionState( get() = _items.value.isEmpty() /** - * Schedules a batched update if not already scheduled. - * Cancels existing batch job and starts a new one. + * Schedules a batched update if not already scheduled (leading-edge + * throttle). Cancelling and restarting the timer on every insert instead + * would starve the flush on a busy feed: items arriving more often than + * [batchDelayMs] would keep resetting the delay and the screen would never + * update until the stream paused. */ private fun scheduleBatchUpdate() { - batchJob?.cancel() + if (batchJob?.isActive == true) return batchJob = scope.launch { delay(batchDelayMs) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt index 94f00b7a92..78be9916d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.viewmodels +import com.vitorpamplona.amethyst.commons.feeds.ChatroomFeedFilter import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey class ChatroomFeedViewModel( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt index 172614a573..65292e8702 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -68,6 +68,5 @@ abstract class FeedViewModel( override fun onCleared() { Log.d("Init") { "OnCleared: ${this::class.simpleName}" } - super.onCleared() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt index d4dc7e0a78..3cc822cede 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.ChangesFlowFilter +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 5fe963a9cd..9c698f36d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.utils.toLongValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -153,8 +154,10 @@ class LiveStreamTopZappersViewModel( } } - private fun publish() { - val merged = streamContributions.values + goalContributions.values + private suspend fun publish() { + // Snapshot both maps under the same mutex their writers hold; the two + // collectors (channel changes, goal zaps) run on different coroutines. + val merged = mutex.withLock { streamContributions.values + goalContributions.values } _topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit) } @@ -162,7 +165,7 @@ class LiveStreamTopZappersViewModel( when (val ev = note.event) { is LnZapEvent -> { val request = ev.zapRequest ?: return null - val sats = ev.amount()?.toLong() ?: return null + val sats = ev.amount()?.toLongValue() ?: return null ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) } is Bolt12ZapEvent -> { @@ -178,7 +181,7 @@ class LiveStreamTopZappersViewModel( ): ZapContribution? { val receiptEv = receiptNote?.event as? LnZapEvent ?: return null val request = zapRequestNote.event as? LnZapRequestEvent ?: return null - val sats = receiptEv.amount()?.toLong() ?: return null + val sats = receiptEv.amount()?.toLongValue() ?: return null return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt index 4f0243ef0d..689e932928 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.viewmodels +import com.vitorpamplona.amethyst.commons.feeds.MarmotGroupFeedFilter import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupList -import com.vitorpamplona.amethyst.commons.ui.feeds.MarmotGroupFeedFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 8568f793f4..ce0267fb9e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.utils.toLongValue /** * One in-flight kind-9735 zap to render as a floating overlay on the @@ -59,7 +60,7 @@ data class RoomZap( eventId = event.id, sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey, targetPubkey = event.zappedAuthor().firstOrNull(), - amountSats = event.amount?.toLong(), + amountSats = event.amount?.toLongValue(), createdAtSec = event.createdAt, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt index 657758c47a..c8dfef6af0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt @@ -25,11 +25,13 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.search.SearchResult import com.vitorpamplona.amethyst.commons.search.parseSearchInput import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -92,7 +94,11 @@ class SearchBarState( } else { _cachedUserResults.value = emptyList() } - }.launchIn(scope) + } + // The scan walks every cached user; callers hand us a Compose scope, + // so keep it off the main dispatcher. + .flowOn(Dispatchers.Default) + .launchIn(scope) } fun updateSearchText(text: String) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/ThreadFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/ThreadFeedFilter.kt index 021d5e8b9a..97e9c3aa34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/ThreadFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/ThreadFeedFilter.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.amethyst.commons.viewmodels.thread import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.LevelSignature import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.model.ThreadLevelCalculator import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableSet diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83Test.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83Test.kt new file mode 100644 index 0000000000..5b022d9aa7 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83Test.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.blurhash + +import kotlin.test.Test +import kotlin.test.assertEquals + +class Base83Test { + @Test + fun charactersOutsideLatin1DecodeAsZeroInsteadOfThrowing() { + // The blurhash string comes from an attacker-controlled imeta tag. + assertEquals(0, Base83.decodeAt("€")) + assertEquals(0, Base83.decode("€￿")) + assertEquals(Base83.decodeFixed2("00"), Base83.decodeFixed2("€€")) + } + + @Test + fun alphabetRoundTrips() { + Base83.ALPHABET.forEachIndexed { i, c -> assertEquals(i, Base83.decodeAt(c.toString())) } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCacheTest.kt new file mode 100644 index 0000000000..fc3d251639 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCacheTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.blurhash + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse + +class CosineCacheTest { + @Test + fun sameProductDifferentShapeGetsDifferentTables() { + CosineCache.clearCache() + // (50, 4) and (100, 2) both have 200 entries; the old product key served + // whichever was computed first to both. + val a = CosineCache.getArrayForCosinesX(useCache = true, width = 50, numCompX = 4) + val b = CosineCache.getArrayForCosinesX(useCache = true, width = 100, numCompX = 2) + assertFalse(a.contentEquals(b)) + assertContentEquals(CosineCache.getArrayForCosinesX(useCache = false, width = 100, numCompX = 2), b) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RepostRenderabilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/RepostRenderabilityTest.kt similarity index 98% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RepostRenderabilityTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/RepostRenderabilityTest.kt index 319866c26a..a9f946b68e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RepostRenderabilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/RepostRenderabilityTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feeds +package com.vitorpamplona.amethyst.commons.feeds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNoteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt similarity index 97% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNoteTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt index 5331ac9187..6e295c5080 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNoteTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.note +package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt similarity index 96% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt index 88c2a79113..4d1e1a6555 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.note +package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test import kotlin.test.assertEquals diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StubCache.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt similarity index 91% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StubCache.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt index 4785fd6f75..3d0774767d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StubCache.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt @@ -18,12 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.note +package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParserTest.kt index 528584b4f5..1e6d1271b4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParserTest.kt @@ -142,4 +142,140 @@ class OpenGraphParserTest { assertEquals("", info.description) assertEquals("", info.image) } + + @Test + fun readsTheAudioFileAPlayerPageDeclares() { + // Trimmed from the real document at + // https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3?t=Aria&by=The+Fishcake -- an HTML + // player page (Content-Type: text/html) whose `og:audio` is the actual mp3. Note that + // `og:audio` comes AFTER title, description and image: an early exit that stops once + // those three are filled never reaches it. + val info = + extract( + """ + | + | + | + | + | + | + | + | + | + """.trimMargin(), + ) + + assertEquals("Aria by The Fishcake", info.title) + assertEquals("https://a.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp3", info.audio) + assertEquals("audio/mpeg", info.audioType) + assertEquals("music.song", info.type) + } + + @Test + fun prefersOgAudioOverTheSecureUrlThatFollowsIt() { + val info = + extract( + """ + | + | + | + | + """.trimMargin(), + ) + + assertEquals("https://example.com/first.mp3", info.audio) + } + + @Test + fun fallsBackToTheSecureUrlWhenItIsTheOnlyOne() { + val info = extract("""""") + + assertEquals("https://example.com/a.mp3", info.audio) + } + + @Test + fun aPageWithoutAudioReportsEmptyAudioFields() { + val info = extract("""""") + + assertEquals("", info.audio) + assertEquals("", info.audioType) + } + + @Test + fun readsTheVideoFileAPlayerPageDeclares() { + // The mirror of the audio page above, from the same host: + // https://e.nostr.build/v__mp4 -- `og:type` flips to video.other and the media pair + // becomes og:video/og:video:type. + val info = + extract( + """ + | + | + | + | + | + | + | + | + | + | + """.trimMargin(), + ) + + assertEquals("https://v.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp4", info.video) + assertEquals("video/mp4", info.videoType) + assertEquals("video.other", info.type) + assertEquals("", info.audio) + } + + @Test + fun readsWhatThePageSaysItIs() { + // og:type is what separates a media host's player page from an article that embeds a clip; + // only the former should have its card replaced by a player. + assertEquals("music.song", extract("""""").type) + assertEquals("article", extract("""""").type) + assertEquals("", extract("""""").type) + } + + @Test + fun readsTheUrlAliasForHostsThatOnlyEmitIt() { + // YouTube writes og:video:url and never the bare og:video. Parsing it is not trusting it + // -- see UrlInfoItemMediaTest, where this exact declaration is refused on its text/html + // type -- but a host that spells it this way with a real file must still be readable. + val info = + extract( + """ + | + | + | + | + """.trimMargin(), + ) + + assertEquals("https://www.youtube.com/embed/dQw4w9WgXcQ", info.video) + assertEquals("text/html", info.videoType) + } + + @Test + fun mediaTagsAreReadEvenWhenTheyTrailACompleteCard() { + // The regression the removed early exit caused: title, description and image are all + // filled before og:audio/og:video appear, so stopping there skipped the media entirely. + val info = + extract( + """ + | + | + | + | + | + | + | + | + | + """.trimMargin(), + ) + + assertEquals("https://example.com/a.mp3", info.audio) + assertEquals("https://example.com/v.mp4", info.video) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotesTest.kt new file mode 100644 index 0000000000..a69cd9a6a6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotesTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FilterRepliesAndReactionsToNotesTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://one.example")!! + private val rootId = "a".repeat(64) + private val author = "b".repeat(64) + + private fun note(id: String) = Note(id).apply { addRelaySync(relay) } + + private fun addressable(address: Address) = AddressableNote(address).apply { addRelaySync(relay) } + + @Test + fun asksForNip22RootScopeSoNestedCommentsLoadOutsideTheThreadScreen() { + val filters = filterRepliesAndReactionsToNotes(listOf(note(rootId)), null)!! + + val rootScope = filters.filter { it.filter.tags?.containsKey("E") == true } + + assertEquals(1, rootScope.size) + assertEquals( + listOf(rootId), + rootScope + .single() + .filter.tags + ?.get("E"), + ) + assertTrue( + rootScope + .single() + .filter.kinds!! + .contains(CommentEvent.KIND), + ) + // The uppercase scope must be its own REQ: tag names inside one filter are ANDed, + // so merging `e` and `E` would only match comments carrying both. + assertTrue(filters.none { it.filter.tags?.containsKey("e") == true && it.filter.tags?.containsKey("E") == true }) + } + + @Test + fun asksForNip22RootAddressScopeOnAddressables() { + val address = Address(30023, author, "article") + val filters = filterRepliesAndReactionsToAddresses(listOf(addressable(address)), null)!! + + val rootScope = filters.filter { it.filter.tags?.containsKey("A") == true } + + assertEquals(1, rootScope.size) + assertEquals( + listOf(address.toValue()), + rootScope + .single() + .filter.tags + ?.get("A"), + ) + assertTrue( + rootScope + .single() + .filter.kinds!! + .contains(CommentEvent.KIND), + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiterTest.kt new file mode 100644 index 0000000000..7a0fb84e63 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiterTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.preload + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class MetadataRateLimiterTest { + @Test + fun partialBatchIsFlushedWithoutWaitingForTheChannelToClose() = + runTest { + val scope = TestScope(testScheduler) + val requested = mutableListOf() + val limiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope, flushDelayMs = 250) + limiter.start { requested.add(it) } + + // Fewer pubkeys than one batch: the old collect-then-flush never ran. + repeat(8) { limiter.enqueue("pubkey-$it") } + scope.advanceTimeBy(1_000) + + assertEquals((0 until 8).map { "pubkey-$it" }, requested) + scope.cancel() + } + + @Test + fun duplicatesAreRequestedOnce() = + runTest { + val scope = TestScope(testScheduler) + val requested = mutableListOf() + val limiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope, flushDelayMs = 250) + limiter.start { requested.add(it) } + + limiter.enqueue("a") + limiter.enqueue("a") + scope.advanceTimeBy(2_000) + limiter.enqueue("a") + scope.advanceTimeBy(2_000) + + assertEquals(listOf("a"), requested) + scope.cancel() + } + + @Test + fun fullBatchesStillRateLimit() = + runTest { + val scope = TestScope(testScheduler) + val requested = mutableListOf() + val limiter = MetadataRateLimiter(maxRequestsPerSecond = 3, scope = scope, flushDelayMs = 250) + limiter.start { requested.add(it) } + + repeat(5) { limiter.enqueue("p$it") } + scope.advanceTimeBy(100) + assertEquals(3, requested.size, "first full batch goes out immediately") + scope.advanceTimeBy(2_000) + assertEquals(5, requested.size, "the partial remainder is flushed after the pause") + scope.cancel() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt index f8537790a1..135afb9908 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext import kotlin.test.Test import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class RichTextParserAudioUrlTest { @@ -78,4 +79,49 @@ class RichTextParserAudioUrlTest { assertTrue(RichTextParser.isAudioContent(null, "https://example.com/a.mp3")) assertFalse(RichTextParser.isAudioContent(null, "https://example.com/a.mp4")) } + + @Test + fun theExtensionMustBeIntroducedByADot() { + // The note that found this: nostr.build hands out an HTML player page whose path ends in + // `_mp3`, not `.mp3`. Verified with curl -- it answers `Content-Type: text/html`, so + // routing it to the player can only ever produce a spinner. + assertFalse(RichTextParser.isAudioUrl("https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3?t=Aria&by=The+Fishcake")) + assertNull(RichTextParser.classifyMedia("https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3?t=Aria&by=The+Fishcake", null)) + + // The file that page is about, which does have the dot. + assertTrue(RichTextParser.isAudioUrl("https://a.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp3")) + } + + @Test + fun theVideoPlayerPageIsTheSameTrap() { + // nostr.build mirrors the scheme for video: `v__mp4` is an HTML page, and the file it + // is about lives on v.nostr.build. + assertFalse(RichTextParser.isVideoUrl("https://e.nostr.build/v_ETvKzX2OdOGmFEp1avRlm5_mp4?t=Clip")) + assertNull(RichTextParser.classifyMedia("https://e.nostr.build/v_ETvKzX2OdOGmFEp1avRlm5_mp4?t=Clip", null)) + + assertTrue(RichTextParser.isVideoUrl("https://v.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp4")) + } + + @Test + fun aSlugEndingInAnExtensionsLettersIsNotMedia() { + assertFalse(RichTextParser.isAudioUrl("https://example.com/my-thoughts-on-mp3")) + assertFalse(RichTextParser.isVideoUrl("https://example.com/how-to-rip-a-webm")) + assertFalse(RichTextParser.isImageUrl("https://example.com/blog/png")) + assertNull(RichTextParser.classifyMedia("https://example.com/my-thoughts-on-mp3", null)) + } + + @Test + fun aDotInTheHostIsNotAnExtension() { + // The last dot of `https://files.mp3/download` sits before a `/`, so it opens a host + // name, not a file extension. + assertFalse(RichTextParser.isAudioUrl("https://files.mp3/download")) + assertFalse(RichTextParser.isAudioUrl("https://example.com/mp3")) + } + + @Test + fun mixedCaseExtensionsResolve() { + // The lower+UPPER doubled lists never covered these; matching case-insensitively does. + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.Mp3")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.mP3?x=1")) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlWithoutSchemeTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlWithoutSchemeTest.kt new file mode 100644 index 0000000000..54b0b6b1bd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlWithoutSchemeTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.measureTime + +class UrlWithoutSchemeTest { + @Test + fun recognisesSchemelessUrls() { + assertTrue(RichTextParser.isUrlWithoutScheme("example.com")) + assertTrue(RichTextParser.isUrlWithoutScheme("example.com/path?x=1")) + assertTrue(RichTextParser.isUrlWithoutScheme("user@example.com")) + assertTrue(RichTextParser.isUrlWithoutScheme("sub.example.com:8080/a-b_c")) + assertFalse(RichTextParser.isUrlWithoutScheme("just words")) + } + + @Test + fun pathologicalInputDoesNotBacktrackExponentially() { + // A trailing line terminator forces the regex to fail; with an optional + // separator inside the repeated group that failure took 2^n steps + // (13 s at n = 26). The composer runs this on every keystroke. + val input = "a.b" + "c".repeat(40) + "\r" + val elapsed = measureTime { RichTextParser.isUrlWithoutScheme(input) } + assertTrue(elapsed.inWholeMilliseconds < 1_000, "took $elapsed") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt index acaca77d15..c631249285 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt @@ -38,7 +38,7 @@ class PartialTokensTest { fun aHalfWrittenFromOpensThePeoplePicker() { val picker = pickerAtEnd("zaps from:ali") assertTrue(picker is ActivePicker.People) - assertEquals(KeyField.FROM, (picker as ActivePicker.People).keyField) + assertEquals(KeyField.FROM, picker.keyField) assertEquals("ali", picker.token.partial) assertEquals(5, picker.token.start) } @@ -65,7 +65,7 @@ class PartialTokensTest { fun aHalfWrittenDateOpensTheCalendar() { val picker = pickerAtEnd("since:2026-0") assertTrue(picker is ActivePicker.Calendar) - assertEquals(DateField.SINCE, (picker as ActivePicker.Calendar).dateField) + assertEquals(DateField.SINCE, picker.dateField) } @Test diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt index d67627da12..d48ac93cfd 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt @@ -34,6 +34,8 @@ actual class SecureKeyStorage private actual constructor() { actual suspend fun getPrivateKey(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + actual suspend fun getPrivateKeyOrThrow(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + actual suspend fun deletePrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") actual suspend fun hasPrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index 228b710249..68c040e065 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.preview import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import java.net.URI @Immutable @@ -30,22 +32,108 @@ class UrlInfoItem( val description: String = "", val image: String = "", val mimeType: String, + /** `og:audio` — the audio file an HTML player page is about. Empty when not declared. */ + val audio: String = "", + /** `og:audio:type` — the MIME the page declares for [audio]. Empty when not declared. */ + val audioType: String = "", + /** `og:video` — the video file an HTML player page is about. Empty when not declared. */ + val video: String = "", + /** `og:video:type` — the MIME the page declares for [video]. Empty when not declared. */ + val videoType: String = "", + /** `og:type` — what the page says it is, e.g. `music.song`, `video.other`, `article`. */ + val type: String = "", ) { val verifiedUrl = runCatching { URI(url).toURL() }.getOrNull() - val imageUrlFullPath = - if (image.startsWith("/")) { - runCatching { - verifiedUrl - ?.toURI() - ?.resolve(image) - ?.toURL() - ?.toString() - }.getOrNull() ?: image - } else { - image + + /** + * Resolves an OpenGraph URL against the page's own address, or null when it cannot be made + * into an absolute `http(s)` one. + * + * Two things are deliberate. It resolves *any* relative reference, not just root-relative + * ones: the earlier `startsWith("/")` test passed a document-relative `media/x.mp3` through + * untouched, and a relative string reaching a media player is a load that can only hang. + * And it requires http(s), so a page cannot point the local player at `file:///` -- the page + * is remote and untrusted, the scheme is the only thing standing between it and a local read. + */ + private fun resolve(path: String): String? { + if (path.isBlank()) return null + return runCatching { + val base = verifiedUrl?.toURI() + val resolved = if (base != null) base.resolve(path) else URI(path) + if (!resolved.scheme.equals("http", ignoreCase = true) && + !resolved.scheme.equals("https", ignoreCase = true) + ) { + null + } else { + resolved.toURL().toString() + } + }.getOrNull() + } + + // Falls back to the raw value so an unresolvable image degrades to "the loader shows nothing", + // which is what it did before. Only the playable gate below treats unresolvable as refusal. + val imageUrlFullPath = resolve(image) ?: image + + /** + * The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the + * renderer can actually play (a `video/` MIME, an `audio/` one, or an HLS playlist type), or + * — when the page omitted the type — a genuine media file extension. + * + * The type check is the entire safeguard, and it is not theoretical. YouTube declares + * `og:video:url` = `youtube.com/embed/` with `og:video:type` = `text/html`: an embed page, + * not a video. Playing that is the same mistake, in the same shape, as the one that made + * nostr.build's player page unplayable — an HTML document handed to ExoPlayer. `text/html` + * classifies as nothing, so it is refused here and the page falls through to its link card. + */ + private val playableVideoUrl: String? = + resolve(video) + ?.takeIf { RichTextParser.classifyMedia(it, videoType.ifEmpty { null }) == MediaContentKind.VIDEO } + + /** + * The declared `og:audio`, absolute, under the same rule: an [audioType] in the `audio/` + * family, or a genuine audio file extension when the page omitted the type. + */ + private val playableAudioUrl: String? = + resolve(audio) + ?.takeIf { RichTextParser.isAudioContent(audioType.ifEmpty { null }, it) } + + /** + * Whether the page presents itself as a player *for* this media rather than a document that + * merely embeds some. + * + * Media hosts declare `music.song` / `video.other`; a news story with a clip in it declares + * `article`. Without this distinction an `og:video` on an article would replace its whole + * card -- headline, description, host, tap-through -- with a bare player, which is a strictly + * worse rendering of an article. A page that declares no type at all is treated as a document + * and keeps its card. + */ + private val isMediaPlayerPage = + type.startsWith("music.", ignoreCase = true) || type.startsWith("video.", ignoreCase = true) + + /** + * The media file this page declares itself to be a player for, or null when it declared none + * we are willing to play. + * + * `og:video` wins a page that declares both, because it is the richer render: the audio path + * deliberately strips the picture. + */ + val playableMediaUrl: String? = if (isMediaPlayerPage) playableVideoUrl ?: playableAudioUrl else null + + /** The MIME the page declared for [playableMediaUrl], or null when it named none. */ + val playableMediaType: String? = + when { + playableMediaUrl == null -> null + playableVideoUrl != null -> videoType.ifEmpty { null } + else -> audioType.ifEmpty { null } } - fun fetchComplete(): Boolean = url.isNotEmpty() && image.isNotEmpty() + /** + * Whether the fetch produced something worth rendering. An image is the usual evidence, but a + * page that declared playable media counts too — a track page that ships no cover art would + * otherwise be thrown away as Empty and fall back to a bare link, which is exactly the player + * we went to the trouble of finding. + */ + fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null) fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt index 63f2d00d83..a3e3676e3f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.ResponseBody import okhttp3.coroutines.executeAsync class UrlPreview { @@ -63,9 +64,20 @@ class UrlPreview { ?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}") when { mimeType.type == "text" && mimeType.subtype == "html" -> { - val metaTags = HtmlParser().parseHtml(response.body.bytes(), mimeType.charset()?.name()) + val metaTags = HtmlParser().parseHtml(response.body.readPrefix(MAX_PREVIEW_BYTES), mimeType.charset()?.name()) val data = OpenGraphParser().extractUrlInfo(metaTags) - UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) + UrlInfoItem( + url, + data.title, + data.description, + data.image, + mimeType.toString(), + data.audio, + data.audioType, + data.video, + data.videoType, + data.type, + ) } mimeType.type == "image" -> { @@ -76,6 +88,16 @@ class UrlPreview { UrlInfoItem(url, image = url, mimeType = mimeType.toString()) } + // A URL with no audio extension that turns out to serve audio anyway + // (`/download?id=7`) used to land in the `else` below and throw, losing + // the note's link entirely. The renderer plays it off the MIME. `image` + // carries the URL for the same reason the video branch above does: it + // is what UrlInfoItem.fetchComplete reads to decide the fetch produced + // something renderable. + mimeType.type == "audio" -> { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } + mimeType.type == "application" && mimeType.subtype == "pdf" -> { UrlInfoItem(url, image = url, mimeType = mimeType.toString()) } @@ -91,3 +113,16 @@ class UrlPreview { } } } + +/** + * Link previews only need the `` (MetaTagsParser stops at ``), and + * the URL is attacker-controlled: any note can point at a host that streams + * `text/html` forever. Read at most [max] bytes instead of `bytes()`. + */ +private fun ResponseBody.readPrefix(max: Long): ByteArray = + source().use { src -> + src.request(max) + src.buffer.readByteArray(minOf(src.buffer.size, max)) + } + +private const val MAX_PREVIEW_BYTES = 512L * 1024L diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt index 6f4b18c3fe..92a7bc5593 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt @@ -37,8 +37,35 @@ object CachedRichTextParser { // working set plus a few other feeds' recent entries, so pre-parsed bodies survive // until the render reads them and feed switches don't thrash. Each entry is one // note's parsed segments — typically single-digit KB. - private val richTextCache = ConcurrentLruCache(500) - private val isMarkdownCache = ConcurrentLruCache(200) + // Keyed by a 32-bit hash for speed, but every hit re-checks the inputs: + // String.hashCode() is trivially collidable ("Aa"/"BB"), and serving one + // note's parsed segments (images, invoices, link cards) under another + // note's author is not an acceptable failure mode. + private class CachedParse( + val content: String, + val tagsHash: Int, + val callbackUri: String?, + val authorPubKey: String?, + val state: RichTextViewerState, + ) { + fun matches( + content: String, + tagsHash: Int, + callbackUri: String?, + authorPubKey: String?, + ) = this.tagsHash == tagsHash && + this.callbackUri == callbackUri && + this.authorPubKey == authorPubKey && + this.content == content + } + + private class CachedMarkdown( + val content: String, + val isMarkdown: Boolean, + ) + + private val richTextCache = ConcurrentLruCache(500) + private val isMarkdownCache = ConcurrentLruCache(200) private fun hashCodeCache( content: String, @@ -75,7 +102,11 @@ object CachedRichTextParser { tags: ImmutableListOfLists, callbackUri: String? = null, authorPubKey: String? = null, - ): RichTextViewerState? = richTextCache.get(hashCodeCache(content, tags, callbackUri, authorPubKey)) + ): RichTextViewerState? = + richTextCache + .get(hashCodeCache(content, tags, callbackUri, authorPubKey)) + ?.takeIf { it.matches(content, tags.contentHash(), callbackUri, authorPubKey) } + ?.state fun parseText( content: String, @@ -84,12 +115,13 @@ object CachedRichTextParser { authorPubKey: String? = null, ): RichTextViewerState { val key = hashCodeCache(content, tags, callbackUri, authorPubKey) + val tagsHash = tags.contentHash() val cached = richTextCache.get(key) - return if (cached != null) { - cached + return if (cached != null && cached.matches(content, tagsHash, callbackUri, authorPubKey)) { + cached.state } else { val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey) - richTextCache.put(key, newState) + richTextCache.put(key, CachedParse(content, tagsHash, callbackUri, authorPubKey, newState)) newState } } @@ -98,9 +130,9 @@ object CachedRichTextParser { // notes only pays for the scan once. The decision is purely a function of `content`. fun isMarkdown(content: String): Boolean { val key = content.hashCode() - isMarkdownCache.get(key)?.let { return it } + isMarkdownCache.get(key)?.takeIf { it.content == content }?.let { return it.isMarkdown } val result = computeIsMarkdown(content) - isMarkdownCache.put(key, result) + isMarkdownCache.put(key, CachedMarkdown(content, result)) return result } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptedBlobInterceptor.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptedBlobInterceptor.kt index 5e735054c0..8a99353d70 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptedBlobInterceptor.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptedBlobInterceptor.kt @@ -30,14 +30,13 @@ class EncryptedBlobInterceptor( val cache: EncryptionKeyCache, ) : Interceptor { private fun Response.decryptOrNullWithErrorCorrection(info: DecryptInformation): Response? { - val body = peekBody(Long.MAX_VALUE) - - // Only tries to decrypt if the content-type is a byte array - // if (body.contentType().toString() != "application/octet-stream") { - // return null - // } - - val bytes = body.bytes() + // The whole blob has to be in memory to decrypt it (the AEAD tag covers + // all of it), but read the body once and close it: peekBody(MAX) used to + // buffer a second full copy and left the original stream — and its + // connection-pool slot, since this is a network interceptor — open. + val body = this.body + val contentType = body.contentType() + val bytes = body.use { it.bytes() } // Tries the correct way first // if it fails, tries to decrypt as UTF8 nonce, which was how @@ -57,7 +56,7 @@ class EncryptedBlobInterceptor( .apply { body( decrypted.toResponseBody( - info.mimeType?.toMediaTypeOrNull() ?: body.contentType(), + info.mimeType?.toMediaTypeOrNull() ?: contentType, ), ) // removes hints that would make the app requrest partial byte arrays diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt index 01a9ed7b5c..0e9fd0892f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt @@ -86,6 +86,16 @@ class OkHttpClientFactory( // Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests // per host at 5, which serializes feed loading. Raise the limits so the feed // can parallelize downloads the way a browser does. + // + // Resist trimming these on intuition. `maxRequests` is effectively the thread + // ceiling (Dispatcher's executor is corePoolSize=0 / maxPoolSize=MAX_VALUE over + // a SynchronousQueue), which makes a lower number look free -- but blocked + // threads commit little, these hosts are HTTP/2 so concurrent calls to one host + // multiplex over a single connection rather than a handshake each, and + // `readyAsyncCalls` is strict FIFO with no priority. PrefetchFeedMedia enqueues + // notes BEFORE the user reaches them, so a tighter cap makes the image actually + // on screen queue behind those prefetches instead of starting straight away. + // Change these with a benchmark/ run, not a hunch. private val dispatcher = Dispatcher().apply { if (!HttpClientEnvironment.isEmulator) { @@ -138,16 +148,22 @@ class OkHttpClientFactory( .addInterceptor(OnionLocationInterceptor(onionCache)) .build() - private var lastProxy: Proxy? = null - + // No connection-pool eviction when the proxy changes. OkHttp's `Address` -- the + // pool's lookup key -- includes the proxy (`Address.equalsNonHost`), so a call + // is only ever handed a connection opened through the very same route. A + // connection left over from an old proxy is already unreachable and simply ages + // out of the pool; evicting was defensive, not load-bearing. + // + // It also cost more than it looked. `evictAll()` empties the ENTIRE shared pool, + // and this one factory mints both the proxied and the direct client (see + // [DualHttpClientManager]) -- `buildLocalSocksProxy` never returns null, so those + // two alternated a single "last proxy" field forever. Every rebuild read as a + // route change and dropped every warm connection the other client was using, on + // each network-state emission and each resubscribe. fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, ): OkHttpClient { - if (proxy != lastProxy) { - rootClient.connectionPool.evictAll() - lastProxy = proxy - } val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds return rootClient .newBuilder() @@ -162,6 +178,13 @@ class OkHttpClientFactory( .build() } + /** + * Closes every idle pooled connection. Call only on a real proxy-route change (see + * [evictOnProxyRouteChange]) — connections on a dead route are already unreachable, so this + * is hygiene, not correctness, and it empties the pool BOTH clients share. + */ + fun evictPooledConnections() = rootClient.connectionPool.evictAll() + fun buildHttpClient( localSocksProxyPort: Int?, isMobile: Boolean?, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactoryForRelays.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactoryForRelays.kt index c547a62e73..319fa34723 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactoryForRelays.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactoryForRelays.kt @@ -86,16 +86,22 @@ class OkHttpClientFactoryForRelays( .addInterceptor(OnionLocationInterceptor(onionCache)) .build() - private var lastProxy: Proxy? = null - + // No connection-pool eviction when the proxy changes. OkHttp's `Address` -- the + // pool's lookup key -- includes the proxy (`Address.equalsNonHost`), so a call + // is only ever handed a connection opened through the very same route. A + // connection left over from an old proxy is already unreachable and simply ages + // out of the pool; evicting was defensive, not load-bearing. + // + // It also cost more than it looked. `evictAll()` empties the ENTIRE shared pool, + // and this one factory mints both the proxied and the direct client (see + // [DualHttpClientManagerForRelays]) -- `buildLocalSocksProxy` never returns null, so those + // two alternated a single "last proxy" field forever. Every rebuild read as a + // route change and dropped every warm connection the other client was using, on + // each network-state emission and each resubscribe. fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, ): OkHttpClient { - if (proxy != lastProxy) { - rootClient.connectionPool.evictAll() - lastProxy = proxy - } val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds return rootClient .newBuilder() @@ -108,6 +114,13 @@ class OkHttpClientFactoryForRelays( .build() } + /** + * Closes every idle pooled connection. Call only on a real proxy-route change (see + * [evictOnProxyRouteChange]) — connections on a dead route are already unreachable, so this + * is hygiene, not correctness, and it empties the pool BOTH clients share. + */ + fun evictPooledConnections() = rootClient.connectionPool.evictAll() + fun buildHttpClient( localSocksProxyPort: Int?, isMobile: Boolean?, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OnionLocationInterceptor.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OnionLocationInterceptor.kt index 9554663538..108b22b9c2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OnionLocationInterceptor.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OnionLocationInterceptor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.service.http +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.Response @@ -47,9 +48,25 @@ class OnionLocationInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) val onionLocation = response.header("Onion-Location") - if (onionLocation != null) { + if (onionLocation != null && isOnionService(onionLocation)) { cache.put(chain.request().url.host, onionLocation) } return response } + + private companion object { + /** + * Only a `.onion` target may be cached. [OnionUrlRewriteInterceptor] + * re-points every Tor request for the host at the cached location and + * deliberately allows https → http because the Tor circuit provides the + * transport security — a guarantee that only holds for onion services. + * A clearnet (or attacker-supplied) `Onion-Location` would otherwise + * redirect LNURL/NIP-05/media traffic to an arbitrary plaintext host + * for the cache TTL. + */ + fun isOnionService(location: String): Boolean { + val host = location.toHttpUrlOrNull()?.host ?: return false + return host.endsWith(".onion", ignoreCase = true) + } + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/namecoin/NamecoinNameService.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/namecoin/NamecoinNameService.kt index c88e38e9fa..7fb2690203 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/namecoin/NamecoinNameService.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/namecoin/NamecoinNameService.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -130,8 +132,11 @@ class NamecoinNameService( } else { NamecoinResolveState.NotFound } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - state.value = NamecoinResolveState.Error(e.message ?: "Unknown error") + Log.w("NamecoinNameService", "resolve failed for $identifier", e) + state.value = NamecoinResolveState.Error(e.message ?: e::class.simpleName ?: "Unknown error") } } return state diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 6fb1a8088a..80ad79160f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -838,7 +838,6 @@ class NestViewModel( closed = true teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true) teardown(targetState = ConnectionUiState.Closed, finalCleanup = true) - super.onCleared() } private fun observeSpeakerState(s: NestsSpeaker) { diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 85c8b9c95e..926135932f 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -37,7 +37,7 @@ import java.security.SecureRandom import java.util.Base64 import javax.crypto.Cipher import javax.crypto.SecretKeyFactory -import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec @@ -78,6 +78,10 @@ actual class SecureKeyStorage private actual constructor() { // Encryption constants for fallback private const val ALGORITHM = "AES" private const val TRANSFORMATION = "AES/GCM/NoPadding" + + // AES-GCM must be initialised with GCMParameterSpec (tag length + IV); + // IvParameterSpec throws InvalidAlgorithmParameterException on every JDK. + private const val GCM_TAG_BITS = 128 private const val KEY_LENGTH = 256 private const val ITERATION_COUNT = 100000 private const val IV_LENGTH = 12 // GCM standard @@ -92,7 +96,7 @@ actual class SecureKeyStorage private actual constructor() { * some OSes (notably macOS and locked GNOME/KWallet sessions), triggers a * user-visible unlock prompt every time. Callers hit the storage at least * twice on cold start (metadata AES key, then active account nsec), so a - * per-call [Keyring.create] would prompt the user twice on startup — the + * per-call [Keyring.create] would prompt the user twice on startup, the * exact bug this cache fixes. * * Guarded by [keyringLock] so probing/opening the backend happens exactly @@ -109,13 +113,39 @@ actual class SecureKeyStorage private actual constructor() { */ internal var keyringFactory: () -> KeyringHandle = { RealKeyringHandle(Keyring.create()) } + /** + * Consolidated single-item vault name. When [enableConsolidatedVault] has + * run, every alias Amethyst owns is packed as one JSON blob under this + * account name, so the OS keychain sees exactly one item to gate. + * + * macOS Keychain gates access per item, not per session, so caching the + * [Keyring] handle alone cannot collapse the cold-boot double prompt + * (metadata AES key plus the active account's nsec). One item, one ACL, + * one prompt is the durable fix. + */ + private val vaultAlias: String = "vault-v1" + private val vaultMutex = Mutex() + + @Volatile + private var vaultActive: Boolean = false + + /** + * In-memory copy of the vault contents. Non-null iff [vaultActive] is + * true. Guarded by [vaultMutex] for mutations; reads are lock-free via + * the volatile reference plus a defensive copy inside [vaultGet]. + */ + @Volatile + private var vaultContents: MutableMap? = null + actual suspend fun savePrivateKey( npub: String, privKeyHex: String, ) { withContext(Dispatchers.IO) { try { - if (keyringAvailable) { + if (vaultActive) { + vaultPut(npub, privKeyHex) + } else if (keyringAvailable) { saveToKeyring(npub, privKeyHex) } else { saveToFallback(npub, privKeyHex) @@ -133,10 +163,14 @@ actual class SecureKeyStorage private actual constructor() { actual suspend fun getPrivateKey(npub: String): String? = withContext(Dispatchers.IO) { try { - if (keyringAvailable) { - getFromKeyring(npub) - } else { - getFromFallback(npub) + when { + // A vault miss is NOT proof of absence: the vault only covers the + // aliases a migration pass was given. Phase 1 activates it with just + // the metadata key, and a failed phase 2 leaves every nsec outside + // it. Fall back to the legacy per-alias item before reporting null. + vaultActive -> vaultGet(npub) ?: getFromKeyring(npub) + keyringAvailable -> getFromKeyring(npub) + else -> getFromFallback(npub) } } catch (e: BackendNotSupportedException) { keyringAvailable = false @@ -149,13 +183,92 @@ actual class SecureKeyStorage private actual constructor() { } } + /** + * Strict variant that distinguishes "backend confirms item not found" from every + * other outcome. This matters on macOS: `javakeyring` collapses `errSecItemNotFound` + * (-25300), `errSecAuthFailed` (-25293), `errSecUserCanceled` (-128), and + * `errSecInteractionNotAllowed` (-25308) into the same `PasswordAccessException`. + * A caller that mistook "user clicked Deny" for "first launch, generate a fresh + * key" would silently rotate the metadata AES key and permanently destroy the + * accounts.json.enc it was supposed to unlock. + * + * On macOS this shells out to `/usr/bin/security find-generic-password`, whose + * exit codes are documented and unambiguous (44 = not found, 128 = user cancel / + * dialog dismissed, others = backend failure). On Windows / Linux, javakeyring + * has no such ambiguity for the equivalent flows in practice, but we still treat + * any `PasswordAccessException` here as ambiguous (throw) to keep the contract + * strict on the getOrCreate path. + */ + actual suspend fun getPrivateKeyOrThrow(npub: String): String? = + withContext(Dispatchers.IO) { + try { + // The vault is authoritative for every alias it covers. Without this the + // strict path probes the OS for a per-alias item the migration already + // deleted, reads exit 44 / NotFound as "definitively absent", and lets + // DesktopAccountStorage.getOrCreateKey mint a fresh AES key over the one + // that decrypts accounts.json.enc -- the exact silent wipe this method + // exists to prevent. A vault miss still falls through to the strict + // per-alias probe, so uncovered aliases keep the strict contract. + if (vaultActive) { + vaultGet(npub)?.let { return@withContext it } + } + if (!keyringAvailable) { + return@withContext getFromFallback(npub) + } + if (isMacOs()) { + return@withContext getFromMacSecurityCli(SERVICE_NAME, npub) + } + try { + keyring().getPassword(SERVICE_NAME, npub) + } catch (e: PasswordAccessException) { + // Non-mac backends: keep the strict contract by refusing to + // treat this as "definitively absent". A caller that needs a + // permissive lookup should use getPrivateKey() instead. + throw SecureStorageException( + "Keyring backend refused access or returned ambiguous not-found", + e, + ) + } + } catch (e: SecureStorageException) { + throw e + } catch (e: BackendNotSupportedException) { + keyringAvailable = false + println("OS keyring not available, using fallback encrypted storage") + getFromFallback(npub) + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key (strict)", e) + } + } + + /** + * Test seam: overridable strategy for the strict macOS lookup. Production wires + * to [defaultMacSecurityLookup] which spawns `/usr/bin/security`. Tests replace + * this with a stub so unit tests run hermetically on any OS. + */ + internal var macSecurityLookup: (String, String) -> MacSecurityResult = + ::defaultMacSecurityLookup + + private fun getFromMacSecurityCli( + service: String, + account: String, + ): String? { + val result = macSecurityLookup(service, account) + return when (result) { + is MacSecurityResult.Found -> result.password + is MacSecurityResult.NotFound -> null + is MacSecurityResult.Ambiguous -> throw SecureStorageException( + "macOS Keychain access failed (${result.reason}, exit=${result.exitCode})", + ) + } + } + actual suspend fun deletePrivateKey(npub: String): Boolean = withContext(Dispatchers.IO) { try { - if (keyringAvailable) { - deleteFromKeyring(npub) - } else { - deleteFromFallback(npub) + when { + vaultActive -> vaultDelete(npub) + keyringAvailable -> deleteFromKeyring(npub) + else -> deleteFromFallback(npub) } } catch (e: BackendNotSupportedException) { keyringAvailable = false @@ -167,6 +280,375 @@ actual class SecureKeyStorage private actual constructor() { actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null + // --- Consolidated vault (see [vaultAlias] docs) --- + + /** + * Consolidates [candidateAliases] into a single OS keychain item named + * [vaultAlias]. On the next cold boot only that one item is read, so the + * OS surfaces at most one Keychain Access prompt regardless of how many + * secrets Amethyst manages. + * + * Semantics: + * + * 1. If [vaultAlias] already exists, load it into memory and mark + * [vaultActive]. Legacy per-alias items are not touched, zero extra + * prompts on that path. + * 2. If [vaultAlias] is absent, batch-read each candidate alias in the + * legacy per-item layout (paying the migration prompt once), pack the + * recovered entries into the vault item, and delete the legacy items. + * 3. If neither the vault nor any legacy alias exists (fresh install), + * the vault becomes an empty active map; subsequent writes go + * straight into it. + * + * Migration is idempotent and cheap when the vault already exists (one + * keychain read plus a JSON parse). Safe to call on every cold boot and + * safe to call multiple times per process: additional calls extend the + * vault with any newly-discovered legacy aliases and never rewrite the + * item if the delta is empty. + * + * Legacy items are deleted only after the vault write succeeds, so a + * crash mid-migration leaves the legacy items in place and the next run + * retries cleanly. No data loss window. + * + * The fallback (no-keyring) storage path is not migrated: it already + * uses a single encrypted file, so it does not have the per-item ACL + * problem the vault exists to solve. + */ + suspend fun enableConsolidatedVault(candidateAliases: List) { + withContext(Dispatchers.IO) { + vaultMutex.withLock { + if (!keyringAvailable) return@withLock // fallback path doesn't need the vault + try { + val handle = keyring() + if (!vaultActive) { + val existing = + try { + handle.getPassword(SERVICE_NAME, vaultAlias) + } catch (_: PasswordAccessException) { + null + } + if (existing != null) { + vaultContents = decodeVault(existing).toMutableMap() + vaultActive = true + // Fall through to the fold-in pass so any legacy per-alias + // items left behind by a partial earlier migration get + // absorbed on this cold boot. + } else { + // Fresh migration path: batch-read every candidate alias. + val collected = LinkedHashMap() + for (alias in candidateAliases) { + try { + collected[alias] = handle.getPassword(SERVICE_NAME, alias) + } catch (_: PasswordAccessException) { + // absent, skip + } + } + // Write the vault first, delete legacy items only after the write + // succeeded. Empty vaults are still written so a fresh install ends + // up in vault mode (subsequent savePrivateKey calls populate it). + handle.setPassword(SERVICE_NAME, vaultAlias, encodeVault(collected)) + for (alias in collected.keys) { + try { + handle.deletePassword(SERVICE_NAME, alias) + } catch (_: PasswordAccessException) { + // already gone, fine + } + } + vaultContents = collected + vaultActive = true + return@withLock + } + } + + // Fold-in pass. Runs on: + // - a second `enableConsolidatedVault(fullList)` call after the + // phase-1 metadata-key-only bootstrap, and + // - a cold boot that finds `vault-v1` alongside legacy per-alias + // items from an interrupted earlier migration. + val current = vaultContents ?: LinkedHashMap() + val additions = LinkedHashMap() + for (alias in candidateAliases) { + if (alias in current) continue + val legacy = + try { + handle.getPassword(SERVICE_NAME, alias) + } catch (_: PasswordAccessException) { + null + } ?: continue + additions[alias] = legacy + } + if (additions.isNotEmpty()) { + val merged = LinkedHashMap(current).also { it.putAll(additions) } + handle.setPassword(SERVICE_NAME, vaultAlias, encodeVault(merged)) + for (alias in additions.keys) { + try { + handle.deletePassword(SERVICE_NAME, alias) + } catch (_: PasswordAccessException) { + // already gone, fine + } + } + vaultContents = merged + } + } catch (e: BackendNotSupportedException) { + keyringAvailable = false + println("OS keyring not available during vault migration, keeping fallback storage") + } catch (e: Exception) { + // Migration is best-effort: if the OS keychain is misbehaving we leave + // the legacy per-alias items in place and continue in legacy mode. + println("enableConsolidatedVault: aborting migration: ${e.message}") + } + } + } + } + + /** Returns true iff the consolidated vault has been loaded or migrated. */ + fun isVaultActive(): Boolean = vaultActive + + /** + * Test-only accessor for the current in-memory alias set. Kept internal + * so tests in the same module can assert vault contents without exposing + * secrets to app code. + */ + internal fun snapshotCacheKeys(): Set = vaultContents?.keys?.toSet() ?: emptySet() + + private fun vaultGet(alias: String): String? = vaultContents?.get(alias) + + private fun vaultPut( + alias: String, + value: String, + ) { + val contents = vaultContents ?: LinkedHashMap().also { vaultContents = it } + contents[alias] = value + keyring().setPassword(SERVICE_NAME, vaultAlias, encodeVault(contents)) + } + + /** + * Removes [alias] from the vault *and* unlinks any legacy per-alias item still + * holding it. An alias the vault does not cover (a partial migration, or a + * phase 2 that failed) would otherwise survive a logout as an orphaned secret + * in the OS keychain, since [getPrivateKey] can still read it. + */ + private fun vaultDelete(alias: String): Boolean { + val contents = vaultContents + val removedFromVault = contents != null && contents.remove(alias) != null + + val removedLegacy = + try { + keyring().deletePassword(SERVICE_NAME, alias) + true + } catch (_: PasswordAccessException) { + false // no legacy item, fine + } + + if (removedFromVault) { + if (contents!!.isEmpty()) { + try { + keyring().deletePassword(SERVICE_NAME, vaultAlias) + } catch (_: PasswordAccessException) { + // already gone, still removed from our POV + } + } else { + keyring().setPassword(SERVICE_NAME, vaultAlias, encodeVault(contents)) + } + } + + return removedFromVault || removedLegacy + } + + /** + * Envelope: `{"schemaVersion":1,"entries":{alias: base64(secret), …}}`. + * + * The [schemaVersion] field reserves room for future migrations. Values + * are base64-encoded so alias/secret contents that contain quotes, + * backslashes, control chars, or non-ASCII round-trip cleanly through the + * hand-rolled JSON codec (kept hand-rolled to avoid pulling Jackson into + * the keystorage module; Jackson lives in desktopApp / quartz). + */ + private fun encodeVault(map: Map): String { + val sb = StringBuilder("{\"schemaVersion\":1,\"entries\":{") + var first = true + for ((k, v) in map) { + if (!first) sb.append(',') + first = false + sb + .append('"') + .append(jsonEscape(k)) + .append('"') + .append(':') + .append('"') + .append(Base64.getEncoder().encodeToString(v.toByteArray(Charsets.UTF_8))) + .append('"') + } + sb.append("}}") + return sb.toString() + } + + private fun decodeVault(raw: String): Map { + val trimmed = raw.trim() + if (trimmed.length < 2 || trimmed.first() != '{' || trimmed.last() != '}') return emptyMap() + val body = trimmed.substring(1, trimmed.length - 1) + if (body.isBlank()) return emptyMap() + + // Minimal object parser: expect a mix of "key":number and "key":"string" + // pairs at the top level, plus a nested "entries":{ ... } object holding + // base64-encoded alias values. Bare-map layouts written by a hypothetical + // earlier vault format are still accepted (fallback path). + var i = 0 + var entriesRaw: String? = null + while (i < body.length) { + if (body[i] != '"') return decodeBareEntries(body) + val keyEnd = findUnescapedQuote(body, i + 1) + if (keyEnd < 0) return emptyMap() + val key = jsonUnescape(body.substring(i + 1, keyEnd)) + i = keyEnd + 1 + if (i >= body.length || body[i] != ':') return emptyMap() + i += 1 + if (i >= body.length) return emptyMap() + when (body[i]) { + '"' -> { + val valEnd = findUnescapedQuote(body, i + 1) + if (valEnd < 0) return emptyMap() + // String-valued top-level fields are ignored except for legacy + // format detection handled by decodeBareEntries. + i = valEnd + 1 + } + '{' -> { + val objEnd = findMatchingBrace(body, i) + if (objEnd < 0) return emptyMap() + if (key == "entries") { + entriesRaw = body.substring(i + 1, objEnd) + } + i = objEnd + 1 + } + else -> { + // Skip a bare token (schemaVersion number, boolean, null). + while (i < body.length && body[i] != ',' && body[i] != '}') i += 1 + } + } + if (i < body.length) { + if (body[i] != ',') return emptyMap() + i += 1 + } + } + return entriesRaw?.let { decodeBareEntries(it) } ?: emptyMap() + } + + /** Parses a `"k":"b64","k2":"b64"` body into `{k: decoded, k2: decoded}`. */ + private fun decodeBareEntries(body: String): Map { + val out = LinkedHashMap() + val trimmed = body.trim() + if (trimmed.isEmpty()) return out + var i = 0 + while (i < trimmed.length) { + if (trimmed[i] != '"') return emptyMap() + val keyEnd = findUnescapedQuote(trimmed, i + 1) + if (keyEnd < 0) return emptyMap() + val key = jsonUnescape(trimmed.substring(i + 1, keyEnd)) + i = keyEnd + 1 + if (i >= trimmed.length || trimmed[i] != ':') return emptyMap() + i += 1 + if (i >= trimmed.length || trimmed[i] != '"') return emptyMap() + val valEnd = findUnescapedQuote(trimmed, i + 1) + if (valEnd < 0) return emptyMap() + val b64 = trimmed.substring(i + 1, valEnd) + val value = + try { + String(Base64.getDecoder().decode(b64), Charsets.UTF_8) + } catch (_: IllegalArgumentException) { + return emptyMap() + } + out[key] = value + i = valEnd + 1 + if (i < trimmed.length) { + if (trimmed[i] != ',') return emptyMap() + i += 1 + } + } + return out + } + + private fun findUnescapedQuote( + s: String, + from: Int, + ): Int { + var i = from + while (i < s.length) { + when (s[i]) { + '\\' -> i += 2 + '"' -> return i + else -> i += 1 + } + } + return -1 + } + + private fun findMatchingBrace( + s: String, + openAt: Int, + ): Int { + var depth = 0 + var i = openAt + while (i < s.length) { + when (s[i]) { + '"' -> { + val end = findUnescapedQuote(s, i + 1) + if (end < 0) return -1 + i = end + 1 + } + '{' -> { + depth += 1 + i += 1 + } + '}' -> { + depth -= 1 + if (depth == 0) return i + i += 1 + } + else -> i += 1 + } + } + return -1 + } + + private fun jsonEscape(s: String): String { + val sb = StringBuilder(s.length) + for (c in s) { + when (c) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> sb.append(c) + } + } + return sb.toString() + } + + private fun jsonUnescape(s: String): String { + if (!s.contains('\\')) return s + val sb = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + if (c == '\\' && i + 1 < s.length) { + when (s[i + 1]) { + '\\' -> sb.append('\\') + '"' -> sb.append('"') + 'n' -> sb.append('\n') + 'r' -> sb.append('\r') + 't' -> sb.append('\t') + else -> sb.append(s[i + 1]) + } + i += 2 + } else { + sb.append(c) + i += 1 + } + } + return sb.toString() + } + // Keyring-based storage /** @@ -393,7 +875,7 @@ actual class SecureKeyStorage private actual constructor() { } } - private fun encryptData( + internal fun encryptData( plaintext: String, password: String, ): String { @@ -405,14 +887,14 @@ actual class SecureKeyStorage private actual constructor() { val key = SecretKeySpec(secretKey.encoded, ALGORITHM) val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) val encrypted = cipher.doFinal(plaintext.toByteArray()) val combined = salt + iv + encrypted return Base64.getEncoder().encodeToString(combined) } - private fun decryptData( + internal fun decryptData( ciphertext: String, password: String, ): String { @@ -427,7 +909,7 @@ actual class SecureKeyStorage private actual constructor() { val key = SecretKeySpec(secretKey.encoded, ALGORITHM) val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) val decrypted = cipher.doFinal(encrypted) return String(decrypted) @@ -466,6 +948,98 @@ internal interface KeyringHandle { ) } +/** + * Outcome of a strict macOS `/usr/bin/security find-generic-password` lookup. + * Kept as a sealed hierarchy so [SecureKeyStorage.getPrivateKeyOrThrow] can + * cleanly translate to `null` versus `SecureStorageException`. + */ +internal sealed class MacSecurityResult { + data class Found( + val password: String, + ) : MacSecurityResult() + + object NotFound : MacSecurityResult() + + /** + * Any exit code other than 0 (found) or 44 (item not found). Reason is a short + * human string derived from stderr / documented codes: + * 128 = user cancelled or dismissed the Keychain Access dialog + * -25293 (errSecAuthFailed) surfaces as exit 51 in practice + * -25308 (errSecInteractionNotAllowed) surfaces when Keychain is locked + */ + data class Ambiguous( + val exitCode: Int, + val reason: String, + ) : MacSecurityResult() +} + +/** + * Pure parser split out for testability on non-macOS CI runners. Maps the + * documented exit code contract of `/usr/bin/security find-generic-password` + * to a [MacSecurityResult]. `stdout` is the raw password body (`-w` prints it + * followed by a newline; strip the trailing newline only). `stderr` is used + * as a hint for the ambiguous [MacSecurityResult.Ambiguous.reason] string. + */ +internal fun parseMacSecurityFindResult( + exitCode: Int, + stdout: String, + stderr: String, +): MacSecurityResult = + when (exitCode) { + 0 -> MacSecurityResult.Found(stdout.trimEnd('\n', '\r')) + 44 -> MacSecurityResult.NotFound + else -> { + val reason = + when { + exitCode == 128 -> "user cancelled Keychain dialog" + stderr.contains("-25293") -> "errSecAuthFailed" + stderr.contains("-25308") -> "errSecInteractionNotAllowed" + stderr.contains("-128") -> "user cancelled Keychain dialog" + stderr.isNotBlank() -> + stderr + .lineSequence() + .first() + .trim() + .take(120) + else -> "unknown" + } + MacSecurityResult.Ambiguous(exitCode, reason) + } + } + +private fun isMacOs(): Boolean = System.getProperty("os.name").orEmpty().startsWith("Mac") + +/** + * Production implementation: spawn `/usr/bin/security` and read exit code + streams. + * Kept package-private so tests can also reach it if they want to run the real path + * on a mac host, but production always goes through the [SecureKeyStorage.macSecurityLookup] + * indirection. + */ +internal fun defaultMacSecurityLookup( + service: String, + account: String, +): MacSecurityResult { + val process = + try { + ProcessBuilder( + "/usr/bin/security", + "find-generic-password", + "-s", + service, + "-a", + account, + "-w", + ).redirectErrorStream(false).start() + } catch (e: Exception) { + return MacSecurityResult.Ambiguous(-1, "failed to spawn /usr/bin/security: ${e.message ?: e::class.simpleName ?: "unknown"}") + } + process.outputStream.close() + val stdout = process.inputStream.bufferedReader().use { it.readText() } + val stderr = process.errorStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + return parseMacSecurityFindResult(exitCode, stdout, stderr) +} + internal class RealKeyringHandle( private val keyring: Keyring, ) : KeyringHandle { diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt index 7c12f8a647..469eaee0e8 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.service.upload +import com.vitorpamplona.quartz.utils.Log import org.apache.commons.imaging.Imaging import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter import java.io.ByteArrayOutputStream @@ -37,7 +38,9 @@ object MediaCompressor { * carries no EXIF, or strip fails. */ fun stripExif(file: File): File { - if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) { + // Sniff the bytes rather than trusting the name: a camera JPEG handed + // over as an extension-less temp file must still lose its GPS tags. + if (ImageFormatSniffer.sniff(file) !is ImageFormat.Jpeg) { return file } @@ -52,7 +55,9 @@ object MediaCompressor { val stripped = AmethystTempDir.createTempFile("amethyst_stripped_", ".jpg") stripped.writeBytes(baos.toByteArray()) stripped - } catch (_: Exception) { + } catch (e: Exception) { + // Never silent: the UI promises the original is stripped before upload. + Log.w("MediaCompressor", "EXIF strip failed for ${file.name}; uploading unstripped original", e) file } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt index 6a9b5cd000..6a07c34a1b 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt @@ -71,7 +71,10 @@ class StaticSitePublisher( root.isDirectory -> root .walkTopDown() - .filter { it.isFile } + // Never follow a link out of the published tree: a symlink + // to ~ would publish the home directory to a Blossom server. + .onEnter { !Files.isSymbolicLink(it.toPath()) } + .filter { it.isFile && !Files.isSymbolicLink(it.toPath()) && it.canonicalFile.startsWith(root) } .sortedBy { it.invariantPath() } .toList() else -> throw IllegalArgumentException("No such file or directory: ${root.path}") diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageFallbackCipherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageFallbackCipherTest.kt new file mode 100644 index 0000000000..39d5553704 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageFallbackCipherTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.keystorage + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SecureKeyStorageFallbackCipherTest { + @Test + fun encryptedFileFallbackRoundTrips() { + // AES/GCM initialised with IvParameterSpec threw on every JDK, which made + // the whole no-keyring fallback (headless Linux, containers) dead code. + val storage = SecureKeyStorage.create() + val ciphertext = storage.encryptData("nsec1secret", "correct horse") + assertNotEquals("nsec1secret", ciphertext) + assertEquals("nsec1secret", storage.decryptData(ciphertext, "correct horse")) + } + + @Test(expected = Exception::class) + fun wrongPasswordIsRejected() { + val storage = SecureKeyStorage.create() + storage.decryptData(storage.encryptData("nsec1secret", "right"), "wrong") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageOrThrowTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageOrThrowTest.kt new file mode 100644 index 0000000000..980d6c718f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageOrThrowTest.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.keystorage + +import com.github.javakeyring.PasswordAccessException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Unit tests for the strict `getPrivateKeyOrThrow` lookup and its pure macOS + * `/usr/bin/security` exit-code parser. + * + * These tests must never touch the OS keychain and must run on any host, so: + * - the macOS integration paths are gated behind [MacSecurityResult] stubs + * injected via `SecureKeyStorage.macSecurityLookup`; + * - the parser test operates on captured stdout/stderr/exitCode triples; + * - non-mac backends are exercised via the `KeyringHandle` test seam already + * used by [SecureKeyStorageKeyringCacheTest]. + */ +class SecureKeyStorageOrThrowTest { + private class ExplodingKeyring( + private val onGet: () -> Nothing, + ) : KeyringHandle { + override fun getPassword( + service: String, + account: String, + ): String = onGet() + + override fun setPassword( + service: String, + account: String, + password: String, + ) { + // unused in these tests + } + + override fun deletePassword( + service: String, + account: String, + ) { + // unused in these tests + } + } + + private class StaticKeyring( + private val map: Map, String>, + ) : KeyringHandle { + override fun getPassword( + service: String, + account: String, + ): String = map[service to account] ?: throw PasswordAccessException("no entry") + + override fun setPassword( + service: String, + account: String, + password: String, + ) {} + + override fun deletePassword( + service: String, + account: String, + ) {} + } + + // --- macOS security(1) parser --- + + @Test + fun `parser exit 0 returns Found with trimmed password`() { + val result = parseMacSecurityFindResult(0, "hunter2\n", "") + assertTrue(result is MacSecurityResult.Found) + assertEquals("hunter2", (result as MacSecurityResult.Found).password) + } + + @Test + fun `parser exit 0 preserves internal newlines and only strips trailing`() { + val result = parseMacSecurityFindResult(0, "line1\nline2\n", "") + assertEquals("line1\nline2", (result as MacSecurityResult.Found).password) + } + + @Test + fun `parser exit 44 returns NotFound`() { + val result = + parseMacSecurityFindResult( + 44, + "", + "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n", + ) + assertTrue(result is MacSecurityResult.NotFound) + } + + @Test + fun `parser exit 128 flagged as user-cancelled`() { + val result = parseMacSecurityFindResult(128, "", "security: dismissed\n") + assertTrue(result is MacSecurityResult.Ambiguous) + val ambig = result as MacSecurityResult.Ambiguous + assertEquals(128, ambig.exitCode) + assertTrue(ambig.reason.contains("cancel")) + } + + @Test + fun `parser stderr -25293 mapped to errSecAuthFailed`() { + val result = parseMacSecurityFindResult(51, "", "security: SecKeychainItemCopyContent (-25293)\n") + val ambig = result as MacSecurityResult.Ambiguous + assertEquals("errSecAuthFailed", ambig.reason) + } + + @Test + fun `parser unknown exit falls back to first stderr line`() { + val result = parseMacSecurityFindResult(9999, "", "security: mystery: line 1\nline 2\n") + val ambig = result as MacSecurityResult.Ambiguous + assertEquals("security: mystery: line 1", ambig.reason) + } + + // --- getPrivateKeyOrThrow: strict semantics via injected macOS lookup --- + // (Enabled unconditionally: the macSecurityLookup indirection is exercised + // via a stub, so no `security` binary is invoked. The `isMacOs()` check + // means this test only takes the mac path on macOS runners; on Linux it + // takes the javakeyring path, which we validate separately below.) + + private fun newStorage(): SecureKeyStorage = SecureKeyStorage.create() + + @Test + fun `mac lookup Found returns password without ambiguity`() = + runBlocking { + if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking + val storage = newStorage() + storage.macSecurityLookup = { _, _ -> MacSecurityResult.Found("secretval") } + assertEquals("secretval", storage.getPrivateKeyOrThrow("account-metadata-key")) + } + + @Test + fun `mac lookup NotFound returns null`() = + runBlocking { + if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking + val storage = newStorage() + storage.macSecurityLookup = { _, _ -> MacSecurityResult.NotFound } + assertNull(storage.getPrivateKeyOrThrow("account-metadata-key")) + } + + @Test + fun `mac lookup Ambiguous throws SecureStorageException with reason`() = + runBlocking { + if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking + val storage = newStorage() + storage.macSecurityLookup = { _, _ -> + MacSecurityResult.Ambiguous(128, "user cancelled Keychain dialog") + } + try { + storage.getPrivateKeyOrThrow("account-metadata-key") + fail("Expected SecureStorageException") + } catch (e: SecureStorageException) { + assertTrue(e.message?.contains("user cancelled") == true) + assertTrue(e.message?.contains("128") == true) + } + } + + // --- non-mac backend: PasswordAccessException must throw, never null --- + + @Test + fun `non-mac keyring PasswordAccessException throws not returns null`() = + runBlocking { + if (System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking + val storage = newStorage() + storage.keyringFactory = { ExplodingKeyring { throw PasswordAccessException("locked") } } + try { + storage.getPrivateKeyOrThrow("account-metadata-key") + fail("Expected SecureStorageException") + } catch (e: SecureStorageException) { + assertTrue( + e.message?.contains("ambiguous", ignoreCase = true) == true || + e.message?.contains("refused", ignoreCase = true) == true, + ) + } + } + + @Test + fun `non-mac keyring hit returns password`() = + runBlocking { + if (System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking + val storage = newStorage() + storage.keyringFactory = { + StaticKeyring( + mapOf( + ("amethyst-desktop" to "account-metadata-key") to "abc123", + ), + ) + } + assertEquals("abc123", storage.getPrivateKeyOrThrow("account-metadata-key")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageVaultTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageVaultTest.kt new file mode 100644 index 0000000000..2f759c25a0 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorageVaultTest.kt @@ -0,0 +1,487 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.keystorage + +import com.github.javakeyring.PasswordAccessException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Regression tests for [SecureKeyStorage.enableConsolidatedVault]. + * + * macOS Keychain gates access per item, not per session, so caching the + * [Keyring] handle alone (see [SecureKeyStorageKeyringCacheTest]) cannot + * collapse the cold-boot double prompt (metadata AES key plus the active + * account's nsec). The vault consolidates every alias Amethyst owns into a + * single keychain item so the OS sees one ACL to gate. + * + * Fake [KeyringHandle] backend keeps every case hermetic; the real macOS / + * Windows / Linux backends are only exercised by manual QA. + */ +class SecureKeyStorageVaultTest { + private class CountingKeyring : KeyringHandle { + val store: ConcurrentHashMap, String> = ConcurrentHashMap() + val readsBySlot: ConcurrentHashMap, AtomicInteger> = ConcurrentHashMap() + val writesBySlot: ConcurrentHashMap, AtomicInteger> = ConcurrentHashMap() + + override fun getPassword( + service: String, + account: String, + ): String { + readsBySlot.computeIfAbsent(service to account) { AtomicInteger(0) }.incrementAndGet() + return store[service to account] ?: throw PasswordAccessException("no entry") + } + + override fun setPassword( + service: String, + account: String, + password: String, + ) { + writesBySlot.computeIfAbsent(service to account) { AtomicInteger(0) }.incrementAndGet() + store[service to account] = password + } + + override fun deletePassword( + service: String, + account: String, + ) { + if (store.remove(service to account) == null) { + throw PasswordAccessException("no entry") + } + } + + fun reads(alias: String): Int = readsBySlot[SERVICE to alias]?.get() ?: 0 + + fun writes(alias: String): Int = writesBySlot[SERVICE to alias]?.get() ?: 0 + + fun snapshotAliases(): Set = + store.keys + .filter { it.first == SERVICE } + .map { it.second } + .toSet() + + companion object { + const val SERVICE = "amethyst-desktop" + } + } + + private fun newStorageWith( + opens: AtomicInteger = AtomicInteger(0), + prepopulate: (CountingKeyring) -> Unit = {}, + ): Pair { + val backend = CountingKeyring() + prepopulate(backend) + val storage = SecureKeyStorage.create() + storage.keyringFactory = { + opens.incrementAndGet() + backend + } + return storage to backend + } + + /** Envelope used to seed a pre-existing vault item in tests. */ + private fun seedVault(entries: Map): String { + val body = + entries.entries.joinToString(",") { (k, v) -> + "\"" + k + "\":\"" + Base64.getEncoder().encodeToString(v.toByteArray(Charsets.UTF_8)) + "\"" + } + return "{\"schemaVersion\":1,\"entries\":{$body}}" + } + + @Test + fun `enableConsolidatedVault fresh install writes an empty vault item`() = + runBlocking { + val (storage, backend) = newStorageWith() + storage.enableConsolidatedVault(candidateAliases = emptyList()) + assertTrue(storage.isVaultActive()) + // Fresh install: vault item exists so future writes stay inside it. + assertEquals(setOf("vault-v1"), backend.snapshotAliases()) + assertEquals(1, backend.writes("vault-v1")) + } + + @Test + fun `enableConsolidatedVault migrates legacy items and deletes originals`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "AES==" + b.store[CountingKeyring.SERVICE to "npub1alice"] = "aaaa" + b.store[CountingKeyring.SERVICE to "npub1bob"] = "bbbb" + } + + storage.enableConsolidatedVault( + candidateAliases = + listOf( + "account-metadata-key", + "npub1alice", + "npub1bob", + "npub1missing", + "bunker_ephemeral_npub1alice", + ), + ) + + // Consolidation: only vault-v1 remains, legacy items unlinked. + assertEquals(setOf("vault-v1"), backend.snapshotAliases()) + assertTrue(storage.isVaultActive()) + + // Reads still serve the correct secrets from the in-memory vault. + assertEquals("AES==", storage.getPrivateKey("account-metadata-key")) + assertEquals("aaaa", storage.getPrivateKey("npub1alice")) + assertEquals("bbbb", storage.getPrivateKey("npub1bob")) + assertNull(storage.getPrivateKey("npub1missing")) + + // Post-migration savePrivateKey stays inside the vault. + storage.savePrivateKey("npub1carol", "cccc") + assertEquals( + "Post-migration writes must stay inside vault-v1, not create per-alias items", + setOf("vault-v1"), + backend.snapshotAliases(), + ) + assertEquals("cccc", storage.getPrivateKey("npub1carol")) + } + + @Test + fun `enableConsolidatedVault existing vault is loaded without probing aliases it already contains`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "vault-v1"] = + seedVault( + mapOf( + "account-metadata-key" to "SEED", + "npub1alice" to "AAAA", + ), + ) + // A leftover legacy item not in the candidate list on this boot, + // e.g. an npub the user removed from accounts.json.enc. + b.store[CountingKeyring.SERVICE to "npub1stale"] = "STALE" + } + + storage.enableConsolidatedVault( + candidateAliases = listOf("account-metadata-key", "npub1alice"), + ) + + assertTrue(storage.isVaultActive()) + assertEquals(1, backend.reads("vault-v1")) + // Aliases that ARE the candidate set must not be re-probed once the vault has them, + // otherwise the cold-boot prompt count would scale with the account count again. + assertEquals(0, backend.reads("account-metadata-key")) + assertEquals(0, backend.reads("npub1alice")) + // Aliases outside the candidate list are ignored entirely on this boot. + assertEquals(0, backend.reads("npub1stale")) + assertEquals("SEED", storage.getPrivateKey("account-metadata-key")) + assertEquals("AAAA", storage.getPrivateKey("npub1alice")) + } + + @Test + fun `enableConsolidatedVault absorbs legacy leftovers when candidate list still names them`() = + runBlocking { + // Interrupted earlier migration: vault-v1 exists but npub1stale is still + // on disk. If the current AccountManager still has npub1stale in its + // candidate list, cold-boot 2 must absorb it. + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "vault-v1"] = + seedVault(mapOf("account-metadata-key" to "SEED")) + b.store[CountingKeyring.SERVICE to "npub1stale"] = "STALE" + } + + storage.enableConsolidatedVault( + candidateAliases = listOf("account-metadata-key", "npub1stale"), + ) + + assertTrue(storage.isVaultActive()) + assertEquals(setOf("vault-v1"), backend.snapshotAliases()) + assertEquals("STALE", storage.getPrivateKey("npub1stale")) + } + + @Test + fun `savePrivateKey after vault enabled persists to vault`() = + runBlocking { + val (storage, backend) = newStorageWith() + storage.enableConsolidatedVault(emptyList()) + + storage.savePrivateKey("npub1new", "1111") + storage.savePrivateKey("npub1other", "2222") + + assertEquals(setOf("vault-v1"), backend.snapshotAliases()) + // Empty-vault seed (1) plus two follow-up writes. + assertEquals(3, backend.writes("vault-v1")) + assertEquals("1111", storage.getPrivateKey("npub1new")) + assertEquals("2222", storage.getPrivateKey("npub1other")) + } + + @Test + fun `getPrivateKey after vault enabled reads from vault contents in memory`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "vault-v1"] = + seedVault(mapOf("npub1alice" to "aaaa")) + } + storage.enableConsolidatedVault(listOf("npub1alice")) + + val readsAfterLoad = backend.reads("vault-v1") + repeat(10) { + assertEquals("aaaa", storage.getPrivateKey("npub1alice")) + assertNull(storage.getPrivateKey("npub1missing")) + } + assertEquals( + "Post-load reads must be served entirely from memory", + readsAfterLoad, + backend.reads("vault-v1"), + ) + } + + @Test + fun `enableConsolidatedVault is idempotent when called repeatedly`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "npub1a"] = "AA" + } + storage.enableConsolidatedVault(listOf("npub1a")) + val writesAfterMigration = backend.writes("vault-v1") + storage.enableConsolidatedVault(listOf("npub1a")) + storage.enableConsolidatedVault(listOf("npub1a")) + // No further writes: idempotent second-phase calls with no new legacy + // aliases are a pure no-op. Migration writes the vault once total. + assertEquals( + "enableConsolidatedVault must be safe to call repeatedly (cold-boot invariant)", + writesAfterMigration, + backend.writes("vault-v1"), + ) + } + + @Test + fun `enableConsolidatedVault two phase migration folds in later aliases`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "K" + b.store[CountingKeyring.SERVICE to "npub1alice"] = "aaaa" + b.store[CountingKeyring.SERVICE to "npub1bob"] = "bbbb" + } + // Phase 1: bootstrap with just the metadata key, mimicking + // AccountManager.create() before accounts.json.enc is decrypted. + storage.enableConsolidatedVault(listOf("account-metadata-key")) + assertTrue(storage.isVaultActive()) + assertEquals("K", storage.getPrivateKey("account-metadata-key")) + // Legacy nsecs still on disk because they weren't in phase-1 candidates. + assertTrue("npub1alice" in backend.snapshotAliases()) + + // Phase 2: full list once npubs are known. + storage.enableConsolidatedVault(listOf("account-metadata-key", "npub1alice", "npub1bob")) + assertEquals(setOf("vault-v1"), backend.snapshotAliases()) + assertEquals("aaaa", storage.getPrivateKey("npub1alice")) + assertEquals("bbbb", storage.getPrivateKey("npub1bob")) + } + + @Test + fun `enableConsolidatedVault partial legacy migration survives relaunch`() = + runBlocking { + // Cold boot 1: only metadata key gets migrated; nsec stays on disk. + val opens1 = AtomicInteger(0) + val (storage1, backend) = + newStorageWith(opens1) { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "K" + b.store[CountingKeyring.SERVICE to "npub1alice"] = "aaaa" + } + storage1.enableConsolidatedVault(listOf("account-metadata-key")) + assertTrue("npub1alice" in backend.snapshotAliases()) + assertEquals("K", storage1.getPrivateKey("account-metadata-key")) + + // Cold boot 2: fresh SecureKeyStorage against the same backing store. + val opens2 = AtomicInteger(0) + val storage2 = SecureKeyStorage.create() + storage2.keyringFactory = { + opens2.incrementAndGet() + backend + } + storage2.enableConsolidatedVault(listOf("account-metadata-key", "npub1alice")) + assertEquals(setOf("vault-v1"), backend.snapshotAliases()) + assertEquals("K", storage2.getPrivateKey("account-metadata-key")) + assertEquals("aaaa", storage2.getPrivateKey("npub1alice")) + } + + @Test + fun `delete removes from vault and unlinks item when the last key goes`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "K" + b.store[CountingKeyring.SERVICE to "npub1x"] = "V" + } + storage.enableConsolidatedVault(listOf("account-metadata-key", "npub1x")) + assertTrue(storage.isVaultActive()) + + assertTrue(storage.deletePrivateKey("npub1x")) + assertTrue("vault-v1" in backend.snapshotAliases()) + + assertTrue(storage.deletePrivateKey("account-metadata-key")) + assertFalse( + "Emptying the vault must unlink the keychain item so a fresh install path can re-migrate cleanly", + "vault-v1" in backend.snapshotAliases(), + ) + } + + @Test + fun `vault survives keys with quotes newlines and unicode`() = + runBlocking { + val weirdAlias = "weird\"key\n\u2603" + val weirdValue = "value with \"quotes\" and \\backslashes and \u2603 snowmen" + val (storage, _) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to weirdAlias] = weirdValue + } + storage.enableConsolidatedVault(listOf(weirdAlias)) + assertEquals(weirdValue, storage.getPrivateKey(weirdAlias)) + } + + // --- Interaction with the strict getOrCreate path (proposal 5d31b68e) --- + + /** + * Wires the strict macOS probe to the fake backend so the test models real + * macOS: `security find-generic-password` sees the OS keychain, returning + * exit 0 (Found) while a per-alias item exists and exit 44 (NotFound) once + * the migration has deleted it. + */ + private fun wireMacProbe( + storage: SecureKeyStorage, + backend: CountingKeyring, + ) { + storage.macSecurityLookup = { service, account -> + backend.store[service to account] + ?.let { MacSecurityResult.Found(it) } + ?: MacSecurityResult.NotFound + } + } + + @Test + fun `strict lookup reads through the vault after migration`() = + runBlocking { + val alias = "account-metadata-key" + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to alias] = "THE-AES-KEY" + } + wireMacProbe(storage, backend) + + assertEquals("THE-AES-KEY", storage.getPrivateKeyOrThrow(alias)) + + storage.enableConsolidatedVault(listOf(alias)) + + // The migration deleted the per-alias item, so an unvaulted strict probe + // would answer "definitively absent" -- and DesktopAccountStorage would + // mint a fresh AES key over the one that decrypts accounts.json.enc, + // wiping every account. The vault must answer instead. + assertFalse(alias in backend.snapshotAliases()) + assertEquals( + "Strict lookup must read through the vault, not report the migrated alias as absent", + "THE-AES-KEY", + storage.getPrivateKeyOrThrow(alias), + ) + } + + @Test + fun `strict lookup keeps the strict contract for aliases outside the vault`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "THE-AES-KEY" + } + storage.enableConsolidatedVault(listOf("account-metadata-key")) + + // An alias the vault never covered still falls through to the strict probe. + // Off macOS that probe is javakeyring, which cannot tell a miss from a denial, + // so the strict contract there is to throw on any miss. + if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) { + try { + storage.getPrivateKeyOrThrow("npub1neverseen") + throw AssertionError("Expected SecureStorageException for a non-mac keyring miss") + } catch (e: SecureStorageException) { + // Walk the chain: coroutine stack-trace recovery may wrap the original. + assertTrue(generateSequence(e) { it.cause }.any { it is PasswordAccessException }) + } + return@runBlocking + } + + // On macOS a confirmed miss is null, an ambiguous answer still throws. + wireMacProbe(storage, backend) + assertNull(storage.getPrivateKeyOrThrow("npub1neverseen")) + + storage.macSecurityLookup = { _, _ -> MacSecurityResult.Ambiguous(128, "user cancelled Keychain dialog") } + try { + storage.getPrivateKeyOrThrow("npub1neverseen") + throw AssertionError("Expected SecureStorageException for an ambiguous answer") + } catch (e: SecureStorageException) { + assertTrue(e.message?.contains("cancelled") == true) + } + } + + @Test + fun `getPrivateKey falls back to a legacy item the vault does not cover`() = + runBlocking { + // Phase 1 activates the vault with only the metadata key; a phase 2 that + // never ran (or threw, which AccountManager swallows) leaves every nsec + // outside it. Those must stay readable, not read as absent. + val (storage, _) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "THE-AES-KEY" + b.store[CountingKeyring.SERVICE to "npub1someaccount"] = "THE-NSEC" + } + + storage.enableConsolidatedVault(listOf("account-metadata-key")) + + assertTrue(storage.isVaultActive()) + assertEquals("THE-AES-KEY", storage.getPrivateKey("account-metadata-key")) + assertEquals( + "An uncovered alias must fall back to its legacy per-alias item", + "THE-NSEC", + storage.getPrivateKey("npub1someaccount"), + ) + assertTrue(storage.hasPrivateKey("npub1someaccount")) + } + + @Test + fun `delete unlinks a legacy item the vault does not cover`() = + runBlocking { + val (storage, backend) = + newStorageWith { b -> + b.store[CountingKeyring.SERVICE to "account-metadata-key"] = "THE-AES-KEY" + b.store[CountingKeyring.SERVICE to "npub1someaccount"] = "THE-NSEC" + } + storage.enableConsolidatedVault(listOf("account-metadata-key")) + + // Logging out of an account whose nsec never reached the vault must not + // leave the secret orphaned in the OS keychain. + assertTrue(storage.deletePrivateKey("npub1someaccount")) + assertFalse("npub1someaccount" in backend.snapshotAliases()) + assertNull(storage.getPrivateKey("npub1someaccount")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt index d84c56ca69..c63f506200 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt @@ -461,9 +461,9 @@ class MarmotPublishBeforeApplyTest { override suspend fun saveRetainedEpochs( nostrGroupId: String, - epochs: List, + retainedSecrets: List, ) { - retained[nostrGroupId] = epochs + retained[nostrGroupId] = retainedSecrets } override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId] ?: emptyList() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt index e85afc40ca..e06891794c 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt @@ -105,9 +105,9 @@ class MarmotPublishDurabilityTest { override suspend fun saveRetainedEpochs( nostrGroupId: String, - epochs: List, + retainedSecrets: List, ) { - retained[nostrGroupId] = epochs + retained[nostrGroupId] = retainedSecrets } override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId].orEmpty() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemMediaTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemMediaTest.kt new file mode 100644 index 0000000000..d469bdb7f4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemMediaTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [UrlInfoItem.playableMediaUrl] — the gate between "the page mentioned an `og:audio`/`og:video`" + * and "we are willing to point a media player at it". + */ +class UrlInfoItemMediaTest { + private fun item( + url: String = "https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3", + image: String = "", + audio: String = "", + audioType: String = "", + video: String = "", + videoType: String = "", + type: String = "music.song", + ) = UrlInfoItem( + url = url, + mimeType = "text/html", + image = image, + audio = audio, + audioType = audioType, + video = video, + videoType = videoType, + type = type, + ) + + @Test + fun aDeclaredAudioMimeMakesTheFilePlayable() { + val info = + item( + audio = "https://a.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp3", + audioType = "audio/mpeg", + ) + + assertEquals("https://a.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp3", info.playableMediaUrl) + assertEquals("audio/mpeg", info.playableMediaType) + } + + @Test + fun aDeclaredVideoMimeMakesTheFilePlayable() { + // nostr.build's video player page, the exact mirror of its audio one: + // https://e.nostr.build/v__mp4 carries og:video = https://v.nostr.build/.mp4 + val info = + item( + url = "https://e.nostr.build/v_ETvKzX2OdOGmFEp1avRlm5_mp4", + video = "https://v.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp4", + videoType = "video/mp4", + ) + + assertEquals("https://v.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp4", info.playableMediaUrl) + assertEquals("video/mp4", info.playableMediaType) + } + + @Test + fun youTubesEmbedDeclarationIsRefused() { + // Verified against the live page: YouTube declares og:video:url as its /embed/ page with + // og:video:type = text/html. It is markup, not a video; handing it to ExoPlayer is the + // same failure this whole change exists to stop. It must fall through to the link card. + val info = + item( + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + image = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + video = "https://www.youtube.com/embed/dQw4w9WgXcQ", + videoType = "text/html", + ) + + assertNull(info.playableMediaUrl) + } + + @Test + fun anHlsPlaylistDeclarationIsPlayable() { + // nostr.build serves HLS too, and its playlist MIME is not in the `video/` family. + val info = + item( + video = "https://v.nostr.build/x/index.m3u8", + videoType = "application/x-mpegurl", + ) + + assertEquals("https://v.nostr.build/x/index.m3u8", info.playableMediaUrl) + } + + @Test + fun aMediaExtensionCarriesAPageThatOmittedTheType() { + assertEquals( + "https://a.nostr.build/x.mp3", + item(audio = "https://a.nostr.build/x.mp3").playableMediaUrl, + ) + assertEquals( + "https://v.nostr.build/x.mp4", + item(video = "https://v.nostr.build/x.mp4").playableMediaUrl, + ) + assertNull(item(video = "https://v.nostr.build/x.mp4").playableMediaType) + } + + @Test + fun aNonMediaDeclarationIsRefused() { + // A page is free to put anything under og:audio/og:video. Pointing the player at an HTML + // page is the exact failure this whole change is about, so an untyped extensionless URL + // and a contradicting MIME both stay null and fall through to the link card. + assertNull(item(audio = "https://example.com/player.html").playableMediaUrl) + assertNull(item(video = "https://example.com/watch/12345").playableMediaUrl) + assertNull(item(audio = "https://example.com/x.mp3", audioType = "text/html").playableMediaUrl) + assertNull(item(video = "https://example.com/x.mp4", videoType = "image/png").playableMediaUrl) + } + + @Test + fun videoWinsAPageThatDeclaresBoth() { + // The audio path renders a pictureless track, so the video is the richer of the two. + val info = + item( + audio = "https://a.nostr.build/x.mp3", + audioType = "audio/mpeg", + video = "https://v.nostr.build/x.mp4", + videoType = "video/mp4", + ) + + assertEquals("https://v.nostr.build/x.mp4", info.playableMediaUrl) + assertEquals("video/mp4", info.playableMediaType) + } + + @Test + fun aRefusedVideoStillFallsBackToAPlayableAudio() { + val info = + item( + audio = "https://a.nostr.build/x.mp3", + audioType = "audio/mpeg", + video = "https://example.com/embed/x", + videoType = "text/html", + ) + + assertEquals("https://a.nostr.build/x.mp3", info.playableMediaUrl) + assertEquals("audio/mpeg", info.playableMediaType) + } + + @Test + fun aPageWithoutMediaHasNothingToPlay() { + assertNull(item().playableMediaUrl) + assertNull(item().playableMediaType) + } + + @Test + fun aRelativeMediaPathResolvesAgainstThePage() { + assertEquals( + "https://e.nostr.build/media/x.mp3", + item(url = "https://e.nostr.build/a_x_mp3", audio = "/media/x.mp3", audioType = "audio/mpeg").playableMediaUrl, + ) + assertEquals( + "https://e.nostr.build/media/x.mp4", + item(url = "https://e.nostr.build/v_x_mp4", video = "/media/x.mp4", videoType = "video/mp4").playableMediaUrl, + ) + } + + @Test + fun aTrackPageWithNoCoverArtStillCountsAsAFetchedPreview() { + // fetchComplete() gates whether UrlCachedPreviewer keeps the result or files it as Empty + // and falls back to a bare link. Playable media is evidence on its own. + val noArt = item(audio = "https://a.nostr.build/x.mp3", audioType = "audio/mpeg") + + assertEquals("", noArt.image) + assertTrue(noArt.fetchComplete()) + + assertFalse(item().fetchComplete()) + } + + @Test + fun onlyAPlayerPageTakesOverTheCard() { + // A news story that embeds a clip declares `article` and keeps its card -- headline, + // description, host, tap-through. Replacing all of that with a bare player is a strictly + // worse rendering of an article. + assertNull( + item(video = "https://example.com/clip.mp4", videoType = "video/mp4", type = "article").playableMediaUrl, + ) + // A page that declares no type at all is a document until it says otherwise. + assertNull( + item(video = "https://example.com/clip.mp4", videoType = "video/mp4", type = "").playableMediaUrl, + ) + // Every music.* and video.* subtype a media host uses is a player page. + assertEquals( + "https://example.com/clip.mp4", + item(video = "https://example.com/clip.mp4", videoType = "video/mp4", type = "video.other").playableMediaUrl, + ) + assertEquals( + "https://example.com/x.mp3", + item(audio = "https://example.com/x.mp3", audioType = "audio/mpeg", type = "music.album").playableMediaUrl, + ) + } + + @Test + fun aDocumentRelativeMediaPathResolvesToo() { + // `startsWith("/")` was the old test, so this spelling used to reach the player verbatim + // as "media/x.mp3" -- a relative string the player can only hang on. + assertEquals( + "https://e.nostr.build/dir/media/x.mp3", + item(url = "https://e.nostr.build/dir/page", audio = "media/x.mp3", audioType = "audio/mpeg").playableMediaUrl, + ) + } + + @Test + fun onlyHttpSchemesArePlayable() { + // The page is remote and untrusted; the scheme is what stops it aiming the local player + // at a local read. + assertNull(item(audio = "file:///sdcard/secret.mp3", audioType = "audio/mpeg").playableMediaUrl) + assertNull(item(audio = "content://media/external/audio/1", audioType = "audio/mpeg").playableMediaUrl) + assertNull(item(audio = "javascript:alert(1)", audioType = "audio/mpeg").playableMediaUrl) + } + + @Test + fun playableMediaTypeFollowsTheUrlItDescribes() { + // A refused page reports no type either, so a caller cannot hand a MIME to a null URL. + val refused = item(video = "https://example.com/clip.mp4", videoType = "video/mp4", type = "article") + + assertNull(refused.playableMediaUrl) + assertNull(refused.playableMediaType) + } + + @Test + fun imageResolutionIsUnchanged() { + assertEquals( + "https://e.nostr.build/poster.jpg", + item(url = "https://e.nostr.build/a_x_mp3", image = "/poster.jpg").imageUrlFullPath, + ) + } +} diff --git a/commonsUI/.gitignore b/commonsUI/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/commonsUI/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/commonsUI/ARCHITECTURE.md b/commonsUI/ARCHITECTURE.md new file mode 100644 index 0000000000..2830e8a38f --- /dev/null +++ b/commonsUI/ARCHITECTURE.md @@ -0,0 +1,64 @@ +# `commonsUI` — the Compose half of the shared layer + +`commonsUI` holds every piece of shared code that needs **Compose UI** and is +therefore useless to the headless `cli`: + +- composables (`ui/`, `/ui` — e.g. `nip64Chess/ui`, + `profile/ui` — and the historical flat `audio` renderers), +- `ImageVector` icons (`icons`, `hashtags`, `robohash`) and the icon fonts, +- theme, layouts, markdown rendering (`ui/markdown`, jvmAndroid), +- Coil (`service/image`: `CoilImageBridge` + BlurHash/ThumbHash/Base64/Blossom + fetchers), +- the `composeResources` tree (translated strings, fonts, the napplet shell + + shim files) and the generated `com.vitorpamplona.amethyst.commons.resources.Res`, +- the `@Composable` relay-client entry points (`observeUser*`, + `*FilterAssemblerSubscription`, `KeyDataSourceSubscription`), +- state holders that carry a `foundation`/`ui` type (`LevelFeedViewModel` with + its `LazyListState`, `ChatNewMessageState` with `TextFieldValue`, + `EmojiSuggestionState` with `TextFieldState`). + +It depends on `:commons` (and `:quartz`) as **`api`**, so a consumer that adds +`:commonsUI` sees the headless layer transitively. `amethyst`, `desktopApp`, +`nappletHost` and `benchmark` depend on it; `cli`, `geode`, `marmotBench` +must never. + +## Same packages as `commons` + +Every file keeps its `com.vitorpamplona.amethyst.commons.*` package. The split +is a **build-graph boundary, not a package rename**: nothing in the apps had to +change an import, and a file can move between the two modules with a `git mv`. +The rule for which module a file lives in is mechanical: + +> imports `androidx.compose.ui` / `foundation` / `material3` / `animation`, +> Coil, `org.jetbrains.compose.resources`, `Res`, `org.jetbrains.skia`, or +> declares a `@Composable` → **`commonsUI`**. Otherwise → **`commons`**. + +The only cross-module gotcha is `internal`: a `commonsUI` file cannot see an +`internal` declaration in `commons`. Make it public (or move the caller). + +The full package taxonomy — including which packages are "mixed" (logic in +`commons`, composables here, same package name) — is the table in +`commons/ARCHITECTURE.md` §2. Read that first; this file only documents what +is specific to the UI module. + +## Source sets + +| Source set | For | +|---------------|-----| +| `commonMain` | Composables, icons, theme, Coil fetchers, `composeResources`. Gated by `verifyKmpPurity` like `commons`. | +| `jvmAndroid` | Markdown renderer (`ui/markdown`), Coil-OkHttp + Blossom read-auth fetcher, the `viewModel()` helper. | +| `jvmMain` | Desktop Coil bridge (`CoilImageBridge.jvm.kt`), `compose.desktop.currentOs`. `dependsOn(jvmAndroid)` + `skikoMain`. | +| `androidMain` | Android Coil bridge. `dependsOn(jvmAndroid)`. | +| `skikoMain` | `org.jetbrains.skia` pixel helpers shared by desktop JVM + iOS (`SkiaBitmapConverter`). | +| `iosMain` | iOS Coil bridge. Compile-only spike today. | + +## Tooling that points here + +- Icon fonts: `tools/material-symbols-subset/subset.sh` and + `tools/icon-font/build_icon_font.py` read `icons/` and write + `composeResources/font/` in this module (see root `.claude/CLAUDE.md`, "Icons"). +- Translations: `crowdin.yml` and `tools/strings-migrate/` target + `commonsUI/src/commonMain/composeResources/`. +- CI: `.github/workflows/build.yml` runs `:commonsUI:jvmTest`, + `:commonsUI:verifyKmpPurity` and the iOS compile/test tasks next to the + `:commons` ones. diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts new file mode 100644 index 0000000000..0bfcd17344 --- /dev/null +++ b/commonsUI/build.gradle.kts @@ -0,0 +1,228 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable + +// `:commonsUI` is the Compose half of the shared layer: every composable, +// icon, theme, Coil fetcher and Compose-resource (strings/fonts/files) that the +// GUI front ends (Android, Desktop, iOS) render. It sits on top of `:commons` +// (headless models, state, ViewModels, relay client) and is never a dependency +// of `:cli`, which keeps Compose UI + Skiko off the CLI classpath. +// Source files keep their `com.vitorpamplona.amethyst.commons.*` packages so +// the module boundary is purely a build-graph constraint — consumers did not +// have to change a single import when the split happened. + +// Disables the Kotlin/Native compiler cache for an iOS test binary so the +// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt +// cache (see the call site in the `kotlin {}` block). The version guard makes +// Kotlin re-surface this workaround once we move past 2.4.20, so it can be +// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a +// helper because @OptIn only applies to declarations, not bare statements. +@OptIn(KotlinNativeCacheApi::class) +fun TestExecutable.disableUiKitPrebuiltCache() = + disableNativeCache( + DisableCacheInKotlinVersion.`2_4_20`, + "Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " + + "linking the iOS test binary fails under Xcode 16.4.", + ) + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) + alias(libs.plugins.jetbrainsComposeCompiler) + alias(libs.plugins.composeMultiplatform) +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + android { + namespace = "com.vitorpamplona.amethyst.commons.ui" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + androidResources.enable = true + + withHostTest { + isReturnDefaultValues = true + } + + withDeviceTest { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + // iOS targets — same compile-only spike as :commons. + iosArm64() + iosSimulatorArm64() + + // Compose Multiplatform 1.11.x ships an `org.jetbrains.compose.ui:ui-uikit` + // prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references + // the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the + // iOS *test* executable against that cache under Xcode 16.4 fails with + // ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion + // because the cached object was built for a newer simulator SDK (18.5) than + // the test binary is being linked for (14.0). Disabling the native cache for + // the iOS test binaries makes ui-uikit recompile against the active SDK, + // where the symbol resolves. See disableUiKitPrebuiltCache() above and + // https://kotl.in/disable-native-cache + targets.withType().configureEach { + binaries.withType().configureEach { + disableUiKitPrebuiltCache() + } + } + + sourceSets { + commonMain { + dependencies { + // The headless half. `api` because every composable here takes + // or returns commons types (Note, User, ViewModels, states). + api(project(":commons")) + api(project(":quartz")) + + // Compose Multiplatform + implementation(libs.jetbrains.compose.ui) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.ui.tooling.preview) + + // Lifecycle (KMP since 2.8.0). lifecycle-runtime-compose ships + // iOS variants; lifecycle-viewmodel-compose (the viewModel() + // Composable helper) is Android-only and lives in jvmAndroid. + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.runtime.compose) + + // Image loading (Coil 3 - KMP). The okhttp network fetcher is + // JVM-only and lives in jvmAndroid; iOS will pull coil-ktor + // when that target wires its actual. + implementation(libs.coil.compose) + + // LruCache (KMP-ready) + implementation(libs.androidx.collection) + + // Immutable collections + api(libs.kotlinx.collections.immutable) + + // Compose Multiplatform Resources (strings, fonts, napplet shell files) + implementation(libs.jetbrains.compose.components.resources) + + // KMP syntax highlighter (Apache-2.0) for the git code browser. + implementation(libs.highlights) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + // Shared JVM code for both Android and Desktop + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + // Coil's OkHttp network fetcher (JVM-only). iOS will use + // coil-ktor when the iOS Compose UI ships. + implementation(libs.coil.okhttp) + + // OkHttp for the Blossom read-auth Coil fetcher. + implementation(libs.okhttp) + + // Markdown rendering (richtext-commonmark). The single + // consumer (RenderMarkdown.kt) lives in jvmAndroid. + // iOS support pending Phase 3 markdown decision. + implementation(libs.markdown.commonmark) + implementation(libs.markdown.ui) + implementation(libs.markdown.ui.material3) + + // viewModel() Compose helper. AndroidX publishes this + // artifact for android/jvmStubs/linuxx64Stubs but not iOS, + // so it stays in jvmAndroid until we either swap to the + // org.jetbrains.androidx.lifecycle variant or accept a + // platform-specific ViewModel access pattern on iOS. + implementation(libs.androidx.lifecycle.viewmodel.compose) + } + } + + jvmMain { + dependsOn(jvmAndroid) + dependencies { + // Desktop-specific Compose + implementation(compose.desktop.currentOs) + implementation(libs.jetbrains.compose.ui.tooling) + } + } + + androidMain { + dependsOn(jvmAndroid) + dependencies { + // Android-specific Compose tooling + implementation(libs.androidx.ui.tooling.preview) + } + } + + // iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code. + val iosMain = + create("iosMain") { + dependsOn(commonMain.get()) + } + getByName("iosArm64Main").dependsOn(iosMain) + getByName("iosSimulatorArm64Main").dependsOn(iosMain) + + // Skiko-backed targets (desktop JVM + iOS) share pixel-format helpers + // (org.jetbrains.skia.* resolves on both through Compose). Android is + // deliberately NOT in this set — it renders through android.graphics. + val skikoMain = + create("skikoMain") { + dependsOn(commonMain.get()) + } + getByName("jvmMain").dependsOn(skikoMain) + iosMain.dependsOn(skikoMain) + + getByName("androidHostTest") { + dependencies { + implementation(libs.junit) + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.espresso.core) + } + } + } +} + +compose.resources { + publicResClass = true + // Kept on the pre-split package so `Res` imports in every consumer + // (amethyst, desktopApp, nappletHost) keep resolving unchanged. + packageOfResClass = "com.vitorpamplona.amethyst.commons.resources" + generateResClass = always +} + +// iOS purity gate — shared task, see gradle/kmp-purity.gradle.kts. +apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) diff --git a/commonsUI/consumer-rules.pro b/commonsUI/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/commonsUI/proguard-rules.pro b/commonsUI/proguard-rules.pro new file mode 100644 index 0000000000..0bf7b32616 --- /dev/null +++ b/commonsUI/proguard-rules.pro @@ -0,0 +1,24 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.vitorpamplona.quartz.** { *; } +-keep class com.vitorpamplona.amethyst.** { *; } \ No newline at end of file diff --git a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensionTest.kt b/commonsUI/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensionTest.kt similarity index 100% rename from commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensionTest.kt rename to commonsUI/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensionTest.kt diff --git a/commonsUI/src/androidMain/AndroidManifest.xml b/commonsUI/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000000..44008a4332 --- /dev/null +++ b/commonsUI/src/androidMain/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt similarity index 100% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt rename to commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt diff --git a/commons/src/commonMain/composeResources/files/napplet/shell.html b/commonsUI/src/commonMain/composeResources/files/napplet/shell.html similarity index 100% rename from commons/src/commonMain/composeResources/files/napplet/shell.html rename to commonsUI/src/commonMain/composeResources/files/napplet/shell.html diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commonsUI/src/commonMain/composeResources/files/napplet/shim.js similarity index 100% rename from commons/src/commonMain/composeResources/files/napplet/shim.js rename to commonsUI/src/commonMain/composeResources/files/napplet/shim.js diff --git a/commons/src/commonMain/composeResources/font/amethyst_icons.ttf b/commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf similarity index 100% rename from commons/src/commonMain/composeResources/font/amethyst_icons.ttf rename to commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf similarity index 100% rename from commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf rename to commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf diff --git a/commons/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ar-rSA/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml diff --git a/commons/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-bn-rBD/strings.xml rename to commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml diff --git a/commons/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml similarity index 97% rename from commons/src/commonMain/composeResources/values-cs/strings.xml rename to commonsUI/src/commonMain/composeResources/values-cs/strings.xml index 9440b51a8d..c1061e454e 100644 --- a/commons/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -205,6 +205,7 @@ Tento příspěvek byl skryt, protože zmiňuje vaše skryté uživatele nebo slova Příspěvek byl ztlumený nebo nahlášen Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací + 👀 Obrázek kanálu Odkazovaná událost nebyla nalezena Nepodařilo se dešifrovat zprávu @@ -234,6 +235,7 @@ Nahlásit zneužívání identity Nahlásit nezákonné jednání Nahlásit malware + Malware zapnul(a) %1$s sats Anonym provádí raid @@ -254,6 +256,7 @@ Blesková faktura Vypršela platnost Nabídka CLINK + Nabídka BOLT12 Komu Potvrdit platbu Částka (sats) @@ -275,7 +278,10 @@ Jiná peněženková aplikace On-chain peněženka Cashu peněženka + Lightning Nabídka CLINK + On-chain + Cashu Částka Cena stanovená nabídkou Potvrzení o zapu @@ -308,6 +314,7 @@ Tajná poznámka pro příjemce Moje skrytá zpráva Viditelná předpona + 😎 Přidat k příspěvku Článek Název kanálu @@ -321,6 +328,7 @@ Moje @tag jméno Zobrazované jméno Moje zobrazované jméno + Ostrich McAwesome Vítejte Ostrich! Uživatelské jméno O mně @@ -436,6 +444,7 @@ Skrýt Někdo, koho sledujete, nahlásil tento účet Nahlášeno + +%1$d Chyba při načítání odpovědí: Zkusit znovu Zatím žádná oznámení. @@ -617,7 +626,11 @@ Implementuje Podporované NIP prostřednictvím %1$s + NIP-%1$s Aplikace + Web + Android + iOS Trénink Název Doba trvání @@ -642,7 +655,25 @@ Sdílejte své tréninky Nechte Amethyst číst dokončené tréninky z Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) a navrhnout příspěvek. Připojit + %1$s km Z Health Connect + Co Amethyst čte + Health Connect a Amethyst + Amethyst čte dokončené tréninky, aby za vás mohl předvyplnit příspěvek o tréninku. + Amethyst je sociální klient sítě Nostr. Jeho sekce Tréninky vám umožňuje zveřejnit souhrn dokončeného tréninku na relaye Nostr, které si zvolíte, aby lidé, kteří vás sledují, viděli, co jste dělali. Místo ručního vypisování každého čísla může Amethyst načíst trénink, který vaše hodinky nebo fitness aplikace už uložily do Health Connect, a příspěvek předvyplnit. Předvyplněný příspěvek vždy uvidíte a sami rozhodnete, zda ho zveřejníte. + Co Amethyst čte a proč + Cvičení · jakou aktivitu jste dělali, kdy začala a jak dlouho trvala — to je samotný trénink a zároveň název a doba trvání příspěvku. + Vzdálenost · jak daleko jste se dostali, zobrazená jako vzdálenost běhu, jízdy, chůze nebo plavání. + Aktivní a celkové kalorie · energie spálená při tréninku. Aktivní kalorie se použijí, pokud je váš zdroj zaznamenává; celkové kalorie jsou náhradou pro zdroje, které zaznamenávají pouze celkovou energii. + Tepová frekvence · průměrná a maximální tepová frekvence během tréninku, běžné měřítko náročnosti. + Kroky · počet kroků při běhu, chůzi nebo turistice. + Převýšení · kolik jste nastoupali, což odlišuje rovinatou jízdu od kopcovité. + Co Amethyst nedělá + Čte pouze tréninky dokončené za posledních 7 dní, a to jen když je otevřený editor Tréninků. Na pozadí nečte nikdy. + Nikdy nic nezapisuje do Health Connect a nikdy nežádá o trasu vašeho cvičení, polohu ani jiný typ zdravotních údajů. + Nic neopustí váš telefon, dokud sami neklepnete na návrh a příspěvek nezveřejníte. Amethyst nemá žádný server: příspěvek jde na relaye Nostr, které jste nastavili. + Celá funkce je volitelná. Vypnete ji v Nastavení → Nastavení editoru, nebo kdykoli odeberete oprávnění v Health Connect — zbytek Amethystu funguje dál. + Přečíst si celé zásady ochrany soukromí Zatím nenalezeny žádné nSites. Obnovit Integrovaný prohlížeč vyžaduje Android 11 nebo novější. @@ -729,7 +760,9 @@ Dešifrovala zprávu Sdílela váš veřejný klíč Připojeno + Ping Vypsala relaye + %1$s Naskenujte pro připojení aplikace k vašemu klíči Naskenovat kód Místo toho vložte odkaz @@ -777,6 +810,7 @@ Zatím vás o přihlášení nepožádalo žádné relé. před %1$s Zdroj: %1$s + v%1$s Stáhnout O aplikaci Platformy @@ -823,6 +857,7 @@ Zavřít místnost Jen odejít ŽIVĚ + Chat Publikum Ruce V publiku zatím nikdo není. @@ -898,15 +933,20 @@ Dokončeno Podpořte pořad od %1$s + S%1$d · E%2$d + Ep %1$d Série %1$d + Video Přepis Kapitoly Moderátoři a hosté Nejlepší podporovatelé Moderátor Spolumoderátor + Editor Ověřený autor Streamovat sats + %1$d sats/min Automaticky posílá hodnotu, když posloucháte. V této relaci odesláno %1$d sats Název @@ -979,6 +1019,7 @@ Přidat hashtag Přidat hashtag Veřejně označte tento příspěvek hashtagem (štítek NIP-32). Lidé, kteří vás sledují, jej uvidí ve feedu daného hashtagu. + Hashtag Přidat přidal(a) Připnuté poznámky @@ -1037,6 +1078,7 @@ Zobrazí se při stisknutí tlačítka zap. Klepnutím na částku ji odeberete. Pokud ponecháte prázdné, otevře se pokaždé dialog pro zadání částky. Odeslat on-chain Dobít mint + %1$s sats Částka dobití Mint k dobití Prostředky z @@ -1248,6 +1290,7 @@ Zobrazí varovnou zprávu, když ji autor příspěvku označil jako citlivý Max. hashtagů na příspěvek Skryje příspěvky s více hashtagy, než je tento limit. Nastavte 0 pro deaktivaci + ∞ Nový symbol reakce Zap-sběr Přidá cílovou částku sats pro tento příspěvek. Podporované klienty ji mohou zobrazovat jako ukazatel postupu k podněcování darování @@ -1279,6 +1322,7 @@ Všechny jazyky Politika příspěvků Podmínky & ujednání + N/A Chyby a upozornění z tohoto relé Zprávy monitoru relé Aktivní odběry @@ -1289,6 +1333,7 @@ %1$d ID od %1$s do %1$s + limit %1$d Min. předpona Max. značek události Max. délka obsahu @@ -1309,10 +1354,12 @@ Max. filtrů na odběr Max. limit (vracené události) Max. délka SubID + Mint: %1$s Vyměnit Kopírovat token Otevřít v jiné aplikaci ŽIVĚ + OFFLINE UKONČENO PLÁNOVÁNO SOUKROMÉ @@ -1438,6 +1485,7 @@ Soukromá Pouze na pozvání ŽIVĚ + %1$d+ Zadejte platnou URL relay (wss://…). Název Popis @@ -1530,6 +1578,7 @@ Hlasy jsou váženy podle hodnoty zapu. Můžete nastavit minimální částku, abyste se vyhnuli spammerům, a maximální částku, abyste zabránili velkým zapperům, kteří by mohli ovládnout hlasování. Použijte stejnou částku v obou polích, aby byla hodnota každého hlasu stejná. Nechte prázdné pro přijetí libovolné částky. Poslat zprávu uživateli Zpráva %1$s + OK Rozdělit a přeposlat Zaps Podporované klienty budou Zapy rozdělovat a přeposílat uživatelům přidaným zde místo vám Hledat a přidat uživatele @@ -1563,6 +1612,7 @@ Poslat prodejci zprávu Ahoj, je to stále k dispozici? Název + iPhone 13 Stav Kategorie Cena (v Sats) @@ -1591,6 +1641,7 @@ Odeslat Přijmout Transakce + sats Vložte fakturu BOLT-11 Zaplatit Platba úspěšná @@ -1719,7 +1770,9 @@ Žádám mint o nabídku… Ověřit ✓ Mint je dostupný + ✓ %1$s Nelze se připojit k mintu: %1$s + Nutzap Vytvořit token Uplatnit Hotovo @@ -1754,9 +1807,11 @@ jméno, NIP-05 nebo npub Změnit příjemce Částka + sats Minimální on-chain zap je %1$s sats — menší částky pohltí poplatky těžařům. Použij raději Lightning zap. Komentář (nepovinné) Priorita + %1$s sat/vB · %2$s Načítání odhadů poplatků… Odeslat %1$s sats Nerozdělovat — zaplatit jednomu příjemci @@ -1765,6 +1820,7 @@ Transakce Poplatek Drobné + %1$s sats Hotovo Selhalo při: %1$s Platba byla odeslána (tx %1$s), ale potvrzení nebylo zveřejněno. @@ -1887,9 +1943,11 @@ Změnit pořadí Odpovědět Odpovědět na tuto poznámku + Boost Repostovat nebo citovat tuto poznámku To se mi líbí Reagovat na tuto poznámku pomocí emoji + Zap Poslat lightning platbu autorovi Sdílet Sdílet tuto poznámku externě @@ -1961,6 +2019,7 @@ %1$s smazal zprávu %1$s smazal zprávu: %2$s %1$s zahájil tuto konverzaci + %1$s: %2$s 🔊 %1$s zahájil huddle 🔊 %1$s se připojil k huddlu 🔊 %1$s opustil huddle @@ -2008,6 +2067,7 @@ Pozvánka do pracovního prostoru Připojit se k tomuto pracovnímu prostoru Pracovní prostor + Role Pracovní prostor se otevře v zabezpečeném prohlížeči v aplikaci, kde si projdete podmínky a dokončíte připojení. Přihlásí vás vaším klíčem Amethyst — bez hesla. Pokračovat v prohlížeči Toto nevypadá jako platný odkaz s pozvánkou Buzz. @@ -2028,6 +2088,7 @@ Sdílet Kopírovat Pozvánku se nepodařilo vytvořit + OK Zatím žádné konverzace Doručení zprávy Zavřít @@ -2076,6 +2137,7 @@ Rozštěpováno od Klon: Větev + Commit Základ sloučení Pull request byl aktualizován na nový commit. Úkoly @@ -2112,6 +2174,7 @@ Commity Není k dispozici žádná historie commitů. Kopírovat obsah souboru + Text Nový Nový problém Název @@ -2174,6 +2237,7 @@ Tento algoritmus zdroje vyžaduje platbu Algoritmus zdroje vrátil chybu Zkusit znovu + Offline Tento algoritmus zdroje v poslední době neposlal signál aktivity a může být nedostupný Odhlásit se na zámek zařízení Relé chatu @@ -2201,6 +2265,7 @@ Zdroj Seřadit podle Filtry + %1$s → %2$s Hledat jazyky Přidat jazyk Přidat jazykový pár @@ -2260,6 +2325,7 @@ Časový limit vypršel Opakuje se… Zavřít + %1$d/%2$d Odesílání Odesílání %1$s Odesílání %1$d událostí… @@ -2302,6 +2368,7 @@ Profil, ze kterého importovat hledání jména, npub1…, alice@example.com Podporuje npub, NIP-05, hex a Namecoin (.bit / d/ / id/) + Tip Nalezeno účtů: %1$d Vybráno: %1$d Sledovat %1$d účtů @@ -2363,6 +2430,7 @@ Vybrat přes Lightning… Exportovat jako Cashu token… Přesouvám mince… + <%1$s Připojování Stahování Chyba @@ -2372,6 +2440,7 @@ Akce profilu Akce médií Přehrávání + Auto Upravit přezdívku Viditelné pouze pro vás Odeslat do… @@ -2386,6 +2455,7 @@ Důvod (nepovinné) Kodek H.265 (lepší komprese) + H.264 H.265 není na tomto zařízení dostupný — používá se H.264. Rozlišení Zdrojové rozlišení: %1$d×%2$d @@ -2423,11 +2493,14 @@ Do Nyní Vždy + URL + https://example.com Název Název záložky Popis Krátký popis Tagy (oddělené čárkou) + nostr, tech, blog Uložit Smazat Smazat tuto webovou záložku? @@ -2436,12 +2509,15 @@ Končí %1$s On-chain dar Cílová částka (sats) + 100000 Popište svůj cíl Na co sbíráte prostředky? Krátký souhrn Stručný popis zobrazený v náhledech URL obrázku (volitelné) + https://example.com/image.jpg URL webu (volitelné) + https://example.com Nastavit termín Požádejte relé o trvalé smazání všech vašich dat do vybraného data. Tato akce je založena na NIP-62 a v některých jurisdikcích je právně závazná. Cílové relé @@ -2546,6 +2622,7 @@ Rozvést Přátelský Profesionální + + Emoji Emoji balíčky Přidat do seznamu emoji Přidat do mého seznamu emoji @@ -2574,6 +2651,7 @@ Akce emoji balíčku Soukromé Soukromé emoji + Gif Porušení pravidel komunity Jsi na seznamu blokovaných této komunity. Příspěvky nebudou přijaty. Tato komunita nepřijímá události typu %1$d. @@ -2594,6 +2672,7 @@ Rozpustit „%1$s“ pro všechny? Konverzace skončí pro každého člena a nelze ji znovu otevřít — bylo by nutné vytvořit novou skupinu. Rozpustit Odkaz na avatar + https://example.com/avatar.png Odkaz https, který načte každý člen. Ponechte prázdné, chcete-li místo toho použít nahraný obrázek. Tato skupina byla vytvořena dříve, než byly podporovány avatary z odkazu, takže může používat pouze nahraný obrázek. Stávající skupiny nelze upgradovat — pro použití odkazu vytvořte novou skupinu. Používat šifrované přílohy @@ -2601,6 +2680,7 @@ Tato skupina byla vytvořena dříve, než bylo podporováno rozpuštění, a nelze ji rozpustit. Stále ji můžete opustit nebo odebrat všechny ostatní členy. Stávající skupiny nelze upgradovat. Skupina Marmot Skupina %1$s… + %1$s… Známé Známé (%1$d) Nové žádosti @@ -2680,6 +2760,7 @@ Potřebuje vaše schválení Čeká na schválení (bez popisu) + Workflow: %1$s od čeká na Jste schvalovatel, ale toto přihlášení nemůže rozhodnutí podepsat. @@ -2707,6 +2788,7 @@ Zatím žádná workflow. Otevřete nabídku výše, zvolte „Nová definice…“, vytvořte ho a pak spusťte. Co má udělat? Spustit běh + Workflow Zatím nejsou definována žádná workflow Vyberte workflow Nová definice… @@ -2756,4 +2838,5 @@ Publikování… Publikovat personu Sleduje vás + Bot diff --git a/commons/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml similarity index 95% rename from commons/src/commonMain/composeResources/values-de-rDE/strings.xml rename to commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index 0adbb765bd..2366af052a 100644 --- a/commons/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -195,6 +195,7 @@ Dieser Beitrag wurde ausgeblendet, weil er Ihre ausgeblendeten Benutzer oder Wörter erwähnt Der Beitrag wurde als unangemessen gekennzeichnet von Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden + 👀 Kanalbild Referenziertes Ereignis nicht gefunden Nachricht konnte nicht entschlüsselt werden @@ -224,6 +225,7 @@ Identitätsbetrug melden Illegales Verhalten melden Malware melden + Malware hat %1$s Sats gezappt Anonym raidet @@ -231,6 +233,7 @@ bearbeitet Original Zitat + Fork Neuer Betrag in Sats und Gefolgt @@ -243,11 +246,13 @@ Lightning-Rechnung Abgelaufen CLINK-Zahlungsanfrage + BOLT12-Angebot An Zahlung bestätigen Betrag (Sats) Erlaubter Bereich: %1$s–%2$s Sats CLINK-Zahlungsanfrage (noffer) + Budget Ausgabenbudget Betrag (Sats) Anfordern @@ -263,8 +268,10 @@ Andere Wallet-App On-Chain-Wallet Cashu-Wallet + Lightning CLINK-Zahlungsanfrage On-Chain + Cashu Betrag Preis durch das Angebot festgelegt Zap-Quittung @@ -297,6 +304,7 @@ Geheime Notiz an Empfänger Meine versteckte Nachricht Sichtbares Präfix + 😎 Zum Beitrag hinzufügen Artikel Kanalname @@ -310,6 +318,7 @@ Mein @tag-Name Anzeigename Mein Anzeigename + Ostrich McAwesome Willkommen Ostrich! Benutzername Über mich @@ -333,6 +342,7 @@ In diesem Chat Bearbeitet die Kanalmetadaten Beitreten + Feed Mod-Warteschlange Beiträge Antworten @@ -340,6 +350,8 @@ Galerie Meldungen %1$s Meldungen + Relays + Website Lightning-Adresse Kopiert die Nsec-ID (Ihr Passwort) zur Sicherung in die Zwischenablage Sende eine Direktnachricht @@ -382,6 +394,7 @@ Ein Banner hinzufügen Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden. Diese Community wurde aufgelöst und ist jetzt schreibgeschützt. Du kannst ihren Verlauf weiterhin lesen, aber es können keine neuen Nachrichten mehr gepostet werden. + Name Über (optional) Sperren Aus dieser Community sperren? @@ -405,9 +418,11 @@ Concord-Community-Einladung Concord-Einladung (öffne den vollständigen Einladungslink zum Beitreten) Concord-Community-Anzeige + Inline Nach Community Jeden Kanal als eigene Unterhaltung anzeigen, vermischt mit deinen Chats. Die Kanäle jeder Community zu einer einzelnen Zeile zusammenfassen, platziert bei ihrer neuesten Nachricht. + Thread Bild senden verschlüsselt veraltet @@ -419,6 +434,7 @@ Ausblenden Jemand, dem du folgst, hat dieses Konto gemeldet Gemeldet + +%1$d Fehler beim Laden der Antworten: Erneut versuchen Noch keine Benachrichtigungen. @@ -564,6 +580,7 @@ Annehmen Ablehnen Aus Profil entfernen + Name Lesbarer Name des Abzeichens Beschreibung Warum wird dieses Abzeichen verliehen? @@ -578,6 +595,8 @@ Abzeichen · %1$d Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen. Du hast noch keine Abzeichen erhalten. + Apps · %1$d + Apps Empfiehl die Nostr-Apps, die du nutzt, damit andere sie entdecken können. Empfohlene Apps Wähle aus, welche Nostr-Apps du öffentlich empfiehlst. Empfehlungen erscheinen in deinem Profil und helfen anderen, Apps für Inhalte zu finden, die dieser Client nicht öffnen kann. @@ -597,6 +616,12 @@ Implementiert Unterstützte NIPs über %1$s + NIP-%1$s + App + Web + Android + iOS + Workout Titel Dauer Distanz @@ -620,7 +645,25 @@ Teile deine Workouts Lass Amethyst abgeschlossene Workouts aus Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) lesen und einen Beitrag vorschlagen. Verbinden + %1$s km Aus Health Connect + Was Amethyst liest + Health Connect und Amethyst + Amethyst liest abgeschlossene Workouts, um einen Workout-Beitrag für dich vorauszufüllen. + Amethyst ist ein sozialer Nostr-Client. Im Bereich Workouts kannst du eine Zusammenfassung eines abgeschlossenen Workouts an die Nostr-Relays deiner Wahl veröffentlichen, damit die Leute, die dir folgen, sehen können, was du gemacht hast. Statt jede Zahl von Hand einzutippen, kann Amethyst das Workout lesen, das deine Uhr oder Fitness-App bereits in Health Connect gespeichert hat, und den Beitrag vorausfüllen. Du siehst den vorausgefüllten Beitrag immer und entscheidest, ob du ihn veröffentlichst. + Was Amethyst liest und warum + Übung · welche Aktivität du gemacht hast, wann sie begann und wie lange sie dauerte — das ist das Workout selbst sowie Titel und Dauer des Beitrags. + Distanz · wie weit du gekommen bist, angezeigt als Distanz des Laufs, der Fahrt, des Spaziergangs oder des Schwimmens. + Aktive und gesamte Kalorien · die beim Workout verbrannte Energie. Aktive Kalorien werden verwendet, wenn deine Quelle sie aufzeichnet; gesamte Kalorien sind der Ersatz für Quellen, die nur die Gesamtenergie aufzeichnen. + Herzfrequenz · die durchschnittliche und maximale Herzfrequenz während des Workouts, das übliche Maß für die Anstrengung. + Schritte · die Schrittzahl eines Laufs, Spaziergangs oder einer Wanderung. + Höhenmeter · wie viel du gestiegen bist, was eine flache Fahrt von einer hügeligen unterscheidet. + Was Amethyst nicht tut + Liest nur Workouts, die in den letzten 7 Tagen beendet wurden, und nur während der Workout-Editor geöffnet ist. Im Hintergrund wird nie gelesen. + Schreibt nie etwas in Health Connect und fragt nie nach deiner Trainingsroute, deinem Standort oder anderen Gesundheitsdaten. + Nichts verlässt dein Telefon, bis du auf einen Vorschlag tippst und den Beitrag selbst veröffentlichst. Amethyst hat keinen Server: Der Beitrag geht an die Nostr-Relays, die du eingerichtet hast. + Die gesamte Funktion ist optional. Schalte sie unter Einstellungen → Editor-Einstellungen aus oder entziehe die Berechtigungen jederzeit in Health Connect — der Rest von Amethyst funktioniert weiter. + Vollständige Datenschutzerklärung lesen Noch keine nSites gefunden. Neu laden Der In-App-Browser benötigt Android 11 oder neuer. @@ -695,6 +738,7 @@ Amethyst hält eine Hintergrundverbindung (als laufende Benachrichtigung angezeigt), um Signaturanfragen auch im geschlossenen Zustand beantworten zu können. Für andere Apps signieren Signer einschalten + Live Diese für jede App merken als %1$s %1$d zulassen @@ -706,7 +750,9 @@ Eine Nachricht entschlüsselt Deinen öffentlichen Schlüssel geteilt Verbunden + Ping Relays aufgelistet + %1$s Scannen, um eine App mit deinem Schlüssel zu verbinden Einen Code scannen Stattdessen einen Link einfügen @@ -754,6 +800,7 @@ Noch hat dich kein Relay zur Anmeldung aufgefordert. vor %1$s Quelle: %1$s + v%1$s Herunterladen Über Plattformen @@ -791,6 +838,7 @@ Im Raum ist gerade niemand. %1$d hört zu Gastgeber + Moderator Mikrofon stumm Spricht gerade Mikrofon offen @@ -798,6 +846,8 @@ Du bist der Gastgeber. Wenn du den Raum schließt, werden alle getrennt. Wähle "Nur verlassen", wenn du später zurückkehren möchtest — der Raum wird nach 8 Stunden Inaktivität automatisch geschlossen. Raum schließen Nur verlassen + LIVE + Chat Publikum Hände Noch niemand im Publikum. @@ -859,6 +909,7 @@ Relay-URL (WebTransport) Auth-URL (JWT-Mint) Hinzufügen + Relay Authentifizierung Amethyst-Standards verwenden Server hinzufügen @@ -866,13 +917,16 @@ Noch keine Nest-Server. Füge unten einen hinzu oder wähle einen empfohlenen Server. Folgen ansehen Noch keine Folgen gefunden + Trailer Staffel %1$d Anstößig Abgeschlossen Die Show unterstützen von %1$s + S%1$d · E%2$d Folge %1$d Staffel %1$d + Video Transkript Kapitel Hosts & Gäste @@ -895,6 +949,7 @@ Themen durch, Komma, getrennte, Tags Audio-URL + https://…/episode.mp3 Audiodatei bereit zum Hochladen Episode löschen Diese Episode löschen? Das kann nicht rückgängig gemacht werden. @@ -918,6 +973,7 @@ Nostr-Nutzer hinzufügen Nach Name oder @Handle suchen Empfänger entfernen + Name (optional) Lightning-Adresse Node (Keysend) Lightning-Adresse @@ -930,6 +986,7 @@ Titelname Künstler Name des Künstlers + Album (optional) Dauer in Sekunden (optional) Songtext oder Credits (optional) Diesen Titel löschen @@ -945,12 +1002,14 @@ Öffentliche Lesezeichen Repositorys Deine gespeicherten Git-Repositorys + Podcasts Deine gespeicherten Podcasts und Episoden Zu den privaten Lesezeichen hinzufügen Aus den privaten Lesezeichen entfernen Hashtag hinzufügen Hashtag hinzufügen Markiere diesen Beitrag öffentlich mit einem Hashtag (NIP-32-Label). Wer dir folgt, sieht ihn im Feed dieses Hashtags. + Hashtag Hinzufügen hinzugefügt von Angeheftete Beiträge @@ -1221,6 +1280,7 @@ Zeigt eine Warnmeldung an, wenn der Autor des Beitrags ihn als sensibel markiert hat Max. Hashtags pro Beitrag Blendet Beiträge mit mehr Hashtags als dieses Limit aus. Auf 0 setzen zum Deaktivieren + ∞ Neues Reaktionssymbol Zap-Sammlung Fügt einen Zielbetrag in Sats für diesen Beitrag hinzu. Unterstützende Clients können dies als Fortschrittsbalken anzeigen, um Spenden zu fördern @@ -1230,6 +1290,7 @@ Inhaber Verwendet von Service-Schlüssel + Version Programme Kontakt Unterstützte NIPs @@ -1251,6 +1312,7 @@ Alle Sprachen Veröffentlichungsrichtlinie Allgemeine Geschäftsbedingungen + N/A Fehler und Hinweise von diesem Relay Relay-Überwachungsberichte Aktive Abonnements @@ -1286,6 +1348,8 @@ Einlösen Token kopieren In anderer App öffnen + LIVE + OFFLINE BEENDET GEPLANT PRIVAT @@ -1365,6 +1429,7 @@ Am besten für Gut Kompromisse + Inline Nach Relay Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats. Fasst die Gruppen jedes Relays in einer einzigen Zeile zusammen, platziert bei seiner neuesten Nachricht. @@ -1382,6 +1447,7 @@ Beitreten Angefragt Administrator + Moderator Mitglied Mitglieder Zum Admin machen @@ -1392,6 +1458,7 @@ %1$s aus dieser Gruppe entfernen? Sie verlieren den Zugriff, bis sie erneut hinzugefügt oder eingeladen werden. Mitglieder Gruppe bearbeiten + Threads Nachricht anheften Nachricht loslösen Angeheftet @@ -1403,7 +1470,10 @@ Einladung wird vorbereitet… Privat Nur mit Einladung + LIVE + %1$d+ Gib eine gültige Relay-URL ein (wss://…). + Name Beschreibung Gruppenbild Foto hinzufügen @@ -1494,6 +1564,7 @@ Die Abstimmungen werden nach der Höhe des Zaps gewichtet. Du kannst einen Mindestbetrag festlegen, um Spam zu verhindern, und einen Höchstbetrag, um zu verhindern, dass große Zapper die Abstimmung dominieren. Verwende denselben Betrag in beiden Feldern, um sicherzustellen, dass jeder Stimme der gleiche Wert zukommt. Lass die Felder leer, um jeden Betrag zu akzeptieren. Mit dem Benutzer kommunizieren Nachricht %1$s + OK Zaps aufteilen und weiterleiten Unterstützende Clients werden Zaps an die hier hinzugefügten Benutzer aufteilen und weiterleiten, anstatt an dich Benutzer suchen und hinzufügen @@ -1527,6 +1598,7 @@ Dem Verkäufer eine Nachricht senden Hallo, ist das noch verfügbar? Titel + iPhone 13 Zustand Kategorie Preis (in Sats) @@ -1572,6 +1644,7 @@ Gesendet Aktualisieren Alle + Zaps Keine Zaps Wallet hinzufügen Standard @@ -1617,10 +1690,12 @@ Lege zuerst deine Haupt-Wallet fest, übertrage dann die Guthaben aus den älteren. Deine Wallet wird eingerichtet… Erneut suchen + Mints: %1$s Wallet erstellt Wähle nun ein paar Mints, die deine Sats verwahren. Mints auswählen Guthaben + Mints Mint-URL Mint entfernen Mint hinzufügen @@ -1681,7 +1756,9 @@ Frage Mint nach einem Angebot… Prüfen ✓ Mint ist erreichbar + ✓ %1$s Mint nicht erreichbar: %1$s + Nutzap Token erstellen Einlösen Fertig @@ -1716,9 +1793,11 @@ Name, NIP-05 oder npub Empfänger ändern Betrag + sats Der minimale On-Chain-Zap beträgt %1$s sats — kleinere Beträge werden von den Miner-Gebühren aufgezehrt. Nutze stattdessen einen Lightning-Zap. Kommentar (optional) Priorität + %1$s sat/vB · %2$s Gebührenschätzungen werden geladen… %1$s sats senden Nicht aufteilen — stattdessen einen Empfänger bezahlen @@ -1727,6 +1806,7 @@ Transaktion Gebühr Wechselgeld + %1$s sats Fertig Fehlgeschlagen bei: %1$s Zahlung wurde gesendet (tx %1$s), aber die Quittung wurde nicht veröffentlicht. @@ -1785,6 +1865,7 @@ Beschreibung Ein Titel ist erforderlich. Noch keine Termine in diesem Kalender. + RSVPs (%1$d) Noch keine Antworten. Teilnehmer (%1$d) In Kalendern (%1$d) @@ -1819,6 +1900,7 @@ Eine kurze Beschreibung deines Artikels\u2026 URL-Slug artikel-url-slug + # Tags Tag hinzufügen\u2026 Beginne deinen Artikel zu schreiben\u2026 Antworten @@ -1847,9 +1929,11 @@ Neu anordnen Antworten Auf diese Notiz antworten + Boost Diese Notiz reposten oder zitieren Gefällt mir Mit einem Emoji auf diese Notiz reagieren + Zap Eine Lightning-Zahlung an den Autor senden Teilen Diese Notiz extern teilen @@ -1921,6 +2005,7 @@ %1$s hat eine Nachricht gelöscht %1$s hat eine Nachricht gelöscht: %2$s %1$s hat diese Unterhaltung begonnen + %1$s: %2$s 🔊 %1$s hat einen Huddle gestartet 🔊 %1$s ist dem Huddle beigetreten 🔊 %1$s hat den Huddle verlassen @@ -1933,9 +2018,11 @@ ⚠ Job-Fehler ▲ %1$s hat einen Beitrag hochgestuft ▼ %1$s hat einen Beitrag herabgestuft + Canvas In diesem Workspace wurde noch kein Canvas geteilt. Canvas bearbeiten Canvas speichern + Canvas (Markdown) Nachricht bearbeiten (bearbeitet) %1$s tippt… @@ -1955,6 +2042,7 @@ Jemanden zu dieser DM hinzufügen Kein gültiger npub- oder Hex-Schlüssel Nur du + Workspace An Jemanden hinzufügen (npub oder hex) Nach Name, NIP-05 oder npub suchen @@ -1964,6 +2052,7 @@ Entfernen Workspace-Einladung Diesem Workspace beitreten + Workspace Rolle Du öffnest den Workspace in einem sicheren In-App-Browser, um die Bedingungen zu prüfen und den Beitritt abzuschließen. Die Anmeldung erfolgt mit deinem Amethyst-Schlüssel — ohne Passwort. Im Browser fortfahren @@ -1985,6 +2074,7 @@ Teilen Kopieren Einladung konnte nicht erstellt werden + OK Noch keine Unterhaltungen Nachrichtenzustellung Schließen @@ -2027,10 +2117,13 @@ Jetzt spenden wurde Ihnen präsentiert von: Diese Version wurde Ihnen präsentiert von: + Version %1$s Vielen Dank! Eingeschränkte Schriften Geforkt von Klonen: + Branch + Commit Merge-Basis Der Pull-Request wurde auf einen neuen Commit aktualisiert. Tickets @@ -2039,6 +2132,8 @@ Geschlossen & erledigt Alle Repository als Lesezeichen speichern + Branches + Tags Dateien Aktualisiert Letzte Aktivität @@ -2047,6 +2142,7 @@ Im Browser öffnen Ohne Titel Persönlicher Fork + Code Repository wird geladen… Das Repository konnte nicht von seiner Klon-URL geladen werden. Diese Repository-Ankündigung enthält keine http(s)-Klon-URL zum Durchsuchen. @@ -2056,10 +2152,15 @@ Dieser Ordner ist leer. Root Erneut versuchen + default + Branches + Tags Dateien durchsuchen… Keine passenden Dateien. + Commits Kein Commit-Verlauf verfügbar. Dateiinhalt kopieren + Text Neu Neues Issue Titel @@ -2077,6 +2178,7 @@ Änderungen erneut laden Überarbeitet Repository bearbeiten + Name Beschreibung Klon-URLs (eine pro Zeile) Web-URLs (eine pro Zeile) @@ -2121,6 +2223,7 @@ Dieser Feed-Algorithmus erfordert eine Zahlung Der Feed-Algorithmus hat einen Fehler zurückgegeben Wiederholen + Offline Dieser Feed-Algorithmus hat in letzter Zeit kein Lebenszeichen gesendet und ist möglicherweise nicht erreichbar Beim Sperren des Geräts abmelden Chat-Relays @@ -2131,6 +2234,7 @@ Personen Beiträge Lokal + Relays Nur Gefolgte Neueste Älteste @@ -2147,6 +2251,7 @@ Quelle Sortieren nach Filter + %1$s → %2$s Sprachen suchen Sprache hinzufügen Sprachpaar hinzufügen @@ -2206,6 +2311,7 @@ Zeitüberschreitung Erneuter Versuch… Schließen + %1$d/%2$d Senden Sende %1$s Sende %1$d Ereignisse… @@ -2280,6 +2386,7 @@ Synchronisierung starten Trotzdem starten (mobile Daten) Abbrechen + Relays: %1$d / %2$d Ereignisse verteilt: %1$d neue von %2$d gesendeten und %3$d empfangenen Synchronisierung abgeschlossen %1$d Ereignisse an Ziel-Relays weitergeleitet von %2$d empfangenen. @@ -2287,6 +2394,9 @@ Abgeschlossen in %1$d Sekunden. Synchronisierungsfehler Senden an + Outbox + Inbox + DMs Wird geprüft (%1$d Relays) Abgeschlossen (%1$d Relays) ges. %1$s @@ -2306,6 +2416,7 @@ Über Lightning abheben… Als Cashu-Token exportieren… Coins werden verschoben… + <%1$s Verbinden Herunterladen Fehler @@ -2315,6 +2426,7 @@ Profilaktionen Medienaktionen Wiedergabe + Auto Spitznamen bearbeiten Nur für dich sichtbar Streamen an… @@ -2327,7 +2439,9 @@ Beschreibung Worum geht es in diesem Video? Grund (optional) + Codec H.265 (bessere Komprimierung) + H.264 H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. Versionen Quellauflösung: %1$d×%2$d @@ -2365,12 +2479,14 @@ Bis Jetzt Gesamter Zeitraum + URL https://beispiel.com Titel Lesezeichen-Titel Beschreibung Eine kurze Beschreibung Tags (kommagetrennt) + nostr, tech, blog Speichern Löschen Dieses Web-Lesezeichen löschen? @@ -2379,11 +2495,13 @@ Endet %1$s On-Chain-Spende Zielbetrag (Sats) + 100000 Beschreibe dein Ziel Wofür sammelst du Geld? Kurze Zusammenfassung Kurze Beschreibung für Vorschauen Bild-URL (optional) + https://example.com/image.jpg Website-URL (optional) https://beispiel.com Frist festlegen @@ -2490,6 +2608,7 @@ Ausführen Freundlich Professionell + + Emoji Emoji-Pakete Zur Emoji-Liste hinzufügen Zu meiner Emoji-Liste hinzufügen @@ -2518,6 +2637,7 @@ Aktionen für Emoji-Paket Privat Privates Emoji + Gif Verstoß gegen Community-Regeln Du stehst auf der Sperrliste dieser Community. Beiträge werden nicht akzeptiert. Diese Community akzeptiert keine Ereignisse vom Typ %1$d. @@ -2538,6 +2658,7 @@ „%1$s“ für alle auflösen? Die Unterhaltung endet für jedes Mitglied und kann nicht wieder geöffnet werden — es müsste eine neue Gruppe erstellt werden. Auflösen Avatar-Link + https://example.com/avatar.png Ein https-Link, den jedes Mitglied laden kann. Leer lassen, um stattdessen das hochgeladene Bild zu verwenden. Diese Gruppe wurde erstellt, bevor Link-Avatare unterstützt wurden, und kann daher nur ein hochgeladenes Bild verwenden. Bestehende Gruppen können nicht aktualisiert werden — erstelle eine neue Gruppe, um einen Link zu verwenden. Verschlüsselte Anhänge verwenden @@ -2545,6 +2666,7 @@ Diese Gruppe wurde erstellt, bevor das Auflösen unterstützt wurde, und kann nicht aufgelöst werden. Du kannst sie trotzdem verlassen oder alle anderen Mitglieder entfernen. Bestehende Gruppen können nicht aktualisiert werden. Marmot-Gruppe Gruppe %1$s… + %1$s… Bekannt Bekannt (%1$d) Neue Anfragen @@ -2604,6 +2726,7 @@ Administratorrechte widerrufen Administratorrechte widerrufen Administratorrechte von "%1$s" widerrufen? Diese Person bleibt Mitglied der Gruppe. + Relays noch keine Ereignisse letztes Ereignis%1$s Kopieren @@ -2612,6 +2735,7 @@ Rechnung vom DVM bezahlen %1$s sats an das DVM zahlen Standort-Chat öffnen + Backlog Workflow-Läufe Agentenarbeit Neuer Lauf @@ -2622,6 +2746,7 @@ Benötigt deine Genehmigung Wartet auf Genehmigung (keine Beschreibung) + Workflow: %1$s von wartet auf Du bist der Genehmigende, aber diese Anmeldung kann keine Entscheidung signieren. @@ -2649,11 +2774,13 @@ Noch keine Workflows. Öffne das Menü oben und wähle „Neue Definition…“, um einen zu erstellen, und löse ihn dann aus. Was soll er tun? Lauf auslösen + Workflow Noch keine Workflows definiert Workflow auswählen Neue Definition… Neue Workflow-Definition Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur. + Name build-und-test YAML-Rezept Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst. @@ -2697,4 +2824,5 @@ Wird veröffentlicht… Persona veröffentlichen Folgt dir + Bot diff --git a/commons/src/commonMain/composeResources/values-de/strings.xml b/commonsUI/src/commonMain/composeResources/values-de/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-de/strings.xml rename to commonsUI/src/commonMain/composeResources/values-de/strings.xml diff --git a/commons/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-el-rGR/strings.xml rename to commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml diff --git a/commons/src/commonMain/composeResources/values-en-rGB/strings.xml b/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-en-rGB/strings.xml rename to commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml diff --git a/commons/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-eo-rUY/strings.xml rename to commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml diff --git a/commons/src/commonMain/composeResources/values-eo/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-eo/strings.xml rename to commonsUI/src/commonMain/composeResources/values-eo/strings.xml diff --git a/commons/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-es-rES/strings.xml rename to commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml diff --git a/commons/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-es-rMX/strings.xml rename to commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml diff --git a/commons/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-es-rUS/strings.xml rename to commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml diff --git a/commons/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-es/strings.xml rename to commonsUI/src/commonMain/composeResources/values-es/strings.xml diff --git a/commons/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-fa-rIR/strings.xml rename to commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml diff --git a/commons/src/commonMain/composeResources/values-fa/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-fa/strings.xml rename to commonsUI/src/commonMain/composeResources/values-fa/strings.xml diff --git a/commons/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-fi-rFI/strings.xml rename to commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml diff --git a/commons/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-fr-rCA/strings.xml rename to commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml diff --git a/commons/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-fr-rFR/strings.xml rename to commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml diff --git a/commons/src/commonMain/composeResources/values-fr/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-fr/strings.xml rename to commonsUI/src/commonMain/composeResources/values-fr/strings.xml diff --git a/commons/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml similarity index 96% rename from commons/src/commonMain/composeResources/values-hi-rIN/strings.xml rename to commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 76f89f5fcf..a5d584bfc5 100644 --- a/commons/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -139,6 +139,10 @@ बिर्डेक्स। %1$d प्रजाति बिर्डेक्स। %1$d प्रजातियाँ + + स %1$d अधिक + स %1$d अधिक + पीएस१ स्मृति पत्रक अभिलेखन खण्ड %1$d रिक्त छेद @@ -238,9 +242,11 @@ %1$s अनुचर निर्गमनांकन अधिक दिखाएँ + अल्प दिखाएँ लैटनिंग चालान समयसीमा समाप्त क्लिंक॰ प्रस्ताव + बोल्ट१२ प्रस्ताव को भुगतान पुष्टि मात्रा (साट्स) @@ -643,6 +649,23 @@ संयोजन %1$s सहस्रमीटर स्वास्थ्य संयोजन से + अमेथिस्ट क्या पढता है + स्वास्थ्य संयोजन तथा अमेथिस्ट + अमेथिस्ट सम्पन्न व्यायामों को पढता है जिससे आपके लिए एक व्यायाम पत्र पूर्वतः भरा जा सके। + अमेथिस्ट एक नोस्टर समूह ग्राहक है। इसका व्यायम विभाग आपको एक सम्पन्न व्यायाम का साराम्श प्रकाशित करने देता है उन नोस्टर पुनःप्रसारकों में जिन्हें आप चुनते है। जिससे आपके अनुगामी देख सकेंगे कि आपने क्या किया। प्रत्येक संख्या को हाथ से लिखने के स्थान पर अमेथिस्ट उस व्यायाम को पढ सकता है जिसको आपकी घडी अथवा स्वास्थ्य क्रमक ने स्वास्थ्य संयोजन में अभिलेखन कर के रखा है तथा पत्र को पूर्वतः भर सके। सर्वदा आप उस पूर्व भरा हुआ पत्र पहले देख सकेंगे तथा प्रकाशन करने का निर्णय ले सकते हैं। + अमेथिस्ट क्या पढता है और क्यों + व्यायाम। आप ने कौनसी क्रिया की। वह कब आरम्भ हुआ तथा कितने समय तक चला। यह व्यायाम स्वयम है। तथा पत्र का शीर्षक तथा कालावधि। + दूरी। आप कितने दूर गए। धवन चालन चलन तरण की दूरी के रूप में दिखाया जाएगा। + सक्रिय तथा कुल उष्ममान। ऊर्जा जिसका व्यय व्यायाम से किया गया। सक्रिय उष्ममान उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममान विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं। + हृदयस्पन्दन गति। मध्यमान तथा उच्चतम हृदयस्पन्दन गति व्यायाम के समय। मानक परिमाण कि श्रम कितना कठिन था। + पद। पदों की गणना एक धवन चलन अथवा पदयात्रा की। + ऊँचाई वृद्धि। आप कितना ऊपर चढे। जो कि पृथकता है एक समतल चालन का एक पर्वतीय चालन से। + अमेथिस्ट क्या नहीं करता + केवल पिछले सात दिनों में सम्पन्न व्यायामों को पढता है। तथा तब ही जब व्यायाम सम्पादक खुला है। पृष्ठभूत होकर कभी पढता नही। + स्वास्थ्य संयोजन पर कुछ भी लिखता नहीं। तथा आपको कभी भी नहीं पूछता आपका व्यायाम मार्ग अथवा स्थल अथवा अन्य किसी भी स्वास्थ्य जानकारी प्रकार के विषय में। + आपके संचारयन्त्र से तब तक कुछ नहीं जाएगा जब तक आप एक सुझाव नहीं दबाते तथा पत्र का प्रकाशन आप स्वयम नहीं करते। अमेथिस्ट का कोई सेवासंगणक नहीं। पत्र उन नोस्टर पुनःप्रसारकों को जाएगा जिनको आपने समाकृत कर रखे हैं। + यह सम्पूर्ण सुविधा विकल्पात्मक है। इसे निष्क्रिय करें स्थापना विकल्पों में सम्पादन पटल में। अथवा स्वास्थ्य संयोजन में अनुमतियाँ का निराकरण करें कभी भी। अमेथिस्ट के शेष भाग कार्य करते रहेंगे। + सम्पूर्ण गोपनीयता नीति पढें कोई नोस्टरस्थान प्राप्त नहीं अब तक। पुनःआवहन क्रमकाभ्यन्तर जालवीक्षक के लिए आण्ड्रोइड ग्यारह अथवा नवीनतर आवश्यक। @@ -1277,6 +1300,8 @@ प्रवेशन ग्राहकता प्रकाशन + शीर्षक रहित विभाग + विषयवस्तु भुगतान जालपता लक्ष्य दर्शक नीतियाँ तथा जालयोजक @@ -1371,6 +1396,15 @@ बाहरी संसाधन पर टिप्पणी जालवीक्षक मे खोलें %1$d मिन॰ पठन + %2$d में से %1$d पुनःप्रसारकों की प्रतीक्षा में + + %1$d विभाग + %1$d विभाग + + + %1$d विभाग अधिक + %1$d विभाग अधिक + सहयोगात्मक निजी अज्ञात अभिलेख @@ -2191,6 +2225,8 @@ इस सूचनावली कलनविधि के लिए भुगतान आवश्यक सूचनावली कलनविधि ने एक अपक्रम लौटाया पुनःप्रयास + असंयोजित + यह सूचनावली कलनविधि निकटकाल में कोई हृदयस्पन्दन संकेत नहीं भेजी तथा निष्क्रिय हो सकती है यन्त्र ताला लगने पर निर्गमनांकन करें चर्चा पुनःप्रसारक चित्र बाँटें… @@ -2207,6 +2243,12 @@ प्रासंगिकता लोकप्रिय छलनियाँ + कोई परिणाम प्राप्त नहीं + अल्पतर छलनियों का उपयोग करें अथवा पृथक शब्द। + कुछ टंकण करें ढूँढने के लिए + ऊपर के छलनियाँ खोज को संकीर्ण करते हैं। वे स्वयम खोजते नहीं। + निकटकाल के खोज + रिक्त पुनःस्थापन स्रोत इसके अनुसार क्रमबद्ध करें @@ -2607,6 +2649,23 @@ समूह बनाएँ मार्मोट॰ समूह बनाएँ एक नया एमएलएस॰ समूह बनेगा। आप सदस्य जोड सकते हैं तत्पश्चात। + नश्वर सन्देश + निष्क्रिय + एक घण्टा + एक दिन + एक सप्ताह + सन्देश अदृश्य हो जाते हैं %1$s पश्चात + सन्देशों को मिटाया जाएगा सभी सदस्यों के यन्त्रों से इस कालावधि पश्चात। यह आगे परिवर्तनीय नहीं। + समूह विघटन करें + क्या %1$s को सभी के लिए विघटित किया जाए। संवाद का अन्त होगा सब के लिए तथा पुनः खोला नहीं जा सकता। एक नया समूह का निर्माण करना पडेगा। + विघटन + परिचायक चित्र योजक + https://example.com/avatar.png + एक एचटीटीपीएस॰ योजक जिसका आवहन सभी सदस्य कर सकते है। इसके स्थान पर आरोहित चित्र का उपयोग करने के लिए इसे रिक्त छोड दें। + इस समूह का निर्माण किया गया था परिचायकचित्र योजकों का अवलम्बन पूर्व। इसीलिए यह केवल आरोहित चित्र का उपयोग कर सकता है। विद्यमान समूहों का उन्नतीकरण नहीं हो सकता। एक नया समूह बनाएँ योजकों का उपयोग करने के लिए। + रहस्यीकृत संलग्न अभिलेखों का उपयोग करें + इस समूह में संलग्न अभिलेख पुराने प्रारूप का उपयोग करते हैं। परिवर्तन से रहस्यीकृत श्रव्यदृश्याभिलेख अवयव जोडा जा सकता है समूह में। तथा सभी सदस्य इस परिवर्तन को देख सकेंगे। इसको पूर्ववत नहीं किया जा सकता। + इस समूह का निर्माण किया गया था विघटन का अवलम्बन पूर्व तथा इसका विघटन नहीं किया जा सकता। तो भी आप इसे छोड सकते हैं अथवा अन्य सभी सदस्यों को हटा सकते हैं। विद्यमान समूहों का उन्नतीकरण नहीं हो सकता। मार्मोट॰ समूह समूह %1$s… %1$s… @@ -2627,6 +2686,21 @@ + %1$s ने %2$s को जोडा + %1$s जुडे + %1$s ने %2$s को हटाया + %1$s को हटाया गया + %1$s चले गए + %1$s ने %2$s को प्रशासक बनाया + %1$s अब एक प्रशासक हैं + %1$s ने %2$s को प्रशासक पद से हटाया + %1$s अब एक प्रशासक नहीं हैं + %1$s ने समूह का नाम %2$s रखा + समूह का नाम %1$s रखा गया + %1$s ने समूह परिचायकचित्र का परिवर्तन किया + समूह परिचायकचित्र का परिवर्तन हुआ + %1$s ने समूह का विघटन किया + समूह का विघटन हुआ चित्र हटाएँ कुंचिकापोटली पुनःप्रसारक स्थापित नहीं आपकी कोई कुंचिकापोटली पुनःप्रसारक सूची नहीं अब तक (मिप॰००)। यह सूची अन्य लोगों को बताता है आपकी कुंचिकापोटली कहाँ प्रकाशित है जिससे कि वे आपको समूह चर्चाओं में आमन्त्रित कर सके।\n\nक्या इसके लिए आपके वर्तमान निर्गतपेटिका पुनःप्रसारकों का उपयोग करें। diff --git a/commons/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml similarity index 96% rename from commons/src/commonMain/composeResources/values-hu-rHU/strings.xml rename to commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index bbfbcf1ccf..6f63c998ab 100644 --- a/commons/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -246,6 +246,7 @@ Lightning-számla Lejárt CLINK ajánlat + BOLT12-ajánlat Neki: Kifizetés megerősítése Összeg (satoshiban) @@ -649,6 +650,23 @@ Kapcsolódás %1$s km Health Connectből + Mihez fér hozzá az Amethyst + Health Connect és Amethyst + Az Amethyst beolvassa a befejezett edzéseket, így előre kitöltheti Önnek az edzésről szóló bejegyzéseit. + Az Amethyst egy kliens a Nostr közösségi hálózathoz. Az „Edzések” menüpontjában közzéteheti egy befejezett edzésének az összefoglalóját a kiválasztott Nostr-átjátszókon, így a követői láthatják, hogy Ön mit csinál. Ahelyett, hogy minden adatot kézzel beírna, az Amethyst beolvashatja az órája vagy a fitneszalkalmazása által a Health Connectbe már elmentett edzésadatokat, és előre kitöltheti a bejegyzést. Ön mindig a már kitöltött teljes bejegyzést látja, és dönthet arról, hogy közzéteszi-e. + Mihez fér hozzá az Amethyst, és miért + Edzés · milyen tevékenységet végzett, mikor kezdődött és meddig tartott; ez maga az edzés, valamint a bejegyzés címe és időtartama. + Távolság · a megtett út hossza (futás, kerékpározás, gyaloglás vagy úszáskor). + Aktív és összes kalória · az edzés közben elégetett energia. Az aktív kalóriaértékek akkor használandók, ha az adatforrás rögzíti őket; az összes kalóriaérték pedig tartalék megoldásként szolgál azon források esetében, amelyek csak a teljes energiafelhasználást rögzítik. + Pulzus · az edzés alatti átlagos és maximális pulzus; az erőkifejtés intenzitásának szokásos mérőszáma. + Lépések · futás, séta vagy túra során megtett lépések száma. + Szintemelkedés · mekkora szintkülönbséget küzdött le; ez különbözteti meg a sík terepen zajló tekerést a dombos útvonaltól. + Mit nem csinál az Amethyst + Csak az elmúlt 7 napban befejezett edzéseket olvassa be, és kizárólag akkor, ha az edzésszerkesztő meg van nyitva. Soha nem olvas be adatokat a háttérben. + Soha nem ír semmit a Health Connectbe, és soha nem kéri le az edzési útvonalat, a tartózkodási helyet vagy bármilyen egyéb egészségügyi adatot. + Semmi sem hagyja el az Ön eszközét, amíg rá nem koppint egy javaslatra, és saját maga közzé nem teszi a bejegyzést. Az Amethyst nem használ kiszolgálót: a bejegyzés az Ön által beállított Nostr-átjátszókra kerül. + Egyetlen funkció sem kötelező. Bármikor kikapcsolhatja a Beállítások → Írás menüpontban, vagy visszavonhatja a Health Connectben megadott engedélyeket; az Amethyst többi része továbbra is működni fog. + Olvassa el a teljes adatvédelmi nyilatkozatot Még nem találhatók nOldalak. Újratöltés Az alkalmazáson belüli böngészőhöz Android 11 vagy újabb verzió szükséges. @@ -1379,6 +1397,7 @@ Megjegyzés egy külső forráshoz Megnyitás böngészőben Olvasási idő: %1$d perc + Várakozás %2$d / %1$d átjátszóra %1$d szakasz %1$d szakasz @@ -2207,6 +2226,8 @@ Ez a írfolyam-algoritmus előfizetést igényel A Hírfolyam-algoritmus hibával tért vissza Újra + Offline + Ez a hírfolyam-algoritmus az utóbbi időben nem küldött magáról „életjelet”, és elképzelhető, hogy leállt Kijelentkeztetés az eszköz zárolása esetén Csevegési átjátszó Kép megosztása… @@ -2223,6 +2244,12 @@ Relevancia Népszerű Szűrők + Nem találhatók eredmények + Próbálkozzon kevesebb szűrővel vagy egy másik szóval. + Keresés… + A fenti szűrők szűkítik a keresést, de önmagukban nem végzik azt. + Legutóbbi keresések + Törlés Visszaállítás Forrás Rendezés @@ -2623,6 +2650,23 @@ Csoport létrehozása Marmot csoport létrehozása Egy új MLS-csoport jön létre. A tagokat később adhatja hozzá. + Eltűnő üzenetek + Ki + 1 óra + 1 nap + 1 hét + Az üzenetek eltűnnek %1$s után + Ennyi idő elteltével az üzenetek minden tag eszközéről törlődnek. Ez a művelet nem vonaható vissza. + Csoport feloszlatása + Feloszlatja a(z) „%1$s” nevű csoportot mindenki számára? A beszélgetés minden tag számára befejeződik, és nem nyitható újra, és ebben az esetben új csoportot kell létrehozni. + Feloszlatás + Profilkép hivatkozása + https://peldaul.hu/profilkep.png + Egy https-hivatkozás, amelyet minden tag be tud tölteni. Hagyja üresen, ha inkább a feltöltött képet szeretné használni. + Ez a csoport még azelőtt jött létre, hogy a hivatkozásalapú profilképek támogatottak lettek volna, ezért csak feltöltött képet használhat. A meglévő csoportok nem frissíthetők. Hozzon létre egy új csoportot, ha hivatkozásokat szeretne használni. + Titkosított mellékletek használata + Ebben a csoportban a mellékletek a régebbi formátumot használják. A váltással hozzáadja a titkosított médiaösszetevőt a csoporthoz, és a változást minden csoporttag látja. Ez a művelet nem vonható vissza. + Ez a csoport még azelőtt jött létre, hogy a feloszlatás lehetősége elérhetővé vált volna, ezért nem oszlatható fel. Továbbra is kiléphet belőle, vagy eltávolíthatja az összes többi tagot. A már létező csoportok nem frissíthetők. Marmot csoport %1$s csoport… %1$s… @@ -2643,6 +2687,21 @@ + %1$s hozzáadta a csoporthoz őt: %2$s + %1$s csatlakozott a csoporthoz + %1$s eltávolította a csoportól őt: %2$s + %1$s el lett távolítva + %1$s elhagyta a csoportot + %1$s megváltoztatta %2$s szerepkörét erre: adminisztrátor + %1$s mostantól adminisztrátor + %1$s eltávolította %2$s adminisztrátori szerepkörét + %1$s mostantól nem adminisztrátor + %1$s átnevezte a csoportot erre: %2$s + A csoport át lett nevezve erre: %1$s + %1$s megváltoztatta a csoport profilképét + A csoport profilképe megváltozott + %1$s feloszlatta a csoportot + A csoport fel lett oszlatva Fotó eltávolítása A KeyPackage átjátszók nincsenek beállítva Még nincs KeyPackage átjátszólistája (MIP-00). Ez a lista tájékoztatja a többi felhasználót arról, hol érhető el a KeyPackage-e, így meg tudják hívni csoportos beszélgetésekbe.\n\nSzeretné ehhez a jelenlegi kimenő átjátszóit használni? diff --git a/commons/src/commonMain/composeResources/values-in-rID/strings.xml b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-in-rID/strings.xml rename to commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml diff --git a/commons/src/commonMain/composeResources/values-in/strings.xml b/commonsUI/src/commonMain/composeResources/values-in/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-in/strings.xml rename to commonsUI/src/commonMain/composeResources/values-in/strings.xml diff --git a/commons/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-it-rIT/strings.xml rename to commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ja-rJP/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ja/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ja/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ja/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ko-rKR/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml diff --git a/commons/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-lv-rLV/strings.xml rename to commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml diff --git a/commons/src/commonMain/composeResources/values-nl-rBE/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-nl-rBE/strings.xml rename to commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml diff --git a/commons/src/commonMain/composeResources/values-nl-rNL/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-nl-rNL/strings.xml rename to commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml diff --git a/commons/src/commonMain/composeResources/values-nl/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-nl/strings.xml rename to commonsUI/src/commonMain/composeResources/values-nl/strings.xml diff --git a/commons/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml similarity index 96% rename from commons/src/commonMain/composeResources/values-pl-rPL/strings.xml rename to commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 98a46ae242..8af1ee771c 100644 --- a/commons/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -256,6 +256,7 @@ Faktura LN Wygasła Oferta CLINK + Oferta BOLT12 Dla Potwierdź płatność Kwota (satoszy) @@ -659,6 +660,23 @@ Połącz %1$s km Z Health Connect + Co czyta Amethyst + Health Connect i Amethyst + Amethyst odczytuje zakończone treningi, dzięki czemu może wstępnie wypełnić za Ciebie wpis dotyczący treningu. + Amethyst to aplikacja społecznościowa oparta na platformie Nostr. Sekcja „Treningi” pozwala opublikować podsumowanie zakończonego treningu na wybranych przez Ciebie transmiterach Nostr, dzięki czemu osoby, które Cię obserwują, mogą zobaczyć, co zrobiłeś. Zamiast ręcznie wpisywać każdą liczbę, Amethyst może odczytać dane treningu, które Twój zegarek lub aplikacja fitness zapisały już w Health Connect, i wstępnie wypełnić treść wpisu. Zawsze widzisz wstępnie wypełniony wpis i sam decydujesz, czy go opublikować. + Co czyta Amethyst i dlaczego + Ćwiczenie · jaki rodzaj aktywności wykonałeś, kiedy ją rozpocząłeś i jak długo trwała — to właśnie sam trening, a także tytuł i czas trwania wpisu. + Dystans · odległość, jaką pokonałeś, podana jako odległość przebiegnięta, przejechana, przebyta pieszo lub przepłynięta. + Kalorie aktywne i całkowite · energia spalona podczas treningu. Kalorie aktywne są uwzględniane, gdy źródło danych je rejestruje; kalorie całkowite stanowią wartość zastępczą w przypadku źródeł, które rejestrują jedynie całkowitą energię. + Tętno · średnie i maksymalne tętno podczas treningu – standardowy wskaźnik intensywności wysiłku. + Kroki · liczba kroków wykonanych podczas biegu, spaceru lub wędrówki. + Różnica wysokości · o ile się wspiąłeś – to właśnie odróżnia trasę płaską od pagórkowatej. + Czego Amethyst nie robi + Odczytuje wyłącznie treningi zakończone w ciągu ostatnich 7 dni i tylko wtedy, gdy aplikacja do tworzenia treningów jest otwarta. Nigdy nie odczytuje danych w tle. + Nigdy nie przesyła żadnych danych do serwisu Health Connect i nigdy nie prosi o podanie trasy ćwiczeń, lokalizacji ani żadnych innych danych dotyczących zdrowia. + Żadna treść nie opuszcza Twojego telefonu, dopóki nie wybierzesz jednej z sugestii i sam nie opublikujesz wpisu. Amethyst nie posiada serwera: wpis trafia do skonfigurowanych przez Ciebie transmiterów Nostr. + Cała ta funkcja jest opcjonalna. Można ją wyłączyć w sekcji Ustawienia → Tworzenie wiadomości lub w dowolnym momencie cofnąć uprawnienia w aplikacji Health Connect — pozostałe funkcje aplikacji Amethyst będą nadal działać. + Przeczytaj pełną politykę prywatności Nie znaleziono żadnych witryn nSite. Odśwież Przeglądarka w aplikacji wymaga Androida 11 lub nowszego. @@ -1389,6 +1407,7 @@ Skomentuj na zewnętrznym serwisie Otwórz w przeglądarce Czas czytania: %1$d min. + Oczekuję na %1$d z %2$d transmiterów %1$d sekcja %1$d sekcji @@ -2221,6 +2240,8 @@ Ten algorytm kanału jest płatny Algorytm kanału zwrócił błąd Ponów + Offline + Ten algorytm kanału nie wysłał ostatnio sygnału kontrolnego i może być nieczynny Wyloguj się przy blokowaniu urządzenia Transmiter Czatu Udostępnij zdjęcie… @@ -2237,6 +2258,12 @@ Trafności Popularności Filtry + Brak wyników wyszukiwania + Spróbuj mniejszej liczby filtrów lub innego słowa. + Wpisz coś do wyszukania + Powyższe filtry zawężają zakres wyszukiwania — same w sobie nie przeprowadzają wyszukiwania. + Ostatnio wyszukiwane + Wyczyść Zresetuj Źródła Sortuj według @@ -2431,7 +2458,7 @@ Powód (Opcjonalne) Kodek H.265 (lepsza kompresja) - + H.264 H.265 nie jest dostępny na tym urządzeniu – wróć do H.264. Wersje Rozdzielczość źródłowa: %1$d×%2$d @@ -2637,6 +2664,23 @@ Utwórz Grupę Utwórz Grupę Marmot Zostanie utworzona nowa grupa MLS. Następnie możesz dodać członków. + Znikające wiadomości + Wył. + 1 godz. + 1 dzień + 1 tydzień + Wiadomości znikają za %1$s + Po upływie tego czasu wiadomości są usuwane z urządzeń wszystkich członków. Nie można tego później zmienić. + Rozwiąż grupę + Czy chcesz rozwiązać grupę „%1$s” dla wszystkich? Rozmowa zostanie zakończona dla wszystkich członków i nie będzie można jej wznowić — konieczne będzie utworzenie nowej grupy. + Rozwiąż + Link do awatara + https://przyklad.com/avatar.png + Link https, który każdy użytkownik może otworzyć. Pozostaw to pole puste, aby zamiast tego użyć przesłanego obrazu. + Ta grupa została utworzona, zanim wprowadzono obsługę awatarów w postaci linków, dlatego może korzystać wyłącznie z przesłanego obrazu. Istniejących grup nie można zaktualizować — aby korzystać z linku, należy utworzyć nową grupę. + Użyj zaszyfrowanych załączników + Załączniki w tej grupie korzystają ze starszego formatu. Przełączanie dodaje zaszyfrowany komponent multimedialny do grupy, a każdy użytkownik widzi zmianę. Nie można go przełączyć ponownie. + Ta grupa została utworzona, zanim wprowadzono funkcję rozwiązywania grup, i nie można jej rozwiązać. Nadal możesz z niej wyjść lub usunąć wszystkich pozostałych członków. Istniejących grup nie można ulepszyć. Grupy Marmot Grupa %1$s… %1$s… @@ -2657,6 +2701,21 @@ + %1$s dodał(a) %2$s + %1$s dołączył(a) + %1$s usunął %2$s + %1$s został usunięty + %1$s pozostało + %1$s uczynił(a) %2$s administratorem + %1$s jest teraz administratorem + %1$s usunął(a) %2$s jako administratora + %1$s nie jest już administratorem + %1$s zmienił nazwę grupy na %2$s + Nazwa grupy została zmieniona na %1$s + %1$s zmienił(a) awatar grupy + Zmieniono awatar grupy + %1$s rozwiązał(a) grupę + Grupa została rozwiązana Usuń zdjęcie Nie ustawiono transmiterów KeyPackage Nie masz jeszcze listy transmiterów KeyPackage (MIP-00). Lista ta informuje innych użytkowników, gdzie opublikowano Twój KeyPackage, dzięki czemu mogą zapraszać Cię do czatów grupowych.\n\nUżyj do tego swoich obecnych transmiterów ze skrzynki wysyłkowej? diff --git a/commons/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml similarity index 96% rename from commons/src/commonMain/composeResources/values-pt-rBR/strings.xml rename to commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index ca6571f850..bab416609a 100644 --- a/commons/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -195,6 +195,7 @@ Esta postagem foi ocultada porque menciona seus usuários ou palavras ocultas A postagem foi sinalizada como imprópria por postagem não encontrada + 👀 Imagem do Canal Evento referenciado não encontrado Não foi possível descriptografar a mensagem @@ -224,11 +225,13 @@ Denunciar representação Denunciar comportamento ilegal Reportar Malware + Malware deu zap de %1$s sats Anônimo está fazendo raid criou um clipe editado + original Citar Garfo Novo Valor em Sats @@ -243,6 +246,7 @@ Fatura Lightning Expirada Oferta CLINK + Oferta BOLT12 Para Confirmar pagamento Valor (sats) @@ -264,7 +268,10 @@ Outro app de carteira Carteira on-chain Carteira Cashu + Lightning Oferta CLINK + On-chain + Cashu Valor Preço definido pela oferta Recibo de zap @@ -297,6 +304,7 @@ Nota Secreta para o Receptor Minha mensagem oculta Prefixo Visível + 😎 Adicionar à postagem Artigo Nome do Canal @@ -334,6 +342,7 @@ Neste chat Editar os dados do canal Entrar + Feed Fila de moderação Notas Respostas @@ -425,6 +434,7 @@ Ocultar Alguém que você segue sinalizou esta conta Denunciado + +%1$d Erro ao carregar respostas Tente novamente Ainda não há notificações. @@ -585,6 +595,7 @@ Selos · %1$d Escolha quais dos selos que você recebeu aparecerão no seu perfil. Você ainda não recebeu nenhum selo. + Apps · %1$d Aplicativos Recomende os apps Nostr que você usa para que outras pessoas possam descobri-los. Apps recomendados @@ -605,7 +616,11 @@ Implementa NIPs suportados pelo %1$s + NIP-%1$s Aplicativo + Web + Android + iOS Treino Título Duração @@ -622,6 +637,7 @@ Repetições Peso Exercícios + Volume Notas Horas Minutos @@ -629,7 +645,25 @@ Compartilhe seus treinos Permita que o Amethyst leia treinos concluídos do Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) e sugira uma publicação. Conectar + %1$s km Do Health Connect + O que o Amethyst lê + Health Connect e Amethyst + O Amethyst lê treinos concluídos para preencher previamente uma publicação de treino para você. + O Amethyst é um cliente social do Nostr. A seção Treinos permite publicar um resumo de um treino concluído nos relays do Nostr que você escolher, para que quem segue você veja o que você fez. Em vez de digitar cada número à mão, o Amethyst pode ler o treino que seu relógio ou aplicativo de fitness já salvou no Health Connect e preencher a publicação previamente. Você sempre vê a publicação preenchida e decide se quer publicá-la. + O que o Amethyst lê e por quê + Exercício · qual atividade você fez, quando começou e quanto tempo durou — é o treino em si, além do título e da duração da publicação. + Distância · o quanto você percorreu, exibido como a distância da corrida, pedalada, caminhada ou natação. + Calorias ativas e totais · a energia gasta no treino. As calorias ativas são usadas quando sua fonte as registra; as calorias totais são a alternativa para fontes que registram apenas a energia total. + Frequência cardíaca · a frequência média e máxima durante o treino, a medida padrão do esforço. + Passos · a contagem de passos de uma corrida, caminhada ou trilha. + Elevação · o quanto você subiu, que é o que distingue um percurso plano de um acidentado. + O que o Amethyst não faz + Lê apenas treinos concluídos nos últimos 7 dias e somente enquanto o editor de Treinos está aberto. Nunca lê em segundo plano. + Nunca grava nada no Health Connect e nunca solicita seu trajeto de exercício, localização ou qualquer outro tipo de dado de saúde. + Nada sai do seu telefone até você tocar em uma sugestão e publicar por conta própria. O Amethyst não tem servidor: a publicação vai para os relays do Nostr que você configurou. + Todo o recurso é opcional. Desative-o em Configurações → Configurações de composição, ou revogue as permissões no Health Connect a qualquer momento — o restante do Amethyst continua funcionando. + Ler a política de privacidade completa Nenhum nSite encontrado ainda. Recarregar O navegador integrado requer Android 11 ou superior. @@ -716,7 +750,9 @@ Descriptografou uma mensagem Compartilhou sua chave pública Conectado + Ping Listou os relays + %1$s Escaneie para conectar um aplicativo à sua chave Escanear um código Colar um link em vez disso @@ -764,10 +800,12 @@ Nenhum relay pediu que você entrasse ainda. há %1$s Fonte: %1$s + v%1$s Baixar Sobre Plataformas Tópicos + Links Versão mais recente Seja o primeiro a comentar. por @@ -879,6 +917,7 @@ Ainda não há servidores. Adicione um abaixo ou escolha um recomendado. Ver episódios Nenhum episódio encontrado ainda + Trailer Temporada %1$d Explícito Concluído @@ -894,8 +933,10 @@ Principais apoiadores Apresentador Coapresentador + Editor Autor verificado Transmitir sats + %1$d sats/min Envia valor automaticamente enquanto você ouve. %1$d sats transmitidos nesta sessão Título @@ -961,12 +1002,14 @@ Itens Salvos Públicos Repositórios Seus repositórios git marcados + Podcasts Seus podcasts e episódios marcados Adicionar aos Itens Salvos Privados Remover dos Itens Salvos Privados Adicionar hashtag Adicionar uma hashtag Marque publicamente este post com uma hashtag (etiqueta NIP-32). Quem segue você o verá no feed dessa hashtag. + Hashtag Adicionar adicionado por Notas Fixadas @@ -987,6 +1030,7 @@ Meus interesses Adicionar hashtag Privado + %1$d hashtag(s) Ações do conjunto de interesses Clonar Adicionar hashtag @@ -1024,6 +1068,7 @@ Exibido ao pressionar o botão de zap. Toque em um valor para removê-lo. Se deixar vazio, abrirá o diálogo para inserir um valor todas as vezes. Enviar on-chain em vez disso Recarregar mint + %1$s sats Quantia da recarga Mint a recarregar Fundos de @@ -1235,6 +1280,7 @@ Mostra uma mensagem de aviso quando o autor da publicação a marcar como sensível Máximo de hashtags por postagem Oculta postagens com mais hashtags do que este limite. Defina como 0 para desativar + ∞ Novo símbolo de reação Arrecadação de Zaps Adiciona uma quantidade alvo de sats para financiar esta postagem. Os clientes podem mostrar isso como uma barra de progresso para incentivar doações @@ -1266,6 +1312,7 @@ Todos os idiomas Política de postagem Termos & condições + N/A Erros e Avisos deste Relé Relatórios do Monitor de Relay Assinaturas Ativas @@ -1284,6 +1331,7 @@ Aceita até %1$s no futuro há %1$s + %1$s bits Retenção de eventos Tamanho do conteúdo Conectividade @@ -1296,6 +1344,7 @@ Máx. filtros por inscrição Limite máx. (eventos retornados) Comprimento máx. do SubID + Mint: %1$s Resgatar Copiar Token Abrir em outro aplicativo @@ -1328,6 +1377,7 @@ Cor de destaque Cor principal usada em botões e links Reativar + Picture-in-Picture Pesquise registros locais e remotos O endereço Nostr foi verificado Endereço Nostr falhou na verificação @@ -1421,6 +1471,7 @@ Privado Somente por convite AO VIVO + %1$d+ Insira uma URL de relay válida (wss://…). Nome Descrição @@ -1513,6 +1564,7 @@ Os votos são ponderados pelo valor do zap. Você pode definir um valor mínimo para evitar spammers e um valor máximo para evitar que grandes zappers assumam o controle da enquete. Use o mesmo valor em ambos os campos para garantir que cada voto tenha o mesmo valor. Deixe em branco para aceitar qualquer valor. Mensagem ao usuário Mensagem %1$s + OK Dividir e encaminhar Zaps Os clientes que suportam dividirão e encaminharão zaps para os usuários adicionados aqui em vez dos seus Pesquisar e adicionar usuário @@ -1546,6 +1598,7 @@ Envie uma mensagem para o vendedor Olá, ainda está disponível? Título + iPhone 13 Condição Categoria Preço (em Sats) @@ -1574,6 +1627,7 @@ Enviar Receber Transações + sats Cole uma fatura BOLT-11 Pagar Pagamento bem-sucedido @@ -1590,6 +1644,7 @@ Enviado Atualizar Todos + Zaps Outros Adicionar carteira Padrão @@ -1635,10 +1690,12 @@ Defina primeiro sua carteira principal, depois recupere os fundos das mais antigas. Configurando sua carteira… Buscar novamente + Mints: %1$s Carteira criada Agora escolha alguns mints para hospedar seus sats. Escolher mints Saldo + Mints URL do mint Remover mint Adicionar mint @@ -1699,7 +1756,9 @@ Pedindo cotação ao mint… Verificar ✓ O mint está acessível + ✓ %1$s Não foi possível acessar o mint: %1$s + Nutzap Criar token Resgatar Concluído @@ -1734,9 +1793,11 @@ nome, NIP-05 ou npub Alterar destinatário Valor + sats O zap on-chain mínimo é %1$s sats — valores menores são consumidos pelas taxas dos mineradores. Use um zap Lightning em vez disso. Comentário (opcional) Prioridade + %1$s sat/vB · %2$s Carregando estimativas de taxa… Enviar %1$s sats Não dividir — pagar a apenas um destinatário @@ -1745,6 +1806,7 @@ Transação Taxa Troco + %1$s sats Concluído Falhou em: %1$s O pagamento foi transmitido (tx %1$s), mas o recibo não foi publicado. @@ -1838,6 +1900,7 @@ Uma breve descrição do seu artigo\u2026 Slug da URL slug-url-do-artigo + # Tags Adicionar uma tag\u2026 Comece a escrever seu artigo\u2026 Responder @@ -1866,9 +1929,11 @@ Reordenar Responder Responder a esta nota + Boost Repostar ou citar esta nota Curtir Reagir a esta nota com um emoji + Zap Enviar um pagamento lightning ao autor Compartilhar Compartilhar esta nota externamente @@ -1888,6 +1953,7 @@ Compartilhar o link do vídeo externamente Salvar no telefone Baixar o vídeo para o dispositivo (oculto em transmissões ao vivo) + Picture-in-Picture Reproduzir o vídeo em uma janela flutuante (oculto se não suportado) Transmitir para dispositivo Envia o vídeo para um receptor Chromecast na sua Wi-Fi (oculto para arquivos locais) @@ -1939,6 +2005,7 @@ %1$s excluiu uma mensagem %1$s excluiu uma mensagem: %2$s %1$s iniciou esta conversa + %1$s: %2$s 🔊 %1$s iniciou um huddle 🔊 %1$s entrou no huddle 🔊 %1$s saiu do huddle @@ -1951,9 +2018,11 @@ ⚠ Erro no trabalho ▲ %1$s votou a favor de um post ▼ %1$s votou contra um post + Canvas Nenhum canvas foi compartilhado neste espaço de trabalho ainda. Editar canvas Salvar canvas + Canvas (Markdown) Editando mensagem (editado) %1$s está digitando… @@ -2005,6 +2074,7 @@ Compartilhar Copiar Não foi possível criar o convite + OK Nenhuma conversa ainda Entrega da mensagem Fechar @@ -2052,6 +2122,8 @@ Gravações Restritas Forkado de Clonar: + Branch + Commit Base de merge O pull request foi atualizado para um novo commit. Tickets @@ -2060,6 +2132,8 @@ Fechados & Resolvidos Todos Marcar repositório + Branches + Tags Arquivos Atualizado Atividade recente @@ -2079,8 +2153,11 @@ raiz Tentar novamente padrão + Branches + Tags Pesquisar arquivos… Nenhum arquivo correspondente. + Commits Nenhum histórico de commits disponível. Copiar conteúdo do arquivo Texto @@ -2146,6 +2223,7 @@ Este algoritmo de feed requer pagamento O algoritmo de feed retornou um erro Tentar novamente + Offline Este algoritmo de feed não enviou sinal de atividade recentemente e pode estar fora do ar Terminar sessão no bloqueio do dispositivo Relé de chat @@ -2155,6 +2233,7 @@ Tudo Pessoas Notas + Local Relés Apenas seguidos Mais recentes @@ -2172,6 +2251,7 @@ Fonte Ordenar por Filtros + %1$s → %2$s Buscar idiomas Adicionar idioma Adicionar par de idiomas @@ -2231,6 +2311,7 @@ Tempo esgotado Tentando novamente… Dispensar + %1$d/%2$d Enviando Enviando %1$s Enviando %1$d eventos… @@ -2315,6 +2396,7 @@ Enviando para Saída Entrada + DMs Verificando (%1$d relays) Concluído (%1$d relays) env. %1$s @@ -2334,6 +2416,7 @@ Sacar via Lightning… Exportar como token Cashu… Movendo moedas… + <%1$s Conectando Baixando Erro @@ -2343,6 +2426,7 @@ Ações do perfil Ações de mídia Reprodução + Auto Editar apelido Visível apenas para você Transmitir para… @@ -2355,7 +2439,9 @@ Descrição Sobre o que é este vídeo? Motivo (opcional) + Codec H.265 (melhor compressão) + H.264 H.265 não disponível neste dispositivo — usando H.264. Versões Resolução da origem: %1$d×%2$d @@ -2393,12 +2479,14 @@ Até Agora Todo o período + URL https://exemplo.com Título Título do marcador Descrição Uma breve descrição Tags (separadas por vírgula) + nostr, tech, blog Salvar Excluir Excluir este marcador web? @@ -2407,6 +2495,7 @@ Termina %1$s Doação on-chain Valor da meta (sats) + 100000 Descreva sua meta Para que você está arrecadando fundos? Resumo curto @@ -2519,6 +2608,7 @@ Elaborar Amigável Profissional + + Emoji Pacotes de emojis Adicionar à lista de emojis Adicionar à minha lista de emojis @@ -2533,6 +2623,7 @@ Enviar imagem de capa Escolha uma imagem quadrada para representar este pacote de emojis. Você ainda não tem nenhum pacote de emoji + %1$d emojis Minha lista de emojis Pacotes de emojis que você adicionou à sua seleção (NIP-51 kind 10030) Você ainda não adicionou nenhum pacote de emojis à sua lista @@ -2546,6 +2637,7 @@ Ações do pacote de emoji Privado Emoji privado + Gif Violação das regras da comunidade Você está na lista de bloqueados desta comunidade. Os posts não serão aceitos. Esta comunidade não aceita eventos do tipo %1$d. @@ -2566,6 +2658,7 @@ Dissolver “%1$s” para todos? A conversa termina para todos os membros e não pode ser reaberta — seria preciso criar um novo grupo. Dissolver Link do avatar + https://example.com/avatar.png Um link https que todos os membros conseguem carregar. Deixe vazio para usar a imagem enviada. Este grupo foi criado antes do suporte a avatares por link, então só pode usar uma imagem enviada. Grupos existentes não podem ser atualizados — crie um novo grupo para usar um link. Usar anexos criptografados @@ -2573,6 +2666,7 @@ Este grupo foi criado antes do suporte à dissolução e não pode ser dissolvido. Você ainda pode sair dele ou remover todos os outros membros. Grupos existentes não podem ser atualizados. Grupo Marmot Grupo %1$s… + %1$s… Conhecidos Conhecidos (%1$d) Novos pedidos @@ -2641,6 +2735,7 @@ Pagar fatura do DVM Pagar %1$s sats ao DVM Abrir chat de localização + Backlog Execuções de fluxo de trabalho Trabalho do agente Nova execução @@ -2729,4 +2824,5 @@ Publicando… Publicar persona Segue você + Bot diff --git a/commons/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-pt-rPT/strings.xml rename to commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ru-rRU/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ru-rUA/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ru/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ru/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ru/strings.xml diff --git a/commons/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-sl-rSI/strings.xml rename to commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml diff --git a/commons/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-sr-rSP/strings.xml rename to commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml diff --git a/commons/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml similarity index 96% rename from commons/src/commonMain/composeResources/values-sv-rSE/strings.xml rename to commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index dc9baa2062..d5cf3725c4 100644 --- a/commons/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -195,6 +195,7 @@ Detta inlägg doldes eftersom det nämner dina dolda användare eller ord Inlägget flaggades som olämpligt av Inlägg hittades inte + 👀 Kanal bild Refererad händelse hittades inte Kunde inte dekryptera meddelandet @@ -230,6 +231,7 @@ gör en raid skapade ett klipp redigerat + original Citera Förgrening Nytt belopp i Sats @@ -244,16 +246,19 @@ Lightning Faktura Förfallen CLINK-erbjudande + BOLT12-erbjudande Till Bekräfta betalning Belopp (sats) Tillåtet intervall: %1$s–%2$s sats CLINK-erbjudande (noffer) + Budget Utgiftsbudget Belopp (sats) Begär Budget godkänd Endast betalning + CLINK Debit Betala och zappa från en plånbok som har förauktoriserat ditt konto. Endast utgifter — inget saldo eller historik. Ogiltig CLINK-debitpekare. En ndebit1…-sträng förväntades. Klistra in ndebit-pekare @@ -263,7 +268,10 @@ En annan plånboksapp On-chain-plånbok Cashu-plånbok + Lightning CLINK-erbjudande + On-chain + Cashu Belopp Pris satt av erbjudandet Zap-kvitto @@ -296,6 +304,7 @@ Hemlig anteckning till mottagaren Mitt dolda inlägg Synligt Prefix + 😎 Lägg till inlägg Artikel Kanal Namn @@ -314,6 +323,7 @@ Användarnamn Om mig Profilbild URL + Banner URL Websida URL Pronomen Källrelän kan vara föråldrade @@ -424,6 +434,7 @@ Dölj Någon du följer har flaggat detta konto Anmäld + +%1$d Det gick inte att läsa in svar: Försök igen Inga aviseringar ännu. @@ -604,6 +615,12 @@ Relaterat Implementerar NIP:er som stöds + via %1$s + NIP-%1$s + App + Web + Android + iOS Träningspass Titel Varaktighet @@ -628,7 +645,25 @@ Dela dina träningspass Låt Amethyst läsa avslutade träningspass från Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) och föreslå ett inlägg. Anslut + %1$s km Från Health Connect + Vad Amethyst läser + Health Connect och Amethyst + Amethyst läser avslutade träningspass för att kunna förifylla ett träningsinlägg åt dig. + Amethyst är en social Nostr-klient. I avsnittet Träningspass kan du publicera en sammanfattning av ett avslutat träningspass till de Nostr-reläer du väljer, så att de som följer dig kan se vad du har gjort. I stället för att skriva in varje siffra för hand kan Amethyst läsa det träningspass som din klocka eller träningsapp redan har sparat i Health Connect och förifylla inlägget. Du ser alltid det förifyllda inlägget och avgör själv om det ska publiceras. + Vad Amethyst läser och varför + Övning · vilken aktivitet du gjorde, när den började och hur länge den varade — det är själva träningspasset och även inläggets titel och varaktighet. + Distans · hur långt du tog dig, som visas som distansen för löprundan, cykelturen, promenaden eller simningen. + Aktiva och totala kalorier · energin som träningspasset förbrände. Aktiva kalorier används när din källa registrerar dem; totala kalorier är reserven för källor som bara registrerar total energi. + Puls · genomsnittlig och maximal puls under träningspasset, det vanliga måttet på hur ansträngande det var. + Steg · antalet steg under en löprunda, promenad eller vandring. + Höjdmeter · hur mycket du klättrade, vilket skiljer en platt tur från en kuperad. + Vad Amethyst inte gör + Läser bara träningspass som avslutades de senaste 7 dagarna, och bara medan träningsredigeraren är öppen. Den läser aldrig i bakgrunden. + Skriver aldrig något till Health Connect och begär aldrig din träningsrutt, plats eller någon annan typ av hälsodata. + Ingenting lämnar din telefon förrän du trycker på ett förslag och publicerar inlägget själv. Amethyst har ingen server: inlägget går till de Nostr-reläer du har ställt in. + Hela funktionen är valfri. Stäng av den under Inställningar → Skrivinställningar, eller återkalla behörigheterna i Health Connect när som helst — resten av Amethyst fortsätter att fungera. + Läs hela integritetspolicyn Inga nSites hittades ännu. Ladda om Den inbyggda webbläsaren kräver Android 11 eller senare. @@ -703,6 +738,7 @@ Amethyst håller en bakgrundsanslutning (visas som en pågående notifiering) så att den kan besvara signeringsförfrågningar när den är stängd. Signera åt andra appar Slå på fjärrsignatären + Live Kom ihåg dessa för varje app som %1$s Tillåt %1$d @@ -714,7 +750,9 @@ Dekrypterade ett meddelande Delade din publika nyckel Ansluten + Ping Listade reläer + %1$s Skanna för att ansluta en app till din nyckel Skanna en kod Klistra in en länk istället @@ -762,6 +800,7 @@ Inget relä har bett dig logga in än. för %1$s sedan Källa: %1$s + v%1$s Ladda ner Om Plattformar @@ -799,6 +838,7 @@ Ingen är i rummet just nu. %1$d lyssnar Värd + Moderator Mikrofon avstängd Talar nu Mikrofon på @@ -806,6 +846,7 @@ Du är värden. Att stänga rummet kopplar bort alla. Välj "Bara lämna" om du vill komma tillbaka senare — rummet stängs automatiskt efter 8 timmars inaktivitet. Stäng rum Bara lämna + LIVE Chatt Publik Händer @@ -876,13 +917,16 @@ Inga nest-servrar än. Lägg till en nedan eller välj en rekommenderad server. Visa avsnitt Inga avsnitt hittades än + Trailer Säsong %1$d + Explicit Slutförd Stöd podden av %1$s S%1$d · A%2$d Avs %1$d Säsong %1$d + Video Transkription Kapitel Värdar & gäster @@ -892,6 +936,7 @@ Redaktör Verifierad författare Streama sats + %1$d sats/min Skickar värde automatiskt medan du lyssnar. Streamade %1$d sats denna session Titel @@ -934,10 +979,12 @@ Lightning-adress namn@example.com Nodens publika nyckel + 02abc… (33-byte hex) Vikt Avgift Titel Spårtitel + Artist Artistens namn Album (valfritt) Längd i sekunder (valfritt) @@ -962,6 +1009,7 @@ Lägg till hashtag Lägg till en hashtag Tagga det här inlägget offentligt med en hashtag (NIP-32-etikett). De som följer dig ser det i den hashtaggens flöde. + Hashtag Lägg till tillagd av Fästa anteckningar @@ -1020,6 +1068,7 @@ Visas när du trycker på Zap-knappen. Tryck på ett belopp för att ta bort det. Om du lämnar det tomt öppnas en dialog för att ange ett belopp varje gång. Skicka on-chain istället Fyll på mint + %1$s sats Påfyllningsbelopp Mint att fylla på Medel från @@ -1231,6 +1280,7 @@ Visar ett varningsmeddelande när författaren till inlägget markerar det som känsligt Max hashtags per inlägg Döljer inlägg med fler hashtaggar än denna gräns. Sätt till 0 för att inaktivera + ∞ Ny reaktionssymbol Zap insamling Lägger till en målsumma av sats för detta inlägg. Stödjande klienter kan visa detta som en framstegsindikator för att stimulera donationer @@ -1240,6 +1290,7 @@ Ägare Används av Service-nyckel + Version Programvara Kontakt Stödda NIPs @@ -1261,6 +1312,7 @@ Alla språk Publiceringspolicy Villkor & bestämmelser + N/A Fel och meddelanden från detta relä Reläövervakningsrapporter Aktiva prenumerationer @@ -1292,9 +1344,12 @@ Max. filter per prenumeration Maxgräns (returnerade händelser) Max. SubID-längd + Mint: %1$s Inlösen Kopiera Token Öppna i annan app + LIVE + OFFLINE SLUTADE PLANERAD PRIVAT @@ -1392,6 +1447,7 @@ Gå med Begärd Administratör + Moderator Medlem Medlemmar Gör till admin @@ -1414,6 +1470,8 @@ Förbereder inbjudan… Privat Endast inbjudan + LIVE + %1$d+ Ange en giltig relä-URL (wss://…). Namn Beskrivning @@ -1540,6 +1598,7 @@ Skicka ett meddelande till säljaren Hallå där, är detta fortfarande tillgängligt? Titel + iPhone 13 Skick Kategori Pris (i Sats) @@ -1568,6 +1627,7 @@ Skicka Ta emot Transaktioner + sats Klistra in en BOLT-11-faktura Betala Betalning lyckades @@ -1584,6 +1644,7 @@ Skickad Uppdatera Alla + Zaps Övriga Lägg till plånbok Standard @@ -1629,10 +1690,12 @@ Ange din huvudplånbok först, återhämta sedan medel från de äldre. Konfigurerar din plånbok… Sök igen + Mints: %1$s Plånbok skapad Välj nu några mints som ska hosta dina sats. Välj mints Saldo + Mints Mint-URL Ta bort mint Lägg till mint @@ -1693,7 +1756,9 @@ Ber mint om en offert… Verifiera ✓ Mint är nåbar + ✓ %1$s Kunde inte nå mint: %1$s + Nutzap Skapa token Lös in Klar @@ -1728,9 +1793,11 @@ namn, NIP-05 eller npub Ändra mottagare Belopp + sats Minsta on-chain-zap är %1$s sats — mindre belopp äts upp av brytaravgifter. Använd en Lightning-zap istället. Kommentar (valfritt) Prioritet + %1$s sat/vB · %2$s Laddar avgiftsuppskattningar… Skicka %1$s sats Dela inte upp — betala en mottagare istället @@ -1739,6 +1806,7 @@ Transaktion Avgift Växel + %1$s sats Klar Misslyckades vid: %1$s Betalningen sändes ut (tx %1$s) men kvittot publicerades inte. @@ -1865,6 +1933,7 @@ Reposta eller citera denna notis Gilla Reagera på denna notis med en emoji + Zap Skicka en lightning-betalning till författaren Dela Dela denna notis externt @@ -1936,6 +2005,7 @@ %1$s tog bort ett meddelande %1$s tog bort ett meddelande: %2$s %1$s startade den här konversationen + %1$s: %2$s 🔊 %1$s startade en huddle 🔊 %1$s gick med i huddlen 🔊 %1$s lämnade huddlen @@ -1948,9 +2018,11 @@ ⚠ Jobbfel ▲ %1$s röstade upp ett inlägg ▼ %1$s röstade ner ett inlägg + Canvas Ingen canvas har delats i den här arbetsytan än. Redigera canvas Spara canvas + Canvas (Markdown) Redigerar meddelande (redigerat) %1$s skriver… @@ -2002,6 +2074,7 @@ Dela Kopiera Kunde inte skapa inbjudan + OK Inga konversationer än Meddelandeleverans Stäng @@ -2044,11 +2117,13 @@ Donera nu tillhandahölls av: Denna version tillhandahölls av: + Version %1$s Tack! Begränsade skrivningar Forkad från Klona: Gren + Commit Mergebas Uppdaterade pull request till en ny commit. Ärenden @@ -2082,8 +2157,10 @@ Taggar Sök filer… Inga matchande filer. + Commits Ingen commit-historik tillgänglig. Kopiera filinnehåll + Text Ny Nytt ärende Titel @@ -2146,6 +2223,7 @@ Den här flödesalgoritmen kräver betalning Flödesalgoritmen returnerade ett fel Försök igen + Offline Den här flödesalgoritmen har inte skickat någon livssignal på sistone och kan vara nere Logga ut när enheten låses Chatt Relä @@ -2173,6 +2251,7 @@ Källa Sortera efter Filter + %1$s → %2$s Sök språk Lägg till språk Lägg till språkpar @@ -2232,6 +2311,7 @@ Tidsgräns överskriden Försöker igen… Avfärda + %1$d/%2$d Sänder Sänder %1$s Sänder %1$d händelser… @@ -2336,6 +2416,7 @@ Ta ut via Lightning… Exportera som Cashu-token… Flyttar mynt… + <%1$s Ansluter Laddar ner Fel @@ -2345,6 +2426,7 @@ Profilåtgärder Medieåtgärder Uppspelning + Auto Redigera smeknamn Endast synligt för dig Casta till… @@ -2357,7 +2439,9 @@ Beskrivning Vad handlar den här videon om? Anledning (valfritt) + Codec H.265 (bättre komprimering) + H.264 H.265 inte tillgängligt på den här enheten — växlar till H.264. Versioner Källupplösning: %1$d×%2$d @@ -2395,12 +2479,14 @@ Till Nu All tid + URL https://exempel.se Titel Bokmärkets titel Beskrivning En kort beskrivning Taggar (kommaseparerade) + nostr, tech, blog Spara Radera Radera detta webbbokmärke? @@ -2409,11 +2495,13 @@ Slutar %1$s On-chain-donation Målbelopp (sats) + 100000 Beskriv ditt mål Vad samlar du in pengar till? Kort sammanfattning Kort beskrivning som visas i förhandsvisningar Bild-URL (valfritt) + https://example.com/image.jpg Webbplats-URL (valfritt) https://exempel.se Sätt en tidsfrist @@ -2520,6 +2608,7 @@ Utveckla Vänlig Professionell + + Emoji Emoji-paket Lägg till i emoji-lista Lägg till i min emoji-lista @@ -2534,6 +2623,7 @@ Ladda upp en omslagsbild Välj en fyrkantig bild som ska representera detta emoji-paket. Du har inga emoji-paket ännu + %1$d emojis Min emoji-lista Emoji-paket som du har lagt till i ditt urval (NIP-51 kind 10030) Du har inte lagt till några emoji-paket i din lista ännu @@ -2547,6 +2637,7 @@ Åtgärder för emoji-paket Privat Privat emoji + Gif Brott mot gemenskapens regler Du står på den här gemenskapens blockeringslista. Inlägg kommer inte att accepteras. Den här gemenskapen accepterar inte händelser av typ %1$d. @@ -2567,6 +2658,7 @@ Upplös ”%1$s” för alla? Konversationen avslutas för alla medlemmar och kan inte öppnas igen — en ny grupp skulle behöva skapas. Upplös Avatarlänk + https://example.com/avatar.png En https-länk som alla medlemmar kan läsa in. Lämna tomt för att använda den uppladdade bilden i stället. Den här gruppen skapades innan länkavatarer stöddes, så den kan bara använda en uppladdad bild. Befintliga grupper kan inte uppgraderas — skapa en ny grupp för att använda en länk. Använd krypterade bilagor @@ -2574,6 +2666,7 @@ Den här gruppen skapades innan upplösning stöddes och kan inte upplösas. Du kan fortfarande lämna den eller ta bort alla andra medlemmar. Befintliga grupper kan inte uppgraderas. Marmot-grupp Grupp %1$s… + %1$s… Kända Kända (%1$d) Nya förfrågningar @@ -2731,4 +2824,5 @@ Publicerar… Publicera persona Följer dig + Bot diff --git a/commons/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-sw/strings.xml rename to commonsUI/src/commonMain/composeResources/values-sw/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ta-rIN/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml diff --git a/commons/src/commonMain/composeResources/values-ta/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-ta/strings.xml rename to commonsUI/src/commonMain/composeResources/values-ta/strings.xml diff --git a/commons/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-th-rTH/strings.xml rename to commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml diff --git a/commons/src/commonMain/composeResources/values-th/strings.xml b/commonsUI/src/commonMain/composeResources/values-th/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-th/strings.xml rename to commonsUI/src/commonMain/composeResources/values-th/strings.xml diff --git a/commons/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-tr-rTR/strings.xml rename to commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml diff --git a/commons/src/commonMain/composeResources/values-tr/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-tr/strings.xml rename to commonsUI/src/commonMain/composeResources/values-tr/strings.xml diff --git a/commons/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-uk-rUA/strings.xml rename to commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml diff --git a/commons/src/commonMain/composeResources/values-uk/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-uk/strings.xml rename to commonsUI/src/commonMain/composeResources/values-uk/strings.xml diff --git a/commons/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-uz-rUZ/strings.xml rename to commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml diff --git a/commons/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-vi-rVN/strings.xml rename to commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml diff --git a/commons/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-zh-rCN/strings.xml rename to commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml diff --git a/commons/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-zh-rHK/strings.xml rename to commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml diff --git a/commons/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-zh-rSG/strings.xml rename to commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml diff --git a/commons/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-zh-rTW/strings.xml rename to commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml diff --git a/commons/src/commonMain/composeResources/values-zh/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml similarity index 100% rename from commons/src/commonMain/composeResources/values-zh/strings.xml rename to commonsUI/src/commonMain/composeResources/values-zh/strings.xml diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml similarity index 99% rename from commons/src/commonMain/composeResources/values/strings.xml rename to commonsUI/src/commonMain/composeResources/values/strings.xml index 4550aca749..ae6a40eb98 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -258,6 +258,7 @@ Lightning Invoice Expired CLINK Offer + BOLT12 Offer To Confirm payment Amount (sats) @@ -1697,6 +1698,7 @@ No transactions match this filter Loading… Received + Open zapped note Sent Refresh All diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbol.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbol.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbol.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbol.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/ArticleHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/ArticleHeader.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/ArticleHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/ArticleHeader.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/TableOfContents.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/TableOfContents.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/TableOfContents.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/article/TableOfContents.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownEditorState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownEditorState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownEditorState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownEditorState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MarkdownToolbar.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MetadataPanel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MetadataPanel.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MetadataPanel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip23LongContent/ui/editor/MetadataPanel.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamSystemCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamSystemCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamSystemCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamSystemCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessBoard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessBoard.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessBoard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessBoard.kt index 4bfb10c8f0..b1ba76d6d5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessBoard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessBoard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessBroadcastBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessBroadcastBanner.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessBroadcastBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessBroadcastBanner.kt index ad56915255..6b5015dd22 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessBroadcastBanner.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessBroadcastBanner.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus /** * Shared banner showing chess broadcast status - broadcast progress, sync status, etc. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessGameViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessGameViewer.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessGameViewer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessGameViewer.kt index 3f90ad5812..119d20fe38 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessGameViewer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessGameViewer.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessLobbyCards.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyCards.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessLobbyCards.kt index 69c6871eb1..96eafc9556 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyCards.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessLobbyCards.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessPieceVectors.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessPieceVectors.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessPieceVectors.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessPieceVectors.kt index 82efbd32d1..291c463548 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessPieceVectors.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessPieceVectors.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessPlayerChip.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessPlayerChip.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessPlayerChip.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessPlayerChip.kt index 3507ab428e..b0bb60e5ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessPlayerChip.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessPlayerChip.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessSyncBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessSyncBanner.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessSyncBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessSyncBanner.kt index 2ab245b0d5..937345a2be 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessSyncBanner.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/ChessSyncBanner.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -58,6 +58,9 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus +import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncState +import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus /** * Shared banner showing chess sync/subscription status with expandable relay details. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/InteractiveChessBoard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/InteractiveChessBoard.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/InteractiveChessBoard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/InteractiveChessBoard.kt index d46dd43648..20bce5dffd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/InteractiveChessBoard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/InteractiveChessBoard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -80,9 +80,11 @@ fun InteractiveChessBoard( ) { // Track local move count + external version to trigger recomposition when board changes var localMoveCount by remember { mutableStateOf(0) } - val effectiveVersion = localMoveCount + positionVersion - val position = remember(engine, effectiveVersion) { engine.getPosition() } - val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() } + // Two independent counters are two keys, not a sum: a re-synced history + // that resets positionVersion while localMoveCount persists must not land + // on the same key and serve a stale position. + val position = remember(engine, localMoveCount, positionVersion) { engine.getPosition() } + val sideToMove = remember(engine, localMoveCount, positionVersion) { engine.getSideToMove() } var selectedSquare by remember { mutableStateOf?>(null) } var legalMoves by remember { mutableStateOf>(emptyList()) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/LiveChessGame.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/LiveChessGame.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/LiveChessGame.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/LiveChessGame.kt index a6f2918b78..06787b1477 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/LiveChessGame.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/LiveChessGame.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -648,8 +648,10 @@ private fun MoveHistoryDisplay(moves: List) { horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Group moves into pairs (white, black) - moves.chunked(2).forEachIndexed { index, movePair -> + // Group moves into pairs (white, black); remembered so a + // recomposition per relay move doesn't re-chunk the list. + val movePairs = remember(moves) { moves.chunked(2) } + movePairs.forEachIndexed { index, movePair -> // Move number Text( text = "${index + 1}.", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/MoveNavigator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/MoveNavigator.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/MoveNavigator.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/MoveNavigator.kt index 4b46193fa5..573530cdb1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/MoveNavigator.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/MoveNavigator.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/PGNMetadata.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/PGNMetadata.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/PGNMetadata.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/PGNMetadata.kt index f68244cb95..0790fa5fc6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/PGNMetadata.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ui/PGNMetadata.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.nip64Chess.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LocalPrivacyLockState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LocalPrivacyLockState.kt new file mode 100644 index 0000000000..1c8429b662 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LocalPrivacyLockState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacylock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf + +/** + * Provided once at the App composition root. Map keyed by [LockScope]; every + * scope must have an entry (see [lockStateFor] which throws when missing). + */ +val LocalPrivacyLockState = + compositionLocalOf> { + error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider") + } + +/** + * Convenience accessor used inside gate composables. Reads the map from the + * ambient [LocalPrivacyLockState] and returns the state holder for [scope]. + * Throws if the scope was not registered at the App root. + */ +@Composable +@ReadOnlyComposable +fun lockStateFor(scope: LockScope): PrivacyLockState = + LocalPrivacyLockState.current[scope] + ?: error("PrivacyLockState for $scope not registered at App root") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/ProfileBroadcastBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/ProfileBroadcastBanner.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/ProfileBroadcastBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/ProfileBroadcastBanner.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/CommunityFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/CommunityFilterAssemblerSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/CommunityFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/CommunityFilterAssemblerSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/gitRepo/RepositoryFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/gitRepo/RepositoryFilterAssemblerSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/gitRepo/RepositoryFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/gitRepo/RepositoryFilterAssemblerSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/UserProfileFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/UserProfileFilterAssemblerSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/UserProfileFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/UserProfileFilterAssemblerSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/Base64Fetcher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/Base64Fetcher.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/Base64Fetcher.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/Base64Fetcher.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlurHashFetcher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlurHashFetcher.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlurHashFetcher.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlurHashFetcher.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/ThumbHashFetcher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/ThumbHashFetcher.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/ThumbHashFetcher.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/ThumbHashFetcher.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/StringRes.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/StringRes.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/StringRes.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/StringRes.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt similarity index 81% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt index 43f01bf82c..8dfc7b1cb8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt @@ -34,10 +34,9 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -79,21 +78,28 @@ fun BunkerHeartbeatIndicator( when (signerConnectionState) { is SignerConnectionState.Connected -> { val infiniteTransition = rememberInfiniteTransition(label = "heartbeat") - val scale by infiniteTransition.animateFloat( - initialValue = 0.85f, - targetValue = 1.15f, - animationSpec = - infiniteRepeatable( - animation = tween(800), - repeatMode = RepeatMode.Reverse, - ), - label = "heartbeatScale", - ) + // No `by`: reading the value in composition recomposed the + // indicator every frame for as long as a bunker was connected. + val scale = + infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = + infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "heartbeatScale", + ) Icon( MaterialSymbols.Favorite, contentDescription = "Bunker connected", tint = Color(0xFF4CAF50), - modifier = Modifier.size(20.dp).scale(scale), + modifier = + Modifier.size(20.dp).graphicsLayer { + scaleX = scale.value + scaleY = scale.value + }, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableTexts.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableTexts.kt similarity index 94% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableTexts.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableTexts.kt index b1a933a09d..eaa3fbff7e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableTexts.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableTexts.kt @@ -70,9 +70,12 @@ fun ClickableTextColor( linkColor: Color = MaterialTheme.colorScheme.primary, onClick: () -> Unit, ) { + // onClick is baked into the LinkAnnotation, so it must be a key: a recycled + // slot with the same label and a new handler would otherwise keep firing + // the old target. Text( text = - remember(text) { + remember(text, linkColor, onClick) { buildAnnotatedString { appendLink(text, linkColor, onClick) } @@ -97,7 +100,7 @@ fun ClickableTextNormal( ) { Text( text = - remember(text) { + remember(text, onClick) { buildAnnotatedString { appendLink(text, onClick) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ForwardingPainter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ForwardingPainter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ForwardingPainter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ForwardingPainter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GenericLoadable.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GenericLoadable.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GenericLoadable.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GenericLoadable.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GlowingCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GlowingCard.kt similarity index 73% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GlowingCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GlowingCard.kt index efb78583f7..d00cb843b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GlowingCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/GlowingCard.kt @@ -31,7 +31,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect @@ -44,6 +45,9 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +private val GLOW_BRUSH = Brush.sweepGradient(colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow)) +private val DASH_INTERVALS = floatArrayOf(10f, 10f) + @Composable fun AnimatedBorderTextCornerRadius( text: String, @@ -69,25 +73,24 @@ fun AnimatedBorderTextCornerRadius( fontSize = fontSize, modifier = modifier - .drawBehind { - val brush = - Brush.sweepGradient( - colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow), + .drawWithCache { + // Brush, stroke geometry and corner radius don't change per + // frame; only the dash phase does. Build them once per size. + val strokeWidth = 2.dp.toPx() + val cornerRadius = CornerRadius(6.dp.toPx()) + onDrawBehind { + drawRoundRect( + brush = GLOW_BRUSH, + style = + Stroke( + width = strokeWidth, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect(DASH_INTERVALS, animatedFloatRestart.value), + ), + cornerRadius = cornerRadius, ) - - drawRoundRect( - brush = brush, - style = - Stroke( - width = 2.dp.toPx(), - cap = StrokeCap.Round, - join = StrokeJoin.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), animatedFloatRestart.value), - ), - cornerRadius = - androidx.compose.ui.geometry - .CornerRadius(6.dp.toPx()), - ) + } }.padding(3.dp), color = color, textAlign = textAlign, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingAnimation.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingAnimation.kt similarity index 88% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingAnimation.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingAnimation.kt index 2b98a61fe5..26e57a9a44 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingAnimation.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingAnimation.kt @@ -32,11 +32,11 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList @@ -65,7 +65,9 @@ fun LoadingAnimation( ) { val infiniteTransition = rememberInfiniteTransition() - val rotateAnimation by + // Read in graphicsLayer, not composition: `Modifier.rotate(value)` recomposed + // the indicator every frame of the loop and rebuilt the sweep gradient. + val rotateAnimation = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, @@ -80,15 +82,17 @@ fun LoadingAnimation( label = "UploadGalleryUploadingAnimation", ) + val brush = remember(circleColors) { Brush.sweepGradient(circleColors) } + CircularProgressIndicator( progress = { 1f }, modifier = Modifier .size(size = indicatorSize) - .rotate(degrees = rotateAnimation) + .graphicsLayer { rotationZ = rotateAnimation.value } .border( width = circleWidth, - brush = Brush.sweepGradient(circleColors), + brush = brush, shape = CircleShape, ), color = MaterialTheme.colorScheme.background, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Nip05OrPubkeyLine.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Nip05OrPubkeyLine.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Nip05OrPubkeyLine.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Nip05OrPubkeyLine.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt similarity index 89% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt index 6421bc9717..2ea14b21e8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt @@ -31,17 +31,7 @@ import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash - -/** - * Determines if the current color scheme is light. - * Uses the luminance of the background color. - */ -@Composable -private fun isLightTheme(): Boolean { - val background = MaterialTheme.colorScheme.background - // Simple luminance check: if any RGB component > 0.5, consider it light - return (background.red + background.green + background.blue) / 3 > 0.5f -} +import com.vitorpamplona.amethyst.commons.ui.theme.isLight /** * Displays a robohash image based on a seed string (typically a public key). @@ -61,7 +51,7 @@ fun RobohashImage( ) { if (loadRobohash) { Image( - imageVector = CachedRobohash.get(robot, isLightTheme()), + imageVector = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight), contentDescription = contentDescription, modifier = modifier, ) @@ -90,7 +80,7 @@ fun RobohashImage( ) { if (loadRobohash) { Image( - painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())), + painter = rememberVectorPainter(CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight)), contentDescription = contentDescription, modifier = modifier, alignment = alignment, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt similarity index 68% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt index 0be7a93ab2..c38be8f417 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt @@ -26,7 +26,6 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,9 +37,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.dp @@ -48,32 +48,47 @@ import androidx.compose.ui.unit.dp @Composable fun ShimmerPlaceholder(modifier: Modifier = Modifier) { val transition = rememberInfiniteTransition(label = "shimmer") - val translateAnim by transition.animateFloat( - initialValue = 0f, - targetValue = 1000f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1200, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "shimmerTranslate", - ) + // Kept as a State (no `by`) and read inside drawBehind: reading it in + // composition recomposed every shimmer — six per NoteCardSkeleton — at the + // display refresh rate and rebuilt the brush and background modifier each + // frame, exactly while the feed is busy parsing events. + val translateAnim = + transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmerTranslate", + ) + val colorScheme = MaterialTheme.colorScheme val shimmerColors = - listOf( - MaterialTheme.colorScheme.surfaceContainerHigh, - MaterialTheme.colorScheme.surfaceContainer, - MaterialTheme.colorScheme.surfaceContainerHigh, - ) + remember(colorScheme) { + listOf( + colorScheme.surfaceContainerHigh, + colorScheme.surfaceContainer, + colorScheme.surfaceContainerHigh, + ) + } + val shape = MaterialTheme.shapes.small - val brush = - Brush.linearGradient( - colors = shimmerColors, - start = Offset(translateAnim - 200f, translateAnim - 200f), - end = Offset(translateAnim, translateAnim), - ) - - Box(modifier.background(brush, MaterialTheme.shapes.small)) + Box( + modifier + .clip(shape) + .drawBehind { + val t = translateAnim.value + drawRect( + Brush.linearGradient( + colors = shimmerColors, + start = Offset(t - 200f, t - 200f), + end = Offset(t, t), + ), + ) + }, + ) } @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/TranslationConfig.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/TranslationConfig.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/TranslationConfig.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/TranslationConfig.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZonedSwipeModifier.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZonedSwipeModifier.kt similarity index 90% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZonedSwipeModifier.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZonedSwipeModifier.kt index 7ac6216eca..a0e8754ed1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZonedSwipeModifier.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZonedSwipeModifier.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -50,8 +51,12 @@ fun Modifier.zonedDrawerSwipe( var gestureStartPage by remember { mutableIntStateOf(0) } var drawerOpened by remember { mutableStateOf(false) } + // The connection is remembered for the pager's lifetime; read the + // current lambda through rememberUpdatedState so a caller that + // re-creates openDrawer (new drawer state, account switch) is honoured. + val currentOpenDrawer by rememberUpdatedState(openDrawer) val connection = - remember { + remember(pagerState) { object : NestedScrollConnection { override fun onPreScroll( available: Offset, @@ -68,7 +73,7 @@ fun Modifier.zonedDrawerSwipe( if (!wasOnFirstPage && !isInPagerZone) { drawerOpened = true - openDrawer() + currentOpenDrawer() return Offset(available.x, 0f) } } @@ -87,7 +92,7 @@ fun Modifier.zonedDrawerSwipe( // so child LazyRows can scroll first. if (available.x > 0f && gestureStartPage == 0) { drawerOpened = true - openDrawer() + currentOpenDrawer() return Offset(available.x, 0f) } return Offset.Zero diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt index 7a28125ced..afb87d271d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt @@ -31,6 +31,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt index 119e2ec795..9074a494dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt @@ -28,6 +28,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarNestedScroll.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarNestedScroll.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarNestedScroll.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarNestedScroll.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingBarState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/GenericRepostLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/GenericRepostLayout.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/GenericRepostLayout.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/GenericRepostLayout.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ActivityCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ActivityCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ActivityCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ActivityCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BirdexCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BirdexCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BirdexCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BirdexCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CodeSnippetCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CodeSnippetCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CodeSnippetCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CodeSnippetCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/EcashMintCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/EcashMintCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/EcashMintCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/EcashMintCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitDiffView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitDiffView.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitDiffView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitDiffView.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastChips.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastChips.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastChips.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastChips.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastSoundbites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastSoundbites.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastSoundbites.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastSoundbites.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastValueSplits.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastValueSplits.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastValueSplits.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PodcastValueSplits.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/Ps1SaveCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/Ps1SaveCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/Ps1SaveCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/Ps1SaveCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RelayDiscoveryCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RelayDiscoveryCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RelayDiscoveryCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RelayDiscoveryCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RoadEventCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RoadEventCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RoadEventCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/RoadEventCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt index cc46f38a24..88bdf56eb7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.notifications import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import kotlinx.coroutines.flow.MutableStateFlow /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt similarity index 77% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt index 0b611c9958..1cce447446 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt @@ -82,28 +82,13 @@ fun InLineIconRenderer( if (fontSize == TextUnit.Unspecified) 22.sp else fontSize.times(1.1f) } + // Remembered like annotatedText below: rebuilding this map handed Text a + // fresh unstable Map (plus a Placeholder and lambda per emoji) on every + // recomposition of every name/note with a custom emoji. val inlineContent = - wordsInOrder - .mapIndexedNotNull { idx, value -> - if (value is CustomEmoji.ImageUrlType) { - "inlineContent$idx" to - InlineTextContent( - Placeholder( - width = placeholderSize, - height = placeholderSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - AsyncImage( - model = value.url, - contentDescription = null, - modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp), - ) - } - } else { - null - } - }.associate { it.first to it.second } + remember(wordsInOrder, placeholderSize) { + wordsInOrder.buildInlineContent(placeholderSize) + } val annotatedText = remember(wordsInOrder, style) { @@ -127,3 +112,25 @@ fun InLineIconRenderer( modifier = modifier, ) } + +private fun ImmutableList.buildInlineContent(placeholderSize: TextUnit): Map = + mapIndexedNotNull { idx, value -> + if (value is CustomEmoji.ImageUrlType) { + "inlineContent$idx" to + InlineTextContent( + Placeholder( + width = placeholderSize, + height = placeholderSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp), + ) + } + } else { + null + } + }.associate { it.first to it.second } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchCalendarPicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchCalendarPicker.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchCalendarPicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchCalendarPicker.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldSources.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldSources.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldSources.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldSources.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchFieldState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchPeoplePicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchPeoplePicker.kt similarity index 93% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchPeoplePicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchPeoplePicker.kt index a8f3ade857..ad427f317a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchPeoplePicker.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchPeoplePicker.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -76,9 +76,9 @@ fun SearchPeoplePicker( ) { if (candidates.isEmpty()) return LazyColumn(modifier.heightIn(max = 280.dp)) { - items(candidates, key = { it.pubkeyHex }) { candidate -> + itemsIndexed(candidates, key = { _, candidate -> candidate.pubkeyHex }) { index, candidate -> PickerRow( - highlighted = candidates.indexOf(candidate) == highlighted, + highlighted = index == highlighted, onClick = { onPick(candidate) }, leading = { UserAvatar( @@ -105,9 +105,9 @@ fun SearchGroupPicker( ) { if (candidates.isEmpty()) return LazyColumn(modifier.heightIn(max = 280.dp)) { - items(candidates, key = { it.id }) { candidate -> + itemsIndexed(candidates, key = { _, candidate -> candidate.id }) { index, candidate -> PickerRow( - highlighted = candidates.indexOf(candidate) == highlighted, + highlighted = index == highlighted, onClick = { onPick(candidate) }, title = candidate.name, subtitle = candidate.subtitle, @@ -132,9 +132,9 @@ fun SearchKindPicker( ) { if (candidates.isEmpty()) return LazyColumn(modifier.heightIn(max = 280.dp)) { - items(candidates, key = { it.alias }) { candidate -> + itemsIndexed(candidates, key = { _, candidate -> candidate.alias }) { index, candidate -> PickerRow( - highlighted = candidates.indexOf(candidate) == highlighted, + highlighted = index == highlighted, onClick = { onPick(candidate) }, title = candidate.alias, // What the token will actually ask for. `kind:video` is four kinds and diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenStyles.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenStyles.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenStyles.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenStyles.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformation.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformation.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformation.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformation.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/TokenizedSearchField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/TokenizedSearchField.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/TokenizedSearchField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/search/TokenizedSearchField.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/AsyncCachedState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/AsyncCachedState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/AsyncCachedState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/AsyncCachedState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/CachedState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/CachedState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/CachedState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/state/CachedState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensions.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensions.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldValueExtensions.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/UiThread.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/UiThread.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/UiThread.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/UiThread.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/NoteBorders.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/NoteBorders.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/NoteBorders.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/NoteBorders.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Sizes.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Sizes.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Sizes.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Sizes.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/thread/ThreadModifiers.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/thread/ThreadModifiers.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/thread/ThreadModifiers.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/thread/ThreadModifiers.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt index 44179cb763..dceaeaaf97 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt @@ -28,11 +28,11 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.ThreadLevelCalculator import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt rename to commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt rename to commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt rename to commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformationTest.kt b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformationTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformationTest.kt rename to commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/search/SearchTokenTransformationTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt rename to commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt similarity index 100% rename from commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt rename to commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlossomReadAuthFetcher.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlossomReadAuthFetcher.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlossomReadAuthFetcher.kt rename to commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/image/BlossomReadAuthFetcher.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewState.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewState.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewState.kt rename to commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewState.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/markdown/RenderMarkdown.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/markdown/RenderMarkdown.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/markdown/RenderMarkdown.kt rename to commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/markdown/RenderMarkdown.kt diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt similarity index 100% rename from commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt rename to commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletShellResourceTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletShellResourceTest.kt similarity index 100% rename from commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletShellResourceTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletShellResourceTest.kt diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt similarity index 100% rename from commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt diff --git a/commons/src/skikoMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/SkiaBitmapConverter.kt b/commonsUI/src/skikoMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/SkiaBitmapConverter.kt similarity index 100% rename from commons/src/skikoMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/SkiaBitmapConverter.kt rename to commonsUI/src/skikoMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/SkiaBitmapConverter.kt diff --git a/crowdin.yml b/crowdin.yml index f2aae05f9d..2b8aa58db0 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -31,8 +31,8 @@ files: # commons (Compose Multiplatform resources, consumed by amethyst + desktopApp). # Same android layout + language mapping as the amethyst source so a key reads # identically whichever module owns it after the note-ui-commons extraction. - - source: commons/src/commonMain/composeResources/values/strings.xml - translation: commons/src/commonMain/composeResources/values-%android_code%/%original_file_name% + - source: commonsUI/src/commonMain/composeResources/values/strings.xml + translation: commonsUI/src/commonMain/composeResources/values-%android_code%/%original_file_name% type: android skip_untranslated_strings: true languages_mapping: diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 7e3fea51fb..4c6d6523f1 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -35,8 +35,9 @@ dependencies { // Quartz Nostr library (will use JVM target) implementation(project(":quartz")) - // Commons library + // Commons library (headless) + its Compose UI half implementation(project(":commons")) + implementation(project(":commonsUI")) // Lifecycle ViewModel (needed to access ViewModel supertype from commons) implementation(libs.androidx.lifecycle.viewmodel.compose) @@ -254,7 +255,7 @@ compose.desktop { // The arch is selected at task-execution time from the host JVM's os.arch, so // the same task builds the correct AppImage on both x86_64 and aarch64 hosts. // BUILDING.md documents local-dev fetch. -val createReleaseAppImage by tasks.registering(Exec::class) { +val createReleaseAppImage = tasks.register("createReleaseAppImage") { group = "compose desktop" description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool." dependsOn("createReleaseDistributable") @@ -330,7 +331,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) { // proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is // a regression guard, not a workaround. It's wired onto every release task so // it fails the build immediately if the .so disappears. -val verifyJkeychainNativeSurvivesProguard by tasks.registering { +val verifyJkeychainNativeSurvivesProguard = tasks.register("verifyJkeychainNativeSurvivesProguard") { description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)" group = "verification" dependsOn("proguardReleaseJars") @@ -395,7 +396,7 @@ listOf( // into the .app) so the subsequent bundle signing seals already-signed code. // Runs only on macOS with the Developer ID identity exported — a no-op on every // other leg and on unsigned local/PR builds. -val signMacJarNatives by tasks.registering { +val signMacJarNatives = tasks.register("signMacJarNatives") { description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized" group = "build" dependsOn("proguardReleaseJars") diff --git a/desktopApp/packaging/homebrew/amethyst-nostr.rb b/desktopApp/packaging/homebrew/amethyst-nostr.rb index 73bbc8af87..ce1a88865b 100644 --- a/desktopApp/packaging/homebrew/amethyst-nostr.rb +++ b/desktopApp/packaging/homebrew/amethyst-nostr.rb @@ -45,8 +45,8 @@ # which writes into that single SHARED plist — deleting it would wipe every # other Java application's preferences too. cask "amethyst-nostr" do - version "1.15.1" - sha256 "9a23e43846072b2eb7e980b3348c06536780235cf5c8ab7e18afe32c6e3fa02d" + version "1.15.2" + sha256 "c02141beeb95fa2160f8c074a4a08bbf9851240da6dec66fa10a3ae41a3b5b1e" url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg" name "Amethyst" diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml index 1ac60889ee..d08e1214dd 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml @@ -13,7 +13,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json PackageIdentifier: VitorPamplona.Amethyst -PackageVersion: 1.15.1 +PackageVersion: 1.15.2 Platform: - Windows.Desktop MinimumOSVersion: 10.0.0.0 @@ -22,11 +22,11 @@ Scope: machine UpgradeBehavior: install Installers: - Architecture: x64 - InstallerUrl: https://github.com/vitorpamplona/amethyst/releases/download/v1.15.1/amethyst-desktop-1.15.1-windows-x64.msi + InstallerUrl: https://github.com/vitorpamplona/amethyst/releases/download/v1.15.2/amethyst-desktop-1.15.2-windows-x64.msi # Quoted deliberately: an all-digit 64-char value is a YAML *integer*, which # fails the schema's `string` type. Quoting makes it a string whatever the # digest happens to be. - InstallerSha256: 'BDE701816C921B13A1BD59C93E3592303666B32853768465C259684B15BCAF2D' - ProductCode: '{76571DF8-3F51-3130-B789-B02D93EB8871}' + InstallerSha256: '2AE2C5FCE1C89D28EBFB984C75D9CC0144D0DDFAB279C68E47A85AD197101EDD' + ProductCode: '{B1E85B09-3C55-3223-981E-34F39970FC63}' ManifestType: installer ManifestVersion: 1.12.0 diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml index 65d21e93aa..82905c605c 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml @@ -5,7 +5,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json PackageIdentifier: VitorPamplona.Amethyst -PackageVersion: 1.15.1 +PackageVersion: 1.15.2 PackageLocale: en-US Publisher: Vitor Pamplona PublisherUrl: https://github.com/vitorpamplona @@ -28,7 +28,7 @@ Tags: - nostr - social - social-network -ReleaseNotesUrl: https://github.com/vitorpamplona/amethyst/releases/tag/v1.15.1 +ReleaseNotesUrl: https://github.com/vitorpamplona/amethyst/releases/tag/v1.15.2 Documentations: - DocumentLabel: Building DocumentUrl: https://github.com/vitorpamplona/amethyst/blob/main/BUILDING.md diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml index 73b01b81aa..0495cadffe 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml @@ -9,7 +9,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json PackageIdentifier: VitorPamplona.Amethyst -PackageVersion: 1.15.1 +PackageVersion: 1.15.2 DefaultLocale: en-US ManifestType: version ManifestVersion: 1.12.0 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 88c1a30aff..02b582fc95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerClientMetadata import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -69,6 +70,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import java.io.File +import kotlin.coroutines.cancellation.CancellationException sealed class AccountState { data object Loading : AccountState() @@ -259,12 +261,93 @@ class AccountManager internal constructor( // --- Account loading --- + /** + * Phase 1 of the vault migration: fold the account-metadata key into `vault-v1`. + * + * This MUST complete before any read that needs that key, because the migration + * deletes the legacy per-alias item. A storage read that gets there first finds + * the item gone and mints a fresh AES key over the one that decrypts + * accounts.json.enc, wiping every account. `refreshAccountListOnStartup()` runs + * before `loadSavedAccount()` on the startup path and does exactly that read, so + * phase 1 is hoisted here and every storage entry point calls it. + * + * Run-once and idempotent: the flag is set inside the lock, so concurrent + * callers serialise and only the first does the work. + */ + private suspend fun ensureVaultMetadataKeyMigrated() { + vaultBootstrapMutex.withLock { + if (vaultMetadataKeyMigrated) return@withLock + vaultMetadataKeyMigrated = true + try { + secureStorage.enableConsolidatedVault(listOf(DesktopAccountStorage.METADATA_KEY_ALIAS)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("AccountManager", "Consolidated keychain vault phase 1 failed; continuing on legacy per-alias reads", e) + } + } + } + + private val vaultBootstrapMutex = Mutex() + private var vaultMetadataKeyMigrated = false + + /** + * Two-phase consolidation of every Amethyst-owned keychain item into the + * single `vault-v1` item that [SecureKeyStorage.enableConsolidatedVault] + * manages, so cold-boot triggers at most one macOS Keychain Access prompt + * regardless of how many accounts (each with its own nsec, per-account + * bunker ephemeral, and NWC URI) the user has. + * + * Phase 1 migrates only the `account-metadata-key` (the AES key that + * decrypts `accounts.json.enc`). Nothing else can be enumerated before + * that file is readable, so this phase runs against a single-alias + * candidate list. It is a no-op on fresh installs (no legacy item) and + * on already-migrated setups (vault-v1 exists). + * + * Phase 2 runs after `accounts.json.enc` has been decrypted and the full + * npub list is known. For each npub we add the nsec alias itself, the + * per-account bunker-ephemeral alias, and the NWC alias. The legacy + * shared bunker-ephemeral alias is included for the pre-per-account + * migration compatibility branch in [loadBunkerAccount]. Phase 2 is + * idempotent (see [SecureKeyStorage.enableConsolidatedVault]) so it is + * safe to run on every startup and to include aliases the vault already + * covers. + */ + private suspend fun bootstrapConsolidatedVault() { + ensureVaultMetadataKeyMigrated() + try { + val npubs = accountStorage.loadAccounts().map { it.npub } + val aliases = mutableListOf() + aliases += DesktopAccountStorage.METADATA_KEY_ALIAS + aliases += LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS + for (npub in npubs) { + aliases += npub + aliases += bunkerEphemeralKeyAlias(npub) + aliases += nwcKeyAlias(npub) + } + secureStorage.enableConsolidatedVault(aliases) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Non-fatal: SecureKeyStorage falls back to legacy per-alias reads for + // anything the vault does not cover, so the worst case is the old + // multi-prompt behaviour. Log it -- swallowing this silently made a + // half-migrated keychain impossible to diagnose from a user report. + Log.w("AccountManager", "Consolidated keychain vault bootstrap failed; continuing on legacy per-alias reads", e) + } + } + suspend fun loadSavedAccount(): Result = try { // Clean up legacy files (one-time) listOf("last_account.txt", "bunker_uri.txt", "nwc_connection.txt") .forEach { File(amethystDir, it).deleteOrWarn("AccountManager", "legacy file") } + // Consolidate keychain items into vault-v1 so macOS prompts once, not per + // item. Phase 1 may already have run via refreshAccountListOnStartup(); + // it is run-once, so this call just adds phase 2. + bootstrapConsolidatedVault() + // Single source of truth: accounts.json.enc val activeNpub = accountStorage.currentAccount() @@ -280,7 +363,7 @@ class AccountManager internal constructor( is SignerType.Remote -> loadBunkerAccount((info.signerType as SignerType.Remote).bunkerUri, activeNpub) is SignerType.ViewOnly -> loadReadOnlyAccount(activeNpub) } - } catch (e: kotlin.coroutines.cancellation.CancellationException) { + } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) @@ -754,6 +837,9 @@ class AccountManager internal constructor( // --- Multi-account management --- suspend fun refreshAccountList() { + // Reads accounts.json.enc, which needs the metadata key -- so the vault + // migration has to have happened first. See [ensureVaultMetadataKeyMigrated]. + ensureVaultMetadataKeyMigrated() _allAccounts.value = accountStorage.loadAccounts().toImmutableList() } @@ -924,7 +1010,7 @@ class AccountManager internal constructor( _nwcConnection.value = parsed Result.success(parsed) - } catch (e: kotlin.coroutines.cancellation.CancellationException) { + } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt index 3f6ff53ec2..75b1dd7088 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt @@ -23,12 +23,16 @@ package com.vitorpamplona.amethyst.desktop.account import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.AccountStorage import com.vitorpamplona.amethyst.commons.model.account.SignerType import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File +import java.io.RandomAccessFile import java.nio.file.Files import java.nio.file.StandardCopyOption import java.nio.file.attribute.PosixFilePermission @@ -58,6 +62,16 @@ sealed class StorageCorruption( class JsonMalformed( backupPath: String?, ) : StorageCorruption(backupPath) + + /** + * A transient failure surfaced from the read path (I/O error, keychain refused + * or otherwise ambiguous access, OOM, etc). No backup was written and the + * on-disk file is untouched. Callers should retry or surface an error UI rather + * than treating this as data loss. See [DesktopAccountStorage.readMetadataFromDisk]. + */ + class TransientError( + val cause: Throwable, + ) : StorageCorruption(backupPath = null) } class DesktopAccountStorage( @@ -66,8 +80,18 @@ class DesktopAccountStorage( private val onCorruption: (StorageCorruption) -> Unit = {}, ) : AccountStorage { companion object { - private const val METADATA_KEY_ALIAS = "account-metadata-key" + /** + * Alias under which the AES-256-GCM key that encrypts + * `accounts.json.enc` is stored in [SecureKeyStorage]. + * + * Exposed as `internal` so [AccountManager.bootstrapConsolidatedVault] + * can name it in the phase-1 candidate list. The single source of truth + * for the alias string stays here where the key is actually read and + * written. + */ + internal const val METADATA_KEY_ALIAS = "account-metadata-key" private const val ACCOUNTS_FILE = "accounts.json.enc" + private const val ACCOUNTS_LOCK_FILE = "accounts.json.enc.lock" private const val AES_KEY_SIZE = 32 // 256 bits private const val GCM_IV_SIZE = 12 private const val GCM_TAG_BITS = 128 @@ -76,38 +100,51 @@ class DesktopAccountStorage( private val mapper = jacksonObjectMapper() private val amethystDir by lazy { File(homeDir, ".amethyst") } - // In-memory cache — read from disk once, then serve from memory + // In-memory cache: read from disk once, then serve from memory private var cachedMetadata: AccountMetadata? = null + // In-process mutex around the cross-process file lock. Two callers inside + // the same JVM would otherwise fail with OverlappingFileLockException from + // FileChannel.lock(), since JVM file locks are per-JVM not per-thread. + private val fileLockMutex = Mutex() + + // Guards read-modify-write cycles on [cachedMetadata]. Distinct from + // [fileLockMutex] so we can hold it across a full read + mutate + write + // sequence (the file lock is taken and released inside each disk op). + private val stateMutex = Mutex() + // --- AccountStorage interface --- override suspend fun loadAccounts(): List = getCachedMetadata().accounts.map { it.toAccountInfo() } - override suspend fun saveAccount(info: AccountInfo) { - val metadata = getCachedMetadata() - val dto = AccountInfoDto.from(info) - val updated = metadata.accounts.filter { it.npub != info.npub } + dto - writeCachedMetadata(metadata.copy(accounts = updated)) - } + override suspend fun saveAccount(info: AccountInfo) = + stateMutex.withLock { + val metadata = getCachedMetadata() + val dto = AccountInfoDto.from(info) + val updated = metadata.accounts.filter { it.npub != info.npub } + dto + writeCachedMetadata(metadata.copy(accounts = updated)) + } - override suspend fun deleteAccount(npub: String) { - val metadata = getCachedMetadata() - val updated = metadata.accounts.filter { it.npub != npub } - val newActive = - if (metadata.activeNpub == npub) { - updated.firstOrNull()?.npub - } else { - metadata.activeNpub - } - writeCachedMetadata(metadata.copy(accounts = updated, activeNpub = newActive)) - } + override suspend fun deleteAccount(npub: String) = + stateMutex.withLock { + val metadata = getCachedMetadata() + val updated = metadata.accounts.filter { it.npub != npub } + val newActive = + if (metadata.activeNpub == npub) { + updated.firstOrNull()?.npub + } else { + metadata.activeNpub + } + writeCachedMetadata(metadata.copy(accounts = updated, activeNpub = newActive)) + } override suspend fun currentAccount(): String? = getCachedMetadata().activeNpub - override suspend fun setCurrentAccount(npub: String) { - val metadata = getCachedMetadata() - writeCachedMetadata(metadata.copy(activeNpub = npub)) - } + override suspend fun setCurrentAccount(npub: String) = + stateMutex.withLock { + val metadata = getCachedMetadata() + writeCachedMetadata(metadata.copy(activeNpub = npub)) + } // --- Cached I/O --- @@ -118,9 +155,17 @@ class DesktopAccountStorage( return loaded } + /** + * Persists first, caches second. + * + * If the disk write fails (keychain refused, I/O error, disk full) the in-memory + * cache must NOT be left claiming a state that was never written: the rest of the + * session would serve accounts that vanish on the next launch, and the user would + * see a successful save that silently did nothing. + */ private suspend fun writeCachedMetadata(metadata: AccountMetadata) { - cachedMetadata = metadata writeMetadataToDisk(metadata) + cachedMetadata = metadata } // --- Encrypted file I/O --- @@ -129,9 +174,17 @@ class DesktopAccountStorage( val file = getAccountsFile() if (!file.exists()) return AccountMetadata() + ensureDir() + return withAccountsFileLock { + readMetadataFromDiskLocked(file) + } + } + + private suspend fun readMetadataFromDiskLocked(file: File): AccountMetadata { val encrypted = file.readBytes() if (encrypted.size < GCM_IV_SIZE) { - val backup = backupCorruptFile(file) + // Genuinely unusable: not enough bytes for the IV. Back up and reset. + val backup = backupCorruptFile(file, ".corrupt") onCorruption(StorageCorruption.FileCorrupted(backup)) return AccountMetadata() } @@ -140,31 +193,43 @@ class DesktopAccountStorage( val decrypted = decrypt(encrypted) mapper.readValue(decrypted) } catch (e: javax.crypto.AEADBadTagException) { - Log.e("DesktopAccountStorage", "GCM auth tag mismatch — file corrupted or key lost", e) - val backup = backupCorruptFile(file) + // Genuine ciphertext corruption or lost/rotated AES key. + Log.e("DesktopAccountStorage", "GCM auth tag mismatch, file corrupted or key lost", e) + val backup = backupCorruptFile(file, ".corrupt") onCorruption(StorageCorruption.FileCorrupted(backup)) AccountMetadata() } catch (e: javax.crypto.BadPaddingException) { - Log.e("DesktopAccountStorage", "Decryption failed — file corrupted", e) - val backup = backupCorruptFile(file) + // Genuine ciphertext corruption. + Log.e("DesktopAccountStorage", "Decryption failed, file corrupted", e) + val backup = backupCorruptFile(file, ".corrupt") onCorruption(StorageCorruption.FileCorrupted(backup)) AccountMetadata() } catch (e: com.fasterxml.jackson.core.JacksonException) { + // Schema mismatch: decrypted cleanly but the JSON does not fit our shape. + // Distinct suffix so operators can tell it apart from ciphertext corruption. Log.e("DesktopAccountStorage", "JSON malformed after decryption", e) - val backup = backupCorruptFile(file) + val backup = backupCorruptFile(file, ".jsonerror") onCorruption(StorageCorruption.JsonMalformed(backup)) AccountMetadata() + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e } catch (e: Exception) { - Log.e("DesktopAccountStorage", "Failed to read accounts metadata", e) - val backup = backupCorruptFile(file) - onCorruption(StorageCorruption.FileCorrupted(backup)) - AccountMetadata() + // Transient failure: I/O error, keychain refused / ambiguous, OOM, etc. + // DO NOT rename the on-disk file; the ciphertext is intact and the next + // launch may succeed (for example after the user re-approves the + // Keychain Access prompt). Surface up for the caller to decide. + Log.e("DesktopAccountStorage", "Transient error reading accounts metadata; file preserved", e) + onCorruption(StorageCorruption.TransientError(e)) + throw e } } - private fun backupCorruptFile(file: File): String? = + private fun backupCorruptFile( + file: File, + suffix: String, + ): String? = try { - val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}") + val backup = File(file.parent, "${file.name}$suffix.${System.currentTimeMillis()}") java.nio.file.Files .copy(file.toPath(), backup.toPath()) file.deleteOrWarn("DesktopAccountStorage", "corrupt accounts file") @@ -178,31 +243,103 @@ class DesktopAccountStorage( val json = mapper.writeValueAsBytes(metadata) val encrypted = encrypt(json) - // Atomic write via temp file val file = getAccountsFile() - val temp = File(amethystDir, "${ACCOUNTS_FILE}.tmp") - temp.writeBytes(encrypted) - Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) - - setFilePermissions(file) + withAccountsFileLock { + // Atomic write via temp file, under the cross-process lock so two + // Amethyst instances (Homebrew upgrade race, accidental double-launch) + // cannot interleave writes and truncate the file. + val temp = File(amethystDir, "$ACCOUNTS_FILE.tmp") + temp.writeBytes(encrypted) + Files.move( + temp.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + setFilePermissions(file) + } } + /** + * Cross-process advisory lock + in-process mutex around the accounts.json.enc + * read/write critical section. The mutex is required because JVM + * `FileChannel.lock()` is a per-JVM lock and would throw + * `OverlappingFileLockException` on the second acquire from the same JVM. + * The channel lock is required to keep two Amethyst processes serial (upgrade + * race, accidental double-launch, cron-style relaunch). + * + * Mirrors the pattern used in SecureKeyStorage.withFileLock; kept private + * to this class so the two lock lifecycles stay independent. + */ + private suspend inline fun withAccountsFileLock(crossinline block: suspend () -> T): T = + fileLockMutex.withLock { + val lockFile = File(amethystDir, ACCOUNTS_LOCK_FILE) + if (!lockFile.exists()) { + lockFile.createNewFile() + setFilePermissions(lockFile) + } + RandomAccessFile(lockFile, "rw").use { raf -> + raf.channel.lock().use { _ -> + block() + } + } + } + private fun getAccountsFile() = File(amethystDir, ACCOUNTS_FILE) // --- AES-256-GCM encryption --- private var cachedKey: ByteArray? = null + /** + * Reads (or creates on first launch) the metadata AES key. + * + * Distinguishes: + * - key exists in keychain: use it + * - keychain confirms definitively absent: generate + persist a fresh key + * - any other outcome (user cancelled/denied prompt, keychain locked, + * backend transient error): propagate the exception, do NOT rotate -- + * unless there is no accounts.json.enc yet, in which case there is no + * ciphertext to orphan and we bootstrap a fresh key (see below). + * + * Rotating the AES key on an ambiguous miss silently destroys the ability + * to decrypt the existing accounts.json.enc, wiping the logged-in accounts + * on next launch. That is the bug this method exists to prevent. + */ private suspend fun getOrCreateKey(): ByteArray { cachedKey?.let { return it } - val existing = secureStorage.getPrivateKey(METADATA_KEY_ALIAS) + val existing = + try { + secureStorage.getPrivateKeyOrThrow(METADATA_KEY_ALIAS) + } catch (e: SecureStorageException) { + // Bootstrap escape. Every non-macOS backend java-keyring ships + // (Windows Credential Store, Freedesktop Secret Service, KWallet) + // throws PasswordAccessException for a *genuinely absent* credential, + // so the strict lookup structurally cannot report "definitively + // absent" there. Without this branch a fresh Linux/Windows install + // could never mint the key and could never persist an account. + // + // Minting is only safe while there is no accounts.json.enc: with no + // ciphertext on disk there is nothing a new key can orphan. Once the + // file exists the strict contract applies and we propagate. + if (getAccountsFile().exists()) throw e + Log.w( + "DesktopAccountStorage", + "Keychain lookup failed and no accounts file exists; bootstrapping a fresh metadata key", + e, + ) + null + } + if (existing != null) { val key = Base64.getDecoder().decode(existing) cachedKey = key return key } + // Definitively absent (or bootstrapping with nothing on disk): safe to + // create and persist a fresh key. val key = ByteArray(AES_KEY_SIZE).also { SecureRandom().nextBytes(it) } secureStorage.savePrivateKey(METADATA_KEY_ALIAS, Base64.getEncoder().encodeToString(key)) cachedKey = key diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 2177994fd9..348b47a8a6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -63,22 +63,22 @@ import androidx.compose.ui.unit.min import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.UserMetadataCache -import com.vitorpamplona.amethyst.commons.nip64Chess.ActiveGameCard -import com.vitorpamplona.amethyst.commons.nip64Chess.ChallengeCard -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastBanner import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge import com.vitorpamplona.amethyst.commons.nip64Chess.ChessConfig -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPlayerVsHeader -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGame -import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGameCard -import com.vitorpamplona.amethyst.commons.nip64Chess.InteractiveChessBoard -import com.vitorpamplona.amethyst.commons.nip64Chess.NewChessGameDialog -import com.vitorpamplona.amethyst.commons.nip64Chess.OutgoingChallengeCard -import com.vitorpamplona.amethyst.commons.nip64Chess.OverlappingAvatars import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGame -import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGameCard -import com.vitorpamplona.amethyst.commons.nip64Chess.SpectatingGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ActiveGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChallengeCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessPlayerVsHeader +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.CompletedGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.InteractiveChessBoard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.OutgoingChallengeCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.OverlappingAvatars +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.PublicGameCard +import com.vitorpamplona.amethyst.commons.nip64Chess.ui.SpectatingGameCard import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 248d2d7409..2dba33235d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.feeds +import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.feeds.DefaultFeedOrder +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource +import com.vitorpamplona.amethyst.commons.feeds.isRenderableRepost import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter -import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index a3e8cb262b..9ae8b96117 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -88,9 +88,11 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.actions.ReplyActions +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.ReplyContext import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction @@ -105,12 +107,10 @@ import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.feeds.rememberNewPostsChipState import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout -import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.SearchHistoryStore diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index dad4160970..69662213e8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.actions.ReplyActions +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 6a22d25e3f..eb8b8242f6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -74,6 +74,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -84,7 +85,6 @@ import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.util.showAmount import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 7fd4690ba1..8c1bec2d71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -90,6 +90,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -103,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt index 2490177cfe..53013a900f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.FlowRowOverflow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -146,7 +145,6 @@ fun LivesSection( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), maxLines = 2, - overflow = FlowRowOverflow.Clip, modifier = Modifier.fillMaxWidth(), ) { ranked.forEach { channel -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 27d6ae795a..54b5971176 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -56,12 +56,12 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.model.ReplyContext import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt index bb9f39e71c..b6cfb8486d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.desktop.viewmodels import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt index 4d511c57f7..6059f961fe 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -58,6 +58,7 @@ class AccountManagerKeyLoginTest { val keySlot = slot() val valueSlot = slot() coEvery { storage.getPrivateKey(capture(keySlot)) } answers { keyStore[keySlot.captured] } + coEvery { storage.getPrivateKeyOrThrow(capture(keySlot)) } answers { keyStore[keySlot.captured] } coEvery { storage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers { keyStore[keySlot.captured] = valueSlot.captured } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt index 8b01b71677..5aad247a29 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt @@ -49,6 +49,7 @@ class AccountManagerLoadAccountTest { storage = mockk(relaxed = true) // Return null so DesktopAccountStorage generates a fresh AES key coEvery { storage.getPrivateKey("account-metadata-key") } returns null + coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-load-test").toFile() amethystDir = File(tempDir, ".amethyst") amethystDir.mkdirs() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt index f98a4c3407..602016ed5f 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt @@ -63,6 +63,7 @@ class AccountManagerLoadStateTransitionsTest { fun setup() { storage = mockk(relaxed = true) coEvery { storage.getPrivateKey("account-metadata-key") } returns null + coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-load-state").toFile() File(tempDir, ".amethyst").mkdirs() manager = AccountManager(storage, tempDir) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt index 2f74a42390..741b79044d 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt @@ -46,6 +46,7 @@ class AccountManagerLogoutTest { fun setup() { storage = mockk(relaxed = true) coEvery { storage.getPrivateKey("account-metadata-key") } returns null + coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-logout-test").toFile() manager = AccountManager(storage, tempDir) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt index f2688a2819..6cb2a59e15 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt @@ -56,6 +56,7 @@ class AccountManagerNip46IsolationTest { fun setup() { storage = mockk(relaxed = true) coEvery { storage.getPrivateKey("account-metadata-key") } returns null + coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile() amethystDir = File(tempDir, ".amethyst") amethystDir.mkdirs() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt index 6734d49793..62e5eb08c0 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt @@ -56,6 +56,7 @@ class AccountManagerStateTransitionTest { fun setup() { storage = mockk(relaxed = true) coEvery { storage.getPrivateKey("account-metadata-key") } returns null + coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-state-test").toFile() amethystDir = File(tempDir, ".amethyst") amethystDir.mkdirs() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerVaultBootstrapOrderTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerVaultBootstrapOrderTest.kt new file mode 100644 index 0000000000..a9c341edce --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerVaultBootstrapOrderTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * The vault migration deletes the legacy `account-metadata-key` item. Any read of + * accounts.json.enc that beats the migration therefore finds the key gone, mints a + * fresh AES key over the one that decrypts the file, and wipes every account. + * + * `Main.kt` calls [AccountManager.refreshAccountListOnStartup] *before* + * [AccountManager.loadSavedAccount], so hanging the migration off loadSavedAccount + * alone is too late -- this was reproduced on macOS against a real Keychain: the + * first launch migrated fine, the second launch wiped the account store. + */ +class AccountManagerVaultBootstrapOrderTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + private val keyStore = mutableMapOf() + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + coEvery { storage.savePrivateKey(any(), any()) } answers { keyStore[firstArg()] = secondArg() } + coEvery { storage.getPrivateKey(any()) } answers { keyStore[firstArg()] } + coEvery { storage.getPrivateKeyOrThrow(any()) } answers { keyStore[firstArg()] } + tempDir = createTempDirectory("acctmgr-vault-order").toFile() + File(tempDir, ".amethyst").mkdirs() + manager = AccountManager(storage, tempDir) + } + + /** + * An empty store short-circuits before it ever needs the metadata key, so seed a + * real accounts.json.enc -- otherwise the ordering under test is never exercised. + */ + private suspend fun seedAccountStore() { + DesktopAccountStorage(storage, tempDir) + .saveAccount(AccountInfo("npub1seeded", SignerType.Internal)) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun `startup list refresh migrates the vault before touching the account store`() = + runTest { + seedAccountStore() + + manager.refreshAccountListOnStartup() + + coVerifyOrder { + storage.enableConsolidatedVault(listOf("account-metadata-key")) + storage.getPrivateKeyOrThrow("account-metadata-key") + } + } + + @Test + fun `phase one runs once even across both startup entry points`() = + runTest { + seedAccountStore() + + manager.refreshAccountListOnStartup() + manager.refreshAccountList() + runCatching { manager.loadSavedAccount() } + + coVerify(exactly = 1) { + storage.enableConsolidatedVault(listOf("account-metadata-key")) + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt index 9ffd9ac1b3..a4d2e9a260 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt @@ -21,18 +21,28 @@ package com.vitorpamplona.amethyst.desktop.account import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.SignerType import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import io.mockk.slot +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import java.io.File +import java.util.concurrent.Executors import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -56,6 +66,9 @@ class DesktopAccountStorageTest { coEvery { secureStorage.getPrivateKey(capture(keySlot)) } answers { keyStore[keySlot.captured] } + coEvery { secureStorage.getPrivateKeyOrThrow(capture(keySlot)) } answers { + keyStore[keySlot.captured] + } coEvery { secureStorage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers { keyStore[keySlot.captured] = valueSlot.captured } @@ -197,4 +210,269 @@ class DesktopAccountStorageTest { // Encrypted content should NOT contain the npub in plaintext assertTrue(!content.contains("npub1secret")) } + + // --- Bug 1: silent AES key rotation --- + + @Test + fun `getOrCreateKey keyring throws ambiguous error does not rotate key or touch file`() = + runTest { + // First launch: seed a real metadata key + an existing accounts.json.enc + storage.saveAccount(AccountInfo("npub1existing", SignerType.Internal)) + val file = File(File(tempDir, ".amethyst"), "accounts.json.enc") + val originalBytes = file.readBytes() + val originalMetadataKey = keyStore["account-metadata-key"] + assertNotNull(originalMetadataKey) + + // Fresh storage instance simulating a relaunch: the keychain now + // returns an ambiguous error (user cancelled the Keychain dialog). + val throwingStorage: SecureKeyStorage = mockk() + coEvery { throwingStorage.getPrivateKeyOrThrow("account-metadata-key") } throws + SecureStorageException("user cancelled Keychain dialog") + // Legacy permissive read should still return the key; production + // must not fall back to it on the getOrCreate path. + coEvery { throwingStorage.getPrivateKey(any()) } answers { keyStore[firstArg()] } + coEvery { throwingStorage.hasPrivateKey(any()) } answers { keyStore.containsKey(firstArg()) } + + val relaunched = DesktopAccountStorage(throwingStorage, tempDir) + + // Any operation that needs the metadata key must fail loudly, not + // rotate the key or write a fresh empty file. + assertFails { runBlocking { relaunched.loadAccounts() } } + + // Bug 1 invariant: no new savePrivateKey call for the metadata key. + coVerify(exactly = 0) { + throwingStorage.savePrivateKey("account-metadata-key", any()) + } + // Bug 1 + Bug 2 invariant: on-disk ciphertext untouched. + assertTrue(file.exists()) + assertContentEquals(originalBytes, file.readBytes()) + // Bug 1 invariant: keyStore metadata key unchanged. + assertEquals(originalMetadataKey, keyStore["account-metadata-key"]) + } + + @Test + fun `getOrCreateKey keyring returns definitive not-found creates and persists new key`() = + runTest { + // Happy path first launch: getPrivateKeyOrThrow returns null, + // storage generates + persists a fresh AES key exactly once. + assertNull(keyStore["account-metadata-key"]) + + storage.saveAccount(AccountInfo("npub1first", SignerType.Internal)) + + assertNotNull(keyStore["account-metadata-key"]) + coVerify(exactly = 1) { + secureStorage.savePrivateKey("account-metadata-key", any()) + } + } + + @Test + fun `getOrCreateKey ambiguous error with no accounts file bootstraps a fresh key`() = + runTest { + // Every non-macOS backend java-keyring ships (Windows Credential Store, + // Freedesktop Secret Service, KWallet) throws PasswordAccessException for a + // *genuinely absent* credential, which the strict lookup surfaces as + // SecureStorageException. With no accounts.json.enc there is no ciphertext + // a new key could orphan, so a fresh install must still be able to mint one + // -- otherwise Linux/Windows can never persist an account at all. + val saved = mutableMapOf() + val throwingStorage: SecureKeyStorage = mockk() + coEvery { throwingStorage.getPrivateKeyOrThrow("account-metadata-key") } throws + SecureStorageException("Keyring backend refused access or returned ambiguous not-found") + coEvery { throwingStorage.savePrivateKey(any(), any()) } answers { + saved[firstArg()] = secondArg() + } + coEvery { throwingStorage.getPrivateKey(any()) } answers { saved[firstArg()] } + coEvery { throwingStorage.hasPrivateKey(any()) } answers { saved.containsKey(firstArg()) } + + val file = File(File(tempDir, ".amethyst"), "accounts.json.enc") + assertFalse(file.exists()) + + val fresh = DesktopAccountStorage(throwingStorage, tempDir) + fresh.saveAccount(AccountInfo("npub1freshinstall", SignerType.Internal)) + + assertNotNull(saved["account-metadata-key"]) + assertTrue(file.exists()) + assertEquals(listOf("npub1freshinstall"), fresh.loadAccounts().map { it.npub }) + // The escape is bootstrap-only: once the file exists the strict contract + // applies again -- pinned by `getOrCreateKey keyring throws ambiguous error + // does not rotate key or touch file` above. + } + + // --- Cache must never claim a state that was not persisted --- + + @Test + fun `failed disk write does not poison the in-memory cache`() = + runTest { + storage.saveAccount(AccountInfo("npub1persisted", SignerType.Internal)) + assertEquals(listOf("npub1persisted"), storage.loadAccounts().map { it.npub }) + + // Block the atomic-write temp path so writeMetadataToDisk fails. + val temp = File(File(tempDir, ".amethyst"), "accounts.json.enc.tmp") + assertTrue(temp.mkdirs()) + + assertFails { + runBlocking { + storage.saveAccount(AccountInfo("npub1phantom", SignerType.Internal)) + } + } + + // Same instance: the cache must still reflect only what reached the disk, + // not the account the failed save handed it. + assertEquals(listOf("npub1persisted"), storage.loadAccounts().map { it.npub }) + + // And the on-disk file agrees. + temp.delete() + val relaunched = DesktopAccountStorage(secureStorage, tempDir) + assertEquals(listOf("npub1persisted"), relaunched.loadAccounts().map { it.npub }) + } + + // --- Bug 2: read failure must not silently reset the file --- + + @Test + fun `readMetadataFromDisk transient IO error does not backup file`() = + runTest { + // Seed a real file we can inspect. + storage.saveAccount(AccountInfo("npub1existing", SignerType.Internal)) + val amethystDir = File(tempDir, ".amethyst") + val file = File(amethystDir, "accounts.json.enc") + val originalBytes = file.readBytes() + val originalName = file.name + + // Fresh storage that surfaces a transient error from the keychain. + val throwingStorage: SecureKeyStorage = mockk() + coEvery { throwingStorage.getPrivateKeyOrThrow("account-metadata-key") } throws + SecureStorageException("transient keychain error") + coEvery { throwingStorage.getPrivateKey(any()) } returns null + coEvery { throwingStorage.hasPrivateKey(any()) } returns false + + val corruptions = mutableListOf() + val relaunched = + DesktopAccountStorage(throwingStorage, tempDir, onCorruption = { corruptions += it }) + + assertFails { runBlocking { relaunched.loadAccounts() } } + + // File preserved, no .corrupt.* or .jsonerror.* sibling created. + assertTrue(file.exists()) + assertContentEquals(originalBytes, file.readBytes()) + val siblings = amethystDir.listFiles().orEmpty().map { it.name } + assertFalse(siblings.any { it != originalName && it.startsWith("accounts.json.enc") && (it.contains(".corrupt.") || it.contains(".jsonerror.")) }) + + // The callback fired with the transient subtype so the app can retry. + assertTrue(corruptions.any { it is StorageCorruption.TransientError }) + } + + @Test + fun `readMetadataFromDisk gcm tag mismatch backs up and resets`() = + runTest { + // Seed a valid file so we have a real metadata key in the mock keystore. + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + val amethystDir = File(tempDir, ".amethyst") + val file = File(amethystDir, "accounts.json.enc") + assertTrue(file.exists()) + + // Overwrite with random bytes that pass the length check but fail + // GCM auth tag verification. Prefix with a fresh IV, then garbage. + val garbage = ByteArray(64) { it.toByte() } + file.writeBytes(garbage) + + val corruptions = mutableListOf() + val relaunched = + DesktopAccountStorage(secureStorage, tempDir, onCorruption = { corruptions += it }) + + val loaded = relaunched.loadAccounts() + assertTrue(loaded.isEmpty()) + + // Backup exists with the .corrupt. suffix; original file was + // removed (and will be re-created on next save). + val siblings = amethystDir.listFiles().orEmpty().map { it.name } + assertTrue(siblings.any { it.startsWith("accounts.json.enc.corrupt.") }) + assertTrue(corruptions.any { it is StorageCorruption.FileCorrupted }) + } + + @Test + fun `readMetadataFromDisk json malformed uses jsonerror suffix`() = + runTest { + // Build an accounts.json.enc whose plaintext decrypts fine but is + // not the expected AccountMetadata shape. Easiest path: reuse the + // production encrypt via a lightweight helper storage that lets us + // control the plaintext. + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + val amethystDir = File(tempDir, ".amethyst") + val file = File(amethystDir, "accounts.json.enc") + + // Encrypt an unrelated JSON payload with the same AES key the mock + // keystore holds so decryption succeeds but Jackson rejects the shape. + val key = + java.util.Base64 + .getDecoder() + .decode(keyStore["account-metadata-key"]!!) + val iv = ByteArray(12) { 7 } + val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + javax.crypto.Cipher.ENCRYPT_MODE, + javax.crypto.spec.SecretKeySpec(key, "AES"), + javax.crypto.spec.GCMParameterSpec(128, iv), + ) + val badPayload = cipher.doFinal("\"not an object\"".toByteArray()) + file.writeBytes(iv + badPayload) + + val corruptions = mutableListOf() + val relaunched = + DesktopAccountStorage(secureStorage, tempDir, onCorruption = { corruptions += it }) + + val loaded = relaunched.loadAccounts() + assertTrue(loaded.isEmpty()) + + val siblings = amethystDir.listFiles().orEmpty().map { it.name } + assertTrue(siblings.any { it.startsWith("accounts.json.enc.jsonerror.") }) + assertTrue(corruptions.any { it is StorageCorruption.JsonMalformed }) + } + + // --- Bug 3: cross-process file lock --- + + @Test + fun `writeMetadataToDisk concurrent saves serialize under file lock`() { + val executor = Executors.newFixedThreadPool(4) + try { + runBlocking { + withContext(executor.asCoroutineDispatcher()) { + val jobs = + (1..8).map { idx -> + async { + storage.saveAccount( + AccountInfo( + npub = "npub1parallel$idx", + signerType = SignerType.Internal, + ), + ) + } + } + jobs.awaitAll() + } + } + // All eight accounts present, file not truncated. + val loaded = runBlocking { storage.loadAccounts() } + assertEquals(8, loaded.size) + val npubs = loaded.map { it.npub }.toSet() + assertEquals((1..8).map { "npub1parallel$it" }.toSet(), npubs) + + // Lock sidecar exists and is respected. + val lockFile = File(File(tempDir, ".amethyst"), "accounts.json.enc.lock") + assertTrue(lockFile.exists()) + } finally { + executor.shutdownNow() + } + } + + private fun assertContentEquals( + expected: ByteArray, + actual: ByteArray, + ) { + assertEquals(expected.size, actual.size, "byte size mismatch") + for (i in expected.indices) { + if (expected[i] != actual[i]) { + throw AssertionError("byte differs at index $i: expected=${expected[i]} actual=${actual[i]}") + } + } + } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt index 7edddfa185..3b017fff94 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt @@ -95,6 +95,7 @@ object LaunchScenario { val tempHome = createTempDirectory("launch-scenario").toFile() val storage = mockk(relaxed = true) coEvery { storage.getPrivateKey(any()) } returns null + coEvery { storage.getPrivateKeyOrThrow(any()) } returns null File(tempHome, ".amethyst").mkdirs() val account = AccountManager(storage, tempHome) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index 480ea4fe06..9b8a88949a 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.cache -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 5760661c5b..0f1ba2911f 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.cache +import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt index e645ca4cfc..cd98c7ea9c 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt @@ -89,6 +89,7 @@ class AppStateMachineTest { File(tempDir, ".amethyst").mkdirs() storage = mockk(relaxed = true) coEvery { storage.getPrivateKey(any()) } returns null + coEvery { storage.getPrivateKeyOrThrow(any()) } returns null harnessScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) relay = LaunchFixtureRelay.open(LaunchFixture.build(noteCount = 0).events) } diff --git a/docs/changelog/README.md b/docs/changelog/README.md index 61cc4fa258..add6454352 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -2,6 +2,7 @@ Release notes for Amethyst, one file per version. Files are named with zero-padded version numbers so they sort correctly in any file browser. Use [`TEMPLATE.md`](TEMPLATE.md) as the starting point for the next release. +- [v1.15.2 — Media Previews, Nested Replies, and Health Connect](v1.15.02.md) - [v1.15.1 — Release Build Fix](v1.15.01.md) - [v1.15.0 — Marmot Updates, a Advanced Search Box, and Books, DVM Updates](v1.15.00.md) - [v1.14.0 — Highlights, Relay Login, and a Much Faster Start](v1.14.00.md) diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 60b7ac36aa..c1c54c98be 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -75,16 +75,71 @@ "rchk": "" }, "sinceLastTag": { - "tag": "v1.15.0", - "since": "2026-09-11T16:59:08-04:00", + "tag": "v1.15.2", + "since": "2026-09-12T10:34:37-04:00", "translators": [ { "user": "vitorpamplona", "languages": [ + "Arabic, Saudi Arabia", + "Bengali", + "Chinese Simplified", + "Chinese Simplified, Singapore", + "Chinese Traditional", + "Chinese Traditional, Hong Kong", "Czech", + "Dutch", + "English, United Kingdom", + "Esperanto", + "Finnish", + "French", + "French, Canada", "German", + "Greek", + "Hindi", + "Hungarian", + "Indonesian", + "Italian", + "Japanese", + "Korean", + "Latvian", + "Persian", + "Polish", + "Portuguese", "Portuguese, Brazilian", - "Swedish" + "Russian", + "Rսssian, Սkraine", + "Serbian (Cyrillic)", + "Slovenian", + "Spanish", + "Spanish, Mexico", + "Spanish, United States", + "Swahili, Kenya", + "Swedish", + "Tamil", + "Thai", + "Turkish", + "Ukrainian", + "Uzbek", + "Vietnamese" + ] + }, + { + "user": "maxblake2015", + "languages": [ + "Polish" + ] + }, + { + "user": "rajs19420616", + "languages": [ + "Hindi" + ] + }, + { + "user": "summoner001", + "languages": [ + "Hungarian" ] }, { @@ -227,10 +282,6 @@ "user": "flobstr", "languages": [] }, - { - "user": "rajs19420616", - "languages": [] - }, { "user": "Maxblake", "languages": [] @@ -255,10 +306,6 @@ "user": "D4rkFIow", "languages": [] }, - { - "user": "summoner001", - "languages": [] - }, { "user": "fiddleway", "languages": [] @@ -279,10 +326,6 @@ "user": "hypnotichemionus4", "languages": [] }, - { - "user": "maxblake2015", - "languages": [] - }, { "user": "eiie7", "languages": [] diff --git a/docs/changelog/v1.15.02.md b/docs/changelog/v1.15.02.md new file mode 100644 index 0000000000..637bf5839d --- /dev/null +++ b/docs/changelog/v1.15.02.md @@ -0,0 +1,45 @@ +# v1.15.2: Media Previews, Nested Replies, and Health Connect + +## Improvements and Bug fixes + +- Plays the track a media player page is *about*, instead of trying to play the + page. A link to `e.nostr.build/a__mp3` rendered a permanently-buffering + player: that URL answers `text/html` — it is the HTML player page, and the + file it is about is named in its `og:audio`. Two separate defects produced it, + and both are fixed. File-extension matching now requires a real dot, so a path + ending in the *letters* of an extension (`_mp3`, or a prose slug like + `/my-thoughts-on-mp3`) is no longer handed to the video player; and the + preview pipeline reads `og:audio` / `og:video` and plays that file, with the + page's `og:image` as poster. + + Reading a declaration is not trusting it: YouTube declares an `og:video` whose + type is `text/html` — an embed page, not a video — so it is refused and keeps + its link card. + +- Loads nested NIP-22 replies in the feed, not only after a thread is opened. + NIP-22 puts the direct parent in lowercase `e`/`a` and the conversation root in + uppercase `E`/`A`, and a comment two or more levels deep carries only the root. + The engagement subscriptions asked for the lowercase tags alone, so everything + below the first level was invisible until `ThreadScreen` opened a subscription + of its own. Each relay now gets a second, root-scoped filter. + + Kind 1619 (NIP-34 PR updates) moves with it: the spec gives it only + `["E", ]` and no lowercase `e` at all, so it had been sitting + in a filter it could never match. + +## Health Connect + +- Adds a rationale screen explaining what Amethyst reads from Health Connect and + why — the data types, their purposes, the seven-day and composer-only limits, + and what it never does (no writes, no route or location). It opens from the + workout composer, from Health Connect's own permission screen, and stands alone + without an account, which is what Google Play requires of it. +- Stops blocking the UI thread on Health Connect reads, and memoizes source app + names rather than asking `PackageManager` again for each record. +- Replaces the whole form when a second workout suggestion is picked, so the + distance and calories of the first no longer leak into the second. +- Documents the integration for Play Console review, and in `PRIVACY.md`. + +## Build + +- Bumps `appCode` to 460. diff --git a/geode/README.md b/geode/README.md index 1e8a8d8bbc..e336c5b203 100644 --- a/geode/README.md +++ b/geode/README.md @@ -32,7 +32,7 @@ docker run -d --name geode -p 7447:7447 \ ``` with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your -`geode.toml`. Pin a version (`:1.15.1`) instead of `:latest` for reproducible +`geode.toml`. Pin a version (`:1.15.2`) instead of `:latest` for reproducible deploys. To build the image yourself, from the repo root: ```bash @@ -47,7 +47,7 @@ it — a minimal JRE is bundled, so no system Java is required. It installs to `/opt/geode/` with the launcher at `/opt/geode/bin/geode`. ```bash -sudo dpkg -i geode-1.15.1-linux-x64.deb # or: sudo rpm -i geode-1.15.1-linux-x64.rpm +sudo dpkg -i geode-1.15.2-linux-x64.deb # or: sudo rpm -i geode-1.15.2-linux-x64.rpm ``` To run it as a managed service, wire up the shipped systemd unit @@ -69,7 +69,7 @@ formula: [`packaging/homebrew/geode-relay.rb`](packaging/homebrew/geode-relay.rb Download `geode---.tar.gz`, unpack, and run: ```bash -tar xzf geode-1.15.1-linux-x64.tar.gz +tar xzf geode-1.15.2-linux-x64.tar.gz ./geode/bin/geode --config geode/share/geode/config.example.toml ``` diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 2c8efa600f..6870826151 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -22,7 +22,7 @@ kotlin { // Generate a BuildConfig.kt carrying the app version from the catalog so // RelayInfo.VERSION (reported over NIP-11) tracks releases automatically // instead of being a hand-bumped literal. -val generateVersionFile by tasks.registering { +val generateVersionFile = tasks.register("generateVersionFile") { val versionValue = libs.versions.app.get() val outDir = layout.buildDirectory.dir("generated/version/kotlin") inputs.property("version", versionValue) diff --git a/geode/packaging/homebrew/geode-relay.rb b/geode/packaging/homebrew/geode-relay.rb index 31871d77db..ae687390fd 100644 --- a/geode/packaging/homebrew/geode-relay.rb +++ b/geode/packaging/homebrew/geode-relay.rb @@ -38,8 +38,8 @@ class GeodeRelay < Formula desc "Standalone Nostr relay from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.15.1/geode-1.15.1-jvm.tar.gz" - sha256 "9acbe6d246d1a94941e1f3a97d037155d33b1f3f6ca6e3dfe555e96d6b63eea7" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.15.2/geode-1.15.2-jvm.tar.gz" + sha256 "caa9adf0b70d39679b39cb6ce667cc7bcb291d2e74bdfeb6aaa4c95de513fe7a" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index b79f81bf51..ea1a1352bd 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -311,14 +311,14 @@ class KtorRelayTest { ) assertEquals(true, ok, "successful insert must round-trip OK true on the wire") - // Duplicate insert returns OK false; this also exercises the - // "non-empty message" branch of the serializer. + // Duplicate insert returns OK true with a `duplicate:` message (NIP-01); + // this also exercises the "non-empty message" branch of the serializer. val ok2 = client.publishAndConfirm( event = event, relayList = setOf(server.url.normalizeRelayUrl()), ) - assertEquals(false, ok2, "duplicate insert must round-trip OK false") + assertEquals(true, ok2, "duplicate insert must round-trip OK true (NIP-01 duplicate:)") } /** diff --git a/gradle/kmp-purity.gradle.kts b/gradle/kmp-purity.gradle.kts new file mode 100644 index 0000000000..f7c92aa15c --- /dev/null +++ b/gradle/kmp-purity.gradle.kts @@ -0,0 +1,81 @@ +// iOS purity gate shared by every KMP library module that targets Apple +// (`:quartz`, `:commons`, `:commonsUI`). Apply it from the module's build +// script with +// +// apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) +// +// It registers `verifyKmpPurity` (wired into `check`) which fails when an +// iOS-targeted source set references a JVM-only API. `verifyKmpPurity` is the +// fast Linux pre-check that runs in the lint job; the macOS iOS compile job is +// the authoritative one. Keeping the pattern table in one file means a new +// forbidden API lands in every module at once instead of drifting per copy. + +val verifyKmpPurity = tasks.register("verifyKmpPurity") { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + // Every source set that feeds an Apple target. Modules that don't have a + // given directory are skipped by the exists() filter, so the list is the + // union across modules (e.g. only :commonsUI has skikoMain today). + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/skikoMain", "src/skikoTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + // Each pattern is paired with a short hint so the failure message + // points at the canonical KMP replacement. + val forbidden = + listOf( + "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", + "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", + "System.currentTimeMillis" to "use TimeUtils.now()", + "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", + "java.util.UUID" to "use kotlin.uuid.Uuid", + "kotlin.jvm.Synchronized" to "use a KMP lock primitive (KmpLock.withLock {} in commons)", + // The bare call, not just the annotation: `synchronized(lock) {}` resolves + // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and + // only fails at the iOS compile step. Catch it here instead. + "synchronized(" to "`synchronized` is JVM-only — use a KMP lock primitive (KmpLock.withLock {} in commons)", + "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", + ) + val offenders = + checkedDirs.flatMap { dir -> + dir + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + val trimmed = line.trimStart() + // Skip KDoc / line-comment lines — those legitimately + // mention forbidden names (migration notes, doc refs). + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return@mapNotNull null + } + forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only APIs. " + + "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7def77e9aa..08582067ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ # Amethyst app version — single source of truth consumed by both Android (amethyst/) # and Desktop (desktopApp/). `appCode` is the Android versionCode: a monotonic # integer that must increment on every release, even when `app` is unchanged. -app = "1.15.1" -appCode = "459" +app = "1.15.2" +appCode = "460" accompanistAdaptive = "0.37.3" # Pinned: 0.3.0 turns CacheMap.entries/keys/values into DeprecationLevel.ERROR (and # throws UnsupportedOperationException at runtime), which breaks quartz's appleMain @@ -28,6 +28,7 @@ uiautomator = "2.4.0" biometricKtx = "1.4.0-alpha02" coil = "3.6.2" composeBom = "2026.09.00" +tracingPerfetto = "1.0.1" composeRuntimeAnnotation = "1.12.1" coreKtx = "1.19.0" datastore = "1.2.1" @@ -54,10 +55,11 @@ genaiRewriting = "1.0.0-beta1" genaiImageDescription = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.11.0" -lightcompressor-enhanced = "2.2.1" +lightcompressor-enhanced = "2.2.3" jlatexmath = "1.5" markdown = "4d3b3dd173" highlights = "1.1.0" +okio = "3.18.1" # JetBrains material3 must track Compose Multiplatform's foundation: CMP 1.12.0 changed # the CustomStyle.applyStyle signature, so the stable 1.9.0 (and the 1.11.0-alpha07 that # kodein-emoji pulls in) crash at runtime with AbstractMethodError in OutlinedTextField. @@ -136,6 +138,9 @@ androidx-camera-extensions = { module = "androidx.camera:camera-extensions", ver androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing" } +androidx-tracing-perfetto = { group = "androidx.tracing", name = "tracing-perfetto", version.ref = "tracingPerfetto" } +androidx-tracing-perfetto-binary = { group = "androidx.tracing", name = "tracing-perfetto-binary", version.ref = "tracingPerfetto" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } @@ -217,6 +222,7 @@ markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", nam markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlights" } +okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt index bf5f68f7fe..7a030c58d4 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt @@ -24,12 +24,10 @@ import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.runBlocking @@ -170,7 +168,7 @@ fun benchJoinWelcome(): BenchResult = } }, ) { (bob, wrap) -> - runBlocking { bob.manager.ingest(wrap as GiftWrapEvent) } + runBlocking { bob.manager.ingest(wrap) } } /** @@ -223,7 +221,7 @@ fun benchIngestAppMessage(members: Int): BenchResult = } }, ) { (bob, event) -> - runBlocking { bob.manager.ingest(event as GroupEvent) } + runBlocking { bob.manager.ingest(event) } } private const val BENCH_RELAY = "wss://bench.invalid" diff --git a/nappletHost/build.gradle.kts b/nappletHost/build.gradle.kts index cec65e1ef4..f10b0653c1 100644 --- a/nappletHost/build.gradle.kts +++ b/nappletHost/build.gradle.kts @@ -33,10 +33,12 @@ kotlin { } dependencies { - // The sandbox runtime depends ONLY on the protocol/contract (commons) and Nostr resolution + // The sandbox runtime depends ONLY on the protocol/contract (commons), the shell/shim + // Compose resources it serves (commonsUI's NappletWebContract) and Nostr resolution // (quartz) — never on :amethyst. This makes it impossible for the `:napplet` process code to // reach for Amethyst.instance / LocalCache / Account (which don't exist in that process). implementation(project(":commons")) + implementation(project(":commonsUI")) implementation(project(":quartz")) implementation(libs.androidx.core.ktx) diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 15e463a301..f14d312308 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -154,7 +154,7 @@ val hangInteropCacheDir = val moqRelayVersion = "0.10.25" val moqTokenCliVersion = "0.5.23" -val interopInstallMoqRelay by tasks.registering(Exec::class) { +val interopInstallMoqRelay = tasks.register("interopInstallMoqRelay") { description = "cargo install moq-relay $moqRelayVersion (interop)" group = "interop" commandLine( @@ -178,7 +178,7 @@ val interopInstallMoqRelay by tasks.registering(Exec::class) { doFirst { hangInteropCacheDir.asFile.mkdirs() } } -val interopInstallMoqTokenCli by tasks.registering(Exec::class) { +val interopInstallMoqTokenCli = tasks.register("interopInstallMoqTokenCli") { description = "cargo install moq-token-cli $moqTokenCliVersion (interop)" group = "interop" commandLine( @@ -201,7 +201,7 @@ val interopInstallMoqTokenCli by tasks.registering(Exec::class) { doFirst { hangInteropCacheDir.asFile.mkdirs() } } -val interopBuildSidecars by tasks.registering(Exec::class) { +val interopBuildSidecars = tasks.register("interopBuildSidecars") { description = "cargo build --release for nestsClient/tests/hang-interop sidecars" group = "interop" workingDir = hangInteropDir.asFile @@ -226,7 +226,7 @@ val interopBuildSidecars by tasks.registering(Exec::class) { outputs.dir(hangInteropDir.dir("target/release")) } -val interopBuildHangSidecars by tasks.registering { +val interopBuildHangSidecars = tasks.register("interopBuildHangSidecars") { description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)." group = "interop" dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli) @@ -305,7 +305,7 @@ fun resolveBunBinary(): String { fun resolveNpxBinary(): String = System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx" -val interopBuildBrowserHarness by tasks.registering(Exec::class) { +val interopBuildBrowserHarness = tasks.register("interopBuildBrowserHarness") { description = "bun install && bun build for the browser interop harness" group = "interop" workingDir = browserInteropDir.asFile @@ -325,7 +325,7 @@ val interopBuildBrowserHarness by tasks.registering(Exec::class) { outputs.dir(browserInteropDir.dir("dist")) } -val interopInstallPlaywrightChromium by tasks.registering(Exec::class) { +val interopInstallPlaywrightChromium = tasks.register("interopInstallPlaywrightChromium") { description = "Install Playwright Chromium + dependencies for the browser interop harness" group = "interop" workingDir = browserInteropDir.asFile diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 9253f2c607..9db22ebaf1 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -304,11 +304,11 @@ kotlin { dependsOn(appleMain) } - val iosArm64Main by getting { + getByName("iosArm64Main") { dependsOn(iosMain.get()) } - val iosSimulatorArm64Main by getting { + getByName("iosSimulatorArm64Main") { dependsOn(iosMain.get()) } @@ -316,11 +316,11 @@ kotlin { dependsOn(appleTest) } - val iosArm64Test by getting { + getByName("iosArm64Test") { dependsOn(iosTest.get()) } - val iosSimulatorArm64Test by getting { + getByName("iosSimulatorArm64Test") { dependsOn(iosTest.get()) } @@ -334,11 +334,11 @@ kotlin { dependsOn(appleTest) } - val macosArm64Main by getting { + getByName("macosArm64Main") { dependsOn(macosMain) } - val macosArm64Test by getting { + getByName("macosArm64Test") { dependsOn(macosTest) } @@ -355,11 +355,11 @@ kotlin { dependsOn(nativeTest) } - val linuxX64Main by getting { + getByName("linuxX64Main") { dependsOn(linuxMain) } - val linuxX64Test by getting { + getByName("linuxX64Test") { dependsOn(linuxTest) } } @@ -383,79 +383,10 @@ dependencies { // Scope: source sets whose code is compiled for at least one non-JVM // target. Excludes jvmAndroid, jvmMain, androidMain (and their tests), // where Jackson and OkHttp are legitimately used. -val verifyKmpPurity by tasks.registering { - group = "verification" - description = "Fails if iOS-targeted source sets import JVM-only deps." - val checkedDirs = - listOf( - "src/commonMain", - "src/commonTest", - "src/appleMain", - "src/appleTest", - "src/nativeMain", - "src/nativeTest", - "src/iosMain", - "src/iosTest", - "src/iosArm64Main", - "src/iosArm64Test", - "src/iosSimulatorArm64Main", - "src/iosSimulatorArm64Test", - "src/linuxMain", - "src/linuxTest", - "src/linuxX64Main", - "src/linuxX64Test", - "src/macosMain", - "src/macosTest", - "src/macosArm64Main", - "src/macosArm64Test", - ).map { layout.projectDirectory.dir(it).asFile } - .filter { it.exists() } - inputs.files(checkedDirs) - doLast { - // Each pattern is paired with a short hint so the failure message - // points at the canonical KMP replacement. - val forbidden = - listOf( - "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", - "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", - "System.currentTimeMillis" to "use TimeUtils.now()", - "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", - "java.util.UUID" to "use kotlin.uuid.Uuid", - "kotlin.jvm.Synchronized" to "use a KMP lock primitive", - // The bare call, not just the annotation: `synchronized(lock) {}` resolves - // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and - // only fails at the iOS compile step. Catch it here instead. - "synchronized(" to "`synchronized` is JVM-only — use a KMP lock primitive", - "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", - ) - val offenders = - checkedDirs.flatMap { dir -> - dir - .walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .flatMap { file -> - file.readLines().withIndex().mapNotNull { (idx, line) -> - val trimmed = line.trimStart() - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { - return@mapNotNull null - } - forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" - } - } - } - } - if (offenders.isNotEmpty()) { - throw GradleException( - "iOS-targeted source sets must not reference JVM-only APIs. " + - "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + - offenders.joinToString("\n "), - ) - } - } -} - -tasks.named("check").configure { dependsOn(verifyKmpPurity) } +// +// The task itself is shared with :commons and :commonsUI — see +// gradle/kmp-purity.gradle.kts for the pattern table and checked dirs. +apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) mavenPublishing { // sources publishing is always enabled by the Kotlin Multiplatform plugin diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 33adaa8851..57c0aa4b8b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -56,6 +56,10 @@ internal class Nip46Test { sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", ) + // The round trip is only type-safe by construction: the caller hands in a T, + // the message is encoded and decoded, and the decoder returns the same + // BunkerMessage subtype. The runtime has no way to check that. + @Suppress("UNCHECKED_CAST") suspend fun encodeDecodeEvent(req: T): T { val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson() diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.apple.kt new file mode 100644 index 0000000000..4bf7fc3821 --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.apple.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +actual fun BigDecimal.toLongValue(): Long = toLong() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 698be085ec..b1cb1f8f79 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -760,7 +760,7 @@ class MarmotInboundProcessor( val author = payloadAuthor(candidate.content.decodeToString()) val sender = candidate.senderAccount val valid = author != null && sender != null && author == sender - if (valid && sender != null) { + if (valid) { convergence.recordWitness(groupId, candidate.stateId, sender) } return GroupEventResult.AppMessageOnCandidateBranch( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt index f1ed9c8d75..91b0760946 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt @@ -561,7 +561,7 @@ class MarmotConvergenceEngine( return mutex.withLock { val ctx = contexts[groupId] ?: return@withLock null - if (rewound && selectedTipId != null) { + if (rewound) { adoptBranch(ctx, graph, selectedTipId, inputs.baseId) } // Keep the states of branches that LOST but stay eligible: losing @@ -576,7 +576,7 @@ class MarmotConvergenceEngine( .forEach { tipId -> var cursor: String? = tipId while (cursor != null && cursor !in canonical) { - graph.statesById[cursor]?.let { ctx.candidateStates[cursor!!] = it } + graph.statesById[cursor]?.let { ctx.candidateStates[cursor] = it } cursor = graph.parentOf[cursor] } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 62fe531d86..61cc4e7627 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -119,6 +119,10 @@ class RelayPool( relays.forEach { url, relay -> relay.disconnect() } + // We just tore every socket down; say so here rather than leaving it to each socket's + // own report. The transports do report a disconnect synchronously (see [WebSocket]), + // but this flow is what the app reads as "connected", and it must not depend on that. + _connectedRelays.update { emptySet() } } fun sendOrConnectAndSync( @@ -210,6 +214,9 @@ class RelayPool( val relayInPool = relays.remove(relay) if (relayInPool != null) { relayInPool.disconnect() + // A relay that is no longer a member cannot be connected, whatever its socket + // layer reports (or fails to report) later. + _connectedRelays.update { it - relay } return true } return false @@ -226,6 +233,7 @@ class RelayPool( disconnect() relays.clear() _availableRelays.update { emptySet() } + _connectedRelays.update { emptySet() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 9520dd9848..33a01e0e92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd @@ -212,7 +213,13 @@ class RelaySession( } is IEventStore.InsertOutcome.Rejected -> { - send(OkMessage(cmd.event.id, false, outcome.reason)) + // NIP-01: an event the relay already holds is acknowledged with + // `OK true` and the `duplicate:` prefix. Every real client + // (amethyst's outbox included) resends an event whose OK has not + // landed yet, and treats OK false as a rejection to surface — + // so answering false here turns a routine resend into an error. + val duplicate = outcome.reason.startsWith(RejectionReason.PREFIX_DUPLICATE) + send(OkMessage(cmd.event.id, duplicate, outcome.reason)) } is IEventStore.InsertOutcome.Failed -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt index 525d5b992b..6c1172230a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt @@ -20,11 +20,26 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.sockets +/** + * One socket session towards a relay, as the relay client sees it. + * + * The contract every implementation keeps, and that [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient] + * relies on for its bookkeeping: + * + * - A session ends with **exactly one** terminal callback on its [WebSocketListener], `onClosed` + * or `onFailure`, however it ends. + * - [disconnect] ends the session itself: it reports `onClosed` **synchronously**, before + * returning, and nothing from that socket reaches the listener afterwards. The relay client + * may dial a new socket immediately, so a late report from the old one -- which OkHttp + * delivers on its own threads for a cancel, and never delivers at all for a relay-initiated + * close it was not allowed to finish -- must be swallowed by the adapter, not forwarded. + */ interface WebSocket { fun needsReconnect(): Boolean fun connect() + /** Ends the session now. Reports `onClosed` synchronously if one was open; a no-op otherwise. */ fun disconnect() fun send(msg: String): Boolean diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt index 4e14cb2550..00d6ccc7dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -44,8 +44,21 @@ object RejectionReason { */ const val PREFIX_REPLACED = "replaced:" + /** NIP-01 prefix for "already have this event" — answered with `OK true`, not false. */ + const val PREFIX_DUPLICATE = "duplicate:" + // The standard store reasons. const val DUPLICATE = "duplicate: already have this event" + + /** + * A replaceable or addressable event that a stored version already supersedes + * (newer `created_at`, or the same `created_at` and a lower id). Nothing is + * written, and — like [DUPLICATE] — the relay answers `OK true`: NIP-01 keeps + * `duplicate:` as the machine-readable prefix for "already covered", and that + * is what nostr-rs-relay sends here too, so clients that retry on anything + * else (MDK's `wn`) settle instead of re-offering the same event forever. + */ + const val SUPERSEDED = "duplicate: a newer version of this replaceable event is already stored" const val EXPIRED = "blocked: Cannot insert an expired event" const val DELETED = "blocked: a deletion event exists" const val VANISHED = "blocked: a request to vanish event exists" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 1b3643543c..a56fb390f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -62,6 +62,14 @@ class SQLiteEventStore( val extraPragmas: List = emptyList(), ) { companion object { + /** SQLite's message for the unique index on `event_headers (id)`. */ + private const val DUPLICATE_ID_CONSTRAINT = "UNIQUE constraint failed: event_headers.id" + + /** + * Common prefix of SQLite's messages for `replaceable_idx` (`kind, pubkey`) and + * `addressable_idx` (`kind, pubkey, d_tag`) — both start with these two columns. + */ + private const val SUPERSEDED_CONSTRAINT = "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey" const val DATABASE_VERSION = 5 } @@ -526,6 +534,20 @@ class SQLiteEventStore( */ private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome { val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED + // A second copy of an event the store already holds trips the unique index on + // event_headers.id. That is not a refusal of the event but a statement that it + // is already here, and NIP-01 has a dedicated answer for it (`OK true` with the + // `duplicate:` prefix) — so name it, instead of leaking SQLite's constraint text + // for the session to turn into a rejection the client then retries or reports. + if (message.contains(DUPLICATE_ID_CONSTRAINT)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) + } + // The replaceable / addressable unique indexes fire only when the supersession + // trigger found nothing older to delete, i.e. the stored version already wins + // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. + if (message.contains(SUPERSEDED_CONSTRAINT)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) + } val refusal = message.contains("blocked:") || message.contains("duplicate:") || diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index c95e636604..7d90f3b256 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.rpc +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.RawJson +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull class NwcTransactionMetadata( @@ -44,6 +46,8 @@ class NwcTransactionMetadata( val pubkeyHex: String?, val recipientPubkeyHex: String?, val content: String?, + val zappedEventId: String?, + val zappedAddress: String?, ) fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) } @@ -54,6 +58,13 @@ class NwcTransactionMetadata( fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex + /** + * The note this zap was for: the `a` address for an addressable target (so the + * living version opens, not the one that was zapped), else the `e` id. Null for + * a profile zap, which legitimately carries neither. + */ + fun zappedNoteId(): String? = nostr?.zappedAddress ?: nostr?.zappedEventId + /** * The message to show for this transaction. * @@ -90,21 +101,26 @@ class NwcTransactionMetadata( val rawPubkey = n["pubkey"] as? String val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } - val tags = n["tags"] as? List<*> - val recipientHex = - tags?.firstNotNullOfOrNull { tag -> - val tagList = tag as? List<*> - if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { - tagList[1] as? String - } else { - null - } + var recipientHex: String? = null + var zappedEventId: String? = null + var zappedAddress: String? = null + (n["tags"] as? List<*>)?.forEach { tag -> + val tagList = tag as? List<*> ?: return@forEach + if (tagList.size < 2) return@forEach + val value = tagList[1] as? String ?: return@forEach + when (tagList[0]) { + "p" -> if (recipientHex == null) recipientHex = value + "e" -> if (zappedEventId == null && value.isValid()) zappedEventId = value + "a" -> if (zappedAddress == null) zappedAddress = Address.parse(value)?.toValue() } + } NostrZapData( pubkeyHex = pubkeyHex, recipientPubkeyHex = recipientHex, content = n["content"] as? String, + zappedEventId = zappedEventId, + zappedAddress = zappedAddress, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index 590e82b634..e48f60a840 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -37,22 +37,22 @@ class BlossomAuthorizationEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { /** - * This event's JSON as Base64url without padding, per BUD-11: "the - * authorization token MUST be encoded as Base64 URL-safe without padding - * (Base64url, as used by JWTs)". + * This event's JSON as standard Base64 WITH padding — the same encoder as + * NIP-98's [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.rawToken]. * - * Deliberately NOT the same encoder as NIP-98's - * [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.rawToken], - * which stays on standard Base64 because NIP-98 does not specify a variant. - * In practice the alphabets coincide here — a token's JSON is printable - * ASCII, and a sextet can only reach 62/63 when the third byte of its group - * is `>`, `~`, `?` or DEL — so the observable change is the dropped `=`. + * NOT what BUD-11 (draft) says: "the authorization token MUST be encoded as + * Base64 URL-safe without padding (Base64url, as used by JWTs)". Deployed + * servers decode with a strict standard decoder (Go's base64.StdEncoding in + * khatru-based servers), which rejects both missing padding and the `-`/`_` + * alphabet as "invalid base64 token" — and padding is needed whenever the + * JSON length is not a multiple of three. Interop wins over the draft. Do not + * switch this back to `Base64.UrlSafe` without re-probing a khatru server. */ - fun rawToken() = BASE64URL.encode(toJson().encodeToByteArray()) + fun rawToken() = Base64.encode(toJson().encodeToByteArray()) /** * The full `Authorization` header value for a Blossom request: - * `Nostr ` (BUD-11, HTTP Authorization Header). + * `Nostr ` (BUD-11, HTTP Authorization Header). */ fun toAuthorizationHeader() = "$AUTH_HEADER_SCHEME${rawToken()}" @@ -62,9 +62,6 @@ class BlossomAuthorizationEvent( /** Scheme prefix for the `Authorization` header value (BUD-11). */ const val AUTH_HEADER_SCHEME = "Nostr " - /** BUD-11's required token encoding: URL-safe alphabet, no `=` padding. */ - val BASE64URL = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) - /** * BUD-11 `t=get` read authorization. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt index 571f812db1..be65f1dac7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt @@ -23,3 +23,15 @@ package com.vitorpamplona.quartz.utils operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other) operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other) + +/** + * Truncate to a Long, the way Number.toLong() does on every platform. + * + * It has to be an expect *function* rather than a member of `expect class + * BigDecimal`: every actual is already a Number and so already has toLong(), + * but java.math.BigDecimal leaves toByte()/toShort() abstract, which makes + * `expect class BigDecimal : Number` impossible to actualize with the JVM + * typealias. Without this, `amount.toLong()` in shared code resolves only in + * the platform compilations and breaks `compileCommonMainKotlinMetadata`. + */ +expect fun BigDecimal.toLongValue(): Long diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt index 192b013387..af50790016 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt @@ -61,7 +61,7 @@ class AuthOutcomeTest { phase: RelayAuthSnapshot.Phase, successCount: Int = 0, ) { - state.value = state.value.put(relay, RelayAuthSnapshot(phase, null, successCount)) + state.value = state.value.putting(relay, RelayAuthSnapshot(phase, null, successCount)) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsInFlightReqDeferralTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsInFlightReqDeferralTest.kt new file mode 100644 index 0000000000..4e5107c97e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsInFlightReqDeferralTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A filter change on a sub whose previous REQ is still awaiting EOSE is not sent: it + * waits for that EOSE. Pinned as today's behaviour, not as the goal. + * + * NIP-47 shares one sub id across requests and publishes the request event right after + * widening the filter with its id, so inside this window the wallet's ephemeral + * kind-23195 reply can reach the relay before the relay knows to forward it. An instant + * refusal (QUOTA_EXCEEDED) can land there; a reply after routing (PAYMENT_FAILED) cannot. + * That is the suspected cause of a refusal the BrollyZapper field trip saw vanish + * (dkamy-cy6.4); the fix is tracked in dkamy-cy6.6. + */ +class PoolRequestsInFlightReqDeferralTest { + private val relay = NormalizedRelayUrl("wss://nwc.example/") + private val subId = "nwc" + + private class RecordingRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + val sent = mutableListOf() + + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) { + sent.add(cmd) + } + + override fun sendIfConnected(cmd: Command) { + sent.add(cmd) + } + + override fun disconnect() = Unit + } + + private fun nwcReplies(vararg requestIds: String) = listOf(Filter(kinds = listOf(23195), tags = mapOf("e" to requestIds.toList()))) + + private fun PoolRequests.update( + client: RecordingRelayClient, + filters: List, + ) { + val affected = addOrUpdate(subId, mapOf(relay to filters), null) + sendToRelayIfChanged(subId, affected) { _, cmd -> client.sendOrConnectAndSync(cmd) } + } + + @Test + fun aFilterChangeWaitsForTheInFlightReqsEose() = + runTest { + val pool = PoolRequests() + val client = RecordingRelayClient(relay) + + pool.update(client, nwcReplies("balance-poll")) + assertEquals(1, client.sent.filterIsInstance().size, "the first REQ goes out") + + // The zap's request id is added before its EOSE arrives: nothing is sent, so a + // request event published now is on the wire ahead of the filter that catches its reply. + pool.update(client, nwcReplies("balance-poll", "zap")) + assertEquals(1, client.sent.filterIsInstance().size, "the widened REQ is held back") + + pool.onIncomingMessage(client, EoseMessage(subId)) + val reqs = client.sent.filterIsInstance() + assertEquals(2, reqs.size, "the EOSE releases it") + assertTrue( + reqs.last().filters.any { it.tags?.get("e")?.contains("zap") == true }, + "only then does the relay learn to forward the zap's reply", + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPoolConnectedFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPoolConnectedFlowTest.kt new file mode 100644 index 0000000000..e815fa167e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPoolConnectedFlowTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [RelayPool.connectedRelays] is what the app reads as "how many relays am I connected to", so it + * must drop a relay the moment the pool lets go of it, even when the socket layer never confirms + * the close. + * + * [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebSocket.disconnect] is a + * no-op that never calls back, which is exactly what OkHttp did after a relay sent a CLOSE frame + * the app never answered: `cancel()` then fired neither `onClosed` nor `onFailure`, and the flow + * carried such relays for minutes. The real transports now report a disconnect synchronously (see + * [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket]), but this flow must not depend on + * that: the pool clears it itself on removal and on disconnect. + */ +class RelayPoolConnectedFlowTest { + private val url = NormalizedRelayUrl("wss://relay.example.com/") + + private fun openedPool(): RelayPool { + val sockets = FakeWebsocketBuilder() + val pool = RelayPool(sockets) + pool.getOrCreateRelay(url).connect() + sockets.lastListener.onOpen(pingMillis = 10, compression = false) + return pool + } + + @Test + fun anOpenSocketIsConnected() { + val pool = openedPool() + + assertEquals(setOf(url), pool.connectedRelays.value) + assertEquals(1, pool.connectedRelaysCount()) + } + + @Test + fun removingARelayDropsItEvenWhenTheCloseIsSilent() { + val pool = openedPool() + + // No subscription wants it anymore: the pool drops it and cancels its socket, and the + // socket layer stays silent. + pool.removeRelay(url) + + assertEquals(emptySet(), pool.connectedRelays.value) + assertEquals(0, pool.connectedRelaysCount()) + } + + @Test + fun disconnectingThePoolDropsEveryRelayEvenWhenTheCloseIsSilent() { + val pool = openedPool() + + // The host is putting the client down (app backgrounded, connectivity lost). + pool.disconnect() + + assertEquals(emptySet(), pool.connectedRelays.value) + assertEquals(setOf(url), pool.availableRelays.value, "still a member, just not connected") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 7171a9e4a0..bde070947f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -120,8 +120,13 @@ class NostrServerTest { server.close() } + /** + * NIP-01: `["OK", , true, "duplicate: already have this event"]`. A client + * resends any event whose OK has not landed, so a duplicate must read as + * success — OK false would make every such resend look like a rejection. + */ @Test - fun duplicateEventReturnsOkFalse() = + fun duplicateEventReturnsOkTrueWithDuplicatePrefix() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) @@ -138,7 +143,72 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) assertTrue(okMessages[0].contains(",true,")) - assertTrue(okMessages[1].contains(",false,")) + assertTrue(okMessages[1].contains(",true,")) + assertTrue(okMessages[1].contains("duplicate:")) + + server.close() + } + + /** + * STORE-W01: a replaceable event older than the stored version is not written, + * and the relay acknowledges it the way nostr-rs-relay does — `OK true` with the + * NIP-01 `duplicate:` prefix — rather than leaking the unique-index text as a + * rejection the client would keep retrying. + */ + @Test + fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val newer = testEvent(id = hexId(2), kind = 0, createdAt = 2000L) + val older = testEvent(id = hexId(3), kind = 0, createdAt = 1000L) + c1.insert(newer) + c1.insert(older) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",true,"), "older version must be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains("duplicate:"), "older version must carry the duplicate: prefix") + + val stored = store.query(Filter(kinds = listOf(0))) + assertEquals(listOf(newer.id), stored.map { it.id }, "the newer version stays the only stored one") + + server.close() + } + + /** + * STORE-W02 tie: two addressable events with the same `d` tag and the same + * `created_at` — the lower id wins, the other is acknowledged as superseded. + * This is the exact shape MDK's `wn keys publish` produces when it mints a + * second KeyPackage within the same second as the first. + */ + @Test + fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val dTag = arrayOf(arrayOf("d", "kp")) + val lowerId = testEvent(id = hexId(4), kind = 30443, createdAt = 5000L, tags = dTag) + val higherId = testEvent(id = hexId(5), kind = 30443, createdAt = 5000L, tags = dTag) + c1.insert(lowerId) + c1.insert(higherId) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains(",true,"), "tie loser must be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains("duplicate:")) + + val stored = store.query(Filter(kinds = listOf(30443))) + assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie") server.close() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt index 1a20c1987c..8bddfc1446 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt @@ -44,15 +44,23 @@ class NwcOutgoingMetadataTest { private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" private val payerHex = "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f" + private val noteHex = "d".repeat(64) + private fun zapRequest( content: String = "great post", relays: List = listOf("wss://relay.damus.io"), + zappedNoteId: String? = null, ) = Event( id = "a".repeat(64), pubKey = payerHex, createdAt = 1756000000L, kind = 9734, - tags = arrayOf(arrayOf("p", recipientHex), arrayOf("relays", *relays.toTypedArray())), + tags = + listOfNotNull( + zappedNoteId?.let { arrayOf("e", it) }, + arrayOf("p", recipientHex), + arrayOf("relays", *relays.toTypedArray()), + ).toTypedArray(), content = content, sig = "b".repeat(128), ) @@ -233,6 +241,52 @@ class NwcOutgoingMetadataTest { assertEquals("for the article", parsed.displayComment()) } + @Test + fun theZappedNoteSurvivesTheRoundTrip() { + val wire = + OptimizedJsonMapper.toJson( + PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(zapRequest(zappedNoteId = noteHex), "user@domain.com", "")), + ) + val back = OptimizedJsonMapper.fromJsonTo(wire) as PayInvoiceMethod + val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata)) + + assertEquals(noteHex, parsed.zappedNoteId()) + assertEquals(recipientHex, parsed.recipientPubkeyHex(), "the p tag is still read with the e tag in front of it") + } + + private fun parseTags(vararg tags: List) = assertNotNull(NwcTransactionMetadata.parse(mapOf("nostr" to mapOf("tags" to tags.toList())))) + + @Test + fun aProfileZapHasNoNote() { + assertNull(parseTags(listOf("p", recipientHex)).zappedNoteId()) + } + + @Test + fun tagOrderDoesNotMatter() { + val parsed = parseTags(listOf("e", noteHex), listOf("p", recipientHex)) + assertEquals(recipientHex, parsed.recipientPubkeyHex()) + assertEquals(noteHex, parsed.zappedNoteId()) + } + + @Test + fun anAddressableZapPrefersTheAddress() { + val address = "30023:$recipientHex:my-article" + assertEquals(address, parseTags(listOf("e", noteHex), listOf("a", address), listOf("p", recipientHex)).zappedNoteId()) + } + + @Test + fun aMalformedAddressFallsBackToTheEventId() { + assertEquals(noteHex, parseTags(listOf("e", noteHex), listOf("a", "not-an-address"), listOf("p", recipientHex)).zappedNoteId()) + } + + @Test + fun aMalformedEventIdIsIgnored() { + // "noteHex + ff": the id check must bound the length, not just scan 64 hex chars. + listOf("not-a-note", noteHex + "ff").forEach { bad -> + assertNull(parseTags(listOf("e", bad), listOf("p", recipientHex)).zappedNoteId(), bad) + } + } + @Test fun blankFieldsReadAsAbsent() { val parsed = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt new file mode 100644 index 0000000000..e90ea7e6a2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * A wallet's refusal, encrypted exactly as it sent it, decrypts through the response + * cache the zap path reads into something with a message to render — whatever the error + * code, over NIP-04 and NIP-44 alike. Parsing alone is pinned in [ResponseTest]; this + * covers the wire bytes. It does not cover `LocalCache.consume` or relay delivery. + */ +class NwcRefusalRoundTripTest { + private val clientSigner = NostrSignerInternal(KeyPair()) + private val walletSigner = NostrSignerInternal(KeyPair()) + + private suspend fun assertRefusalDecryptsWithMessage( + code: String, + message: String, + ) { + val walletJson = """{"result_type":"pay_invoice","error":{"code":"$code","message":"$message"}}""" + + for (useNip44 in listOf(false, true)) { + val request = + LnZapPaymentRequestEvent.createRequest( + PayInvoiceMethod.create("lnbc50n1pjtest"), + walletSigner.pubKey, + clientSigner, + useNip44 = useNip44, + ) + + // Encrypted by hand rather than through LnZapPaymentResponseEvent.createResponse, + // which re-serializes a Response: the point is the wallet's bytes, verbatim. + val encrypted = + if (useNip44) { + walletSigner.nip44Encrypt(walletJson, clientSigner.pubKey) + } else { + walletSigner.nip04Encrypt(walletJson, clientSigner.pubKey) + } + val reply = + walletSigner.sign( + request.createdAt, + LnZapPaymentResponseEvent.KIND, + arrayOf(arrayOf("p", clientSigner.pubKey), arrayOf("e", request.id)), + encrypted, + ) + + val response = NostrWalletConnectResponseCache(clientSigner).decryptResponse(reply) + val case = "$code over ${if (useNip44) "NIP-44" else "NIP-04"}" + assertIs(response, case) + assertEquals(message, response.errorMessage(), case) + } + } + + @Test + fun quotaExceededDecryptsWithItsMessage() = runTest { assertRefusalDecryptsWithMessage("QUOTA_EXCEEDED", "this payment would exceed the connection's budget for this period") } + + @Test + fun paymentFailedDecryptsWithItsMessage() = runTest { assertRefusalDecryptsWithMessage("PAYMENT_FAILED", "NO_ROUTE: no route found") } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt index b9733297ef..71c9707f53 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nipB7Blossom import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -96,35 +97,30 @@ class BlossomAuthorizationEventTest { assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)) val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME) - val decoded = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString() + val decoded = Base64.decode(token).decodeToString() assertEquals(event.toJson(), decoded) } /** - * BUD-11: "the authorization token MUST be encoded as Base64 URL-safe - * without padding (Base64url, as used by JWTs)". Padded standard Base64 was - * what this produced before, so both halves are worth pinning. Several - * lengths, because whether padding appears at all depends on the JSON - * length mod 3 — a single sample passes by luck about half the time. + * Must survive a strict standard decode, as deployed servers do — see + * [BlossomAuthorizationEvent.rawToken]. The alt lengths cover every + * `length mod 3` case, since only some of them need padding. */ @Test - fun authorizationTokenIsBase64UrlWithoutPadding() = + fun authorizationTokenIsStandardPaddedBase64BecauseServersDecodeStrictly() = runTest { + var sawPadding = false listOf("a", "List", "List blobs", "List all of the blobs", "List blobs \u00e1\u00e9\u00ed") .forEach { alt -> - val header = BlossomAuthorizationEvent.createListAuth(signer, alt).toAuthorizationHeader() - val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME) + val event = BlossomAuthorizationEvent.createListAuth(signer, alt) + val token = event.toAuthorizationHeader().removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME) - assertTrue(token.none { it == '=' }, "padding must be absent for `$alt`, got: $token") - assertTrue( - token.none { it == '+' || it == '/' }, - "standard-alphabet chars must not appear for `$alt`, got: $token", - ) - assertTrue( - token.all { it.isLetterOrDigit() || it == '-' || it == '_' }, - "token must be base64url for `$alt`, got: $token", - ) + // kotlin.io.encoding.Base64 (default) is the strict standard decoder: + // it throws on missing padding and on `-`/`_`, like Go's StdEncoding. + assertEquals(event.toJson(), Base64.decode(token).decodeToString(), "strict standard decode for `$alt`") + if (token.endsWith("=")) sawPadding = true } + assertTrue(sawPadding, "at least one of these lengths needs padding; if none did, the encoder is still dropping it") } /** diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt index f0a9c61cec..20a07cd991 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import java.util.concurrent.atomic.AtomicBoolean import okhttp3.WebSocket as OkHttpWebSocket import okhttp3.WebSocketListener as OkHttpWebSocketListener @@ -51,11 +52,27 @@ class BasicOkHttpWebSocket( } } - private var socket: OkHttpWebSocket? = null + @Volatile private var socket: OkHttpWebSocket? = null + + /** + * Set once, by whichever of `onClosed`, `onFailure` or [disconnect] ends the session first. + * + * One adapter is one session: the relay client builds a fresh one per dial, and OkHttp binds + * exactly one socket to the listener created in [connect], so anything that reaches that + * listener is from this session by construction. The only question a callback has to ask is + * whether the session already ended -- which is what keeps the [WebSocket.disconnect] contract: + * after [disconnect] the failure OkHttp raises for its own `cancel()` on the reader thread, or + * the `onClosed` its writer thread delivers once a close handshake completes, is dropped rather + * than reaching a relay client that has already moved on. Claimed with a compare-and-set so a + * [disconnect] racing a terminal callback still yields exactly one report. + */ + private val ended = AtomicBoolean(false) override fun needsReconnect() = socket == null override fun connect() { + if (socket != null || ended.get()) return + val request = Request.Builder().url(url.url).build() val listener = @@ -79,33 +96,65 @@ class BasicOkHttpWebSocket( } } + /** Claims the session's single terminal report. False if it already ended. */ + private fun endSession(): Boolean { + if (!ended.compareAndSet(false, true)) return false + socket = null + incomingMessages.close() + job.cancel() + scope.cancel() + return true + } + override fun onOpen( webSocket: OkHttpWebSocket, response: Response, - ) = out.onOpen( - (response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(), - response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, - ) + ) { + if (ended.get()) return + out.onOpen( + (response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(), + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, + ) + } override fun onMessage( webSocket: OkHttpWebSocket, text: String, ) { + if (ended.get()) return // Never blocks (unlimited channel): the OkHttp reader // thread must stay free to keep draining the socket. incomingMessages.trySendBlocking(text) } + override fun onClosing( + webSocket: OkHttpWebSocket, + code: Int, + reason: String, + ) { + // The relay sent a CLOSE frame. OkHttp's contract (WebSocketListener KDoc, + // RealWebSocket, and its own WebSocketEcho recipe) is that onClosed fires + // only once BOTH peers have sent a close, and sending ours is the + // application's job. Left unanswered, the socket sits half-closed: no + // onClosed, no onFailure, send() still accepted and silently discarded, and + // a later cancel() is silent too -- so the relay client kept believing it + // was connected, with its REQs live, until OkHttp's 120s ping path finally + // failed up to two intervals later. Answering completes the handshake and + // OkHttp reports onClosed at once, whether or not the relay still holds the + // TCP session open. + // + // Always 1000 rather than echoing `code`: close() validates the code it is + // asked to write and throws on the reserved ones (1005, 1006, 1015), and a + // relay may send anything. + webSocket.close(1000, null) + } + override fun onClosed( webSocket: OkHttpWebSocket, code: Int, reason: String, ) { - // Close the channel when the WebSocket connection is closed. - incomingMessages.close() - job.cancel() - scope.cancel() - + if (!endSession()) return out.onClosed(code, reason) } @@ -114,11 +163,7 @@ class BasicOkHttpWebSocket( t: Throwable, response: Response?, ) { - // Close the channel on failure, and propagate the error. - incomingMessages.close() - job.cancel() - scope.cancel() - + if (!endSession()) return out.onFailure(t, response?.code, response?.message) } } @@ -127,8 +172,14 @@ class BasicOkHttpWebSocket( } override fun disconnect() { - socket?.cancel() + // Claim the session ourselves: OkHttp's cancel() raises no callback when no reader is + // left to fail (the state a relay-initiated close leaves behind), and when it does the + // failure arrives later on its own thread. The relay client needs the answer now. + val closing = socket ?: return + if (!ended.compareAndSet(false, true)) return socket = null + closing.cancel() + out.onClosed(1000, "client disconnect") } override fun send(msg: String): Boolean = socket?.send(msg) ?: false @@ -140,6 +191,6 @@ class BasicOkHttpWebSocket( override fun build( url: NormalizedRelayUrl, out: WebSocketListener, - ) = BasicOkHttpWebSocket(url, httpClient, out) + ): WebSocket = BasicOkHttpWebSocket(url, httpClient, out) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.jvmAndroid.kt new file mode 100644 index 0000000000..4bf7fc3821 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.jvmAndroid.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +actual fun BigDecimal.toLongValue(): Long = toLong() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt index b9feeeca5a..403aa6e217 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt @@ -245,8 +245,8 @@ class LastResortKeyPackageReuseTest { override suspend fun load(): ByteArray? = bytes - override suspend fun save(data: ByteArray) { - bytes = data + override suspend fun save(snapshot: ByteArray) { + bytes = snapshot } override suspend fun delete() { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocketCloseHandshakeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocketCloseHandshakeTest.kt new file mode 100644 index 0000000000..27b43775e6 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocketCloseHandshakeTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStream +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread + +/** + * A relay-initiated close must be answered, or OkHttp never finishes the handshake. + * + * OkHttp fires `onClosed` only once BOTH peers have sent a CLOSE frame, and sending ours is the + * application's job (its `WebSocketEcho` recipe answers `onClosing` with `close(1000, null)`). + * Before [BasicOkHttpWebSocket] did that, a relay's CLOSE frame left the socket half-closed: no + * `onClosed`, no `onFailure`, `send()` still accepted and discarded, and a later `cancel()` silent + * too. The relay client kept believing it was connected, with its REQs live, until the ping path + * failed up to two ping intervals later. + * + * Driven against a minimal RFC 6455 server on a loopback [ServerSocket] rather than a mock + * server library, so the test needs no new dependency and controls the exact frames on the wire. + */ +class BasicOkHttpWebSocketCloseHandshakeTest { + /** Handshakes one client, sends it a CLOSE frame on demand, and records the frames it sends back. */ + private class TinyRelay : AutoCloseable { + private val server = ServerSocket(0) + val url = NormalizedRelayUrl("ws://127.0.0.1:${server.localPort}/") + + private val handshaken = CountDownLatch(1) + val clientCloseFrame = CountDownLatch(1) + val clientCloseCode = AtomicInteger(-1) + + private var socket: Socket? = null + private var out: OutputStream? = null + + private val thread = + thread(isDaemon = true, name = "tiny-relay") { + runCatching { + val s = server.accept() + socket = s + val input = s.getInputStream() + val output = s.getOutputStream() + out = output + handshake(input, output) + handshaken.countDown() + readFrames(input) + } + } + + private fun handshake( + input: InputStream, + output: OutputStream, + ) { + var key: String? = null + val line = StringBuilder() + while (true) { + val c = input.read() + check(c != -1) { "EOF during handshake" } + if (c == '\n'.code) { + val l = line.toString().trim() + if (l.isEmpty()) break + if (l.lowercase().startsWith("sec-websocket-key:")) key = l.substring(18).trim() + line.setLength(0) + } else if (c != '\r'.code) { + line.append(c.toChar()) + } + } + val accept = + Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-1").digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray()), + ) + output.write( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: $accept\r\n\r\n" + ).toByteArray(Charsets.ISO_8859_1), + ) + output.flush() + } + + /** Client frames are masked; decode enough to spot a CLOSE and read its status code. */ + private fun readFrames(input: InputStream) { + while (true) { + val b0 = input.read() + if (b0 == -1) return + val b1 = input.read() + if (b1 == -1) return + val opcode = b0 and 0x0F + var len = b1 and 0x7F + if (len == 126) { + len = (input.read() shl 8) or input.read() + } else if (len == 127) { + len = 0 + repeat(8) { len = (len shl 8) or input.read() } + } + val masked = (b1 and 0x80) != 0 + val mask = if (masked) ByteArray(4) { input.read().toByte() } else ByteArray(4) + val payload = ByteArray(len) { i -> (input.read() xor mask[i % 4].toInt()).toByte() } + if (opcode == 0x8) { + if (len >= 2) { + clientCloseCode.set(((payload[0].toInt() and 0xFF) shl 8) or (payload[1].toInt() and 0xFF)) + } + clientCloseFrame.countDown() + } + } + } + + fun awaitClient() = handshaken.await(5, TimeUnit.SECONDS) + + /** Server-initiated CLOSE, status 1000, unmasked as servers send it. The TCP session stays open. */ + fun sendClose() { + val output = checkNotNull(out) { "no client yet" } + output.write(byteArrayOf(0x88.toByte(), 0x02, 0x03, 0xE8.toByte())) + output.flush() + } + + override fun close() { + runCatching { socket?.close() } + runCatching { server.close() } + thread.join(2_000) + } + } + + private class Recorder : WebSocketListener { + val opened = CountDownLatch(1) + val closed = CountDownLatch(1) + val closedCount = AtomicInteger(0) + val closedCode = AtomicInteger(-1) + val failure = AtomicReference(null) + + override fun onOpen( + pingMillis: Int, + compression: Boolean, + ) = opened.countDown() + + override suspend fun onMessage(text: String) {} + + override fun onClosed( + code: Int, + reason: String, + ) { + closedCode.set(code) + closedCount.incrementAndGet() + closed.countDown() + } + + override fun onFailure( + t: Throwable, + code: Int?, + response: String?, + ) { + failure.set(t) + } + } + + @Test + fun `a relay initiated close is answered and reported as closed`() { + TinyRelay().use { relay -> + val recorder = Recorder() + val client = OkHttpClient() + val socket = BasicOkHttpWebSocket(relay.url, { client }, recorder) + + socket.connect() + assertTrue("relay never saw the client", relay.awaitClient()) + assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS)) + + relay.sendClose() + + // The half of the handshake that is ours to send. + assertTrue("client never answered the relay's CLOSE frame", relay.clientCloseFrame.await(5, TimeUnit.SECONDS)) + assertEquals(1000, relay.clientCloseCode.get()) + + // And the terminal callback the relay client's bookkeeping depends on. + assertTrue("onClosed never fired", recorder.closed.await(5, TimeUnit.SECONDS)) + assertEquals("the relay's status code is what gets reported", 1000, recorder.closedCode.get()) + assertNull("a clean handshake is not a failure", recorder.failure.get()) + + // The session already ended; the usual teardown afterwards must not report it twice. + socket.disconnect() + assertEquals("one terminal report per session", 1, recorder.closedCount.get()) + assertTrue("a closed socket needs a fresh dial", socket.needsReconnect()) + + client.dispatcher.executorService.shutdown() + } + } + + @Test + fun `disconnect reports the session end once, synchronously, and drops what OkHttp says afterwards`() { + TinyRelay().use { relay -> + val recorder = Recorder() + val client = OkHttpClient() + val socket = BasicOkHttpWebSocket(relay.url, { client }, recorder) + + socket.connect() + assertTrue("relay never saw the client", relay.awaitClient()) + assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS)) + + socket.disconnect() + + // Reported before disconnect() returned: the relay client dials the replacement + // right after this call and must not hear from the old socket later. + assertEquals("disconnect() must report synchronously", 1, recorder.closedCount.get()) + assertEquals(1000, recorder.closedCode.get()) + assertTrue(socket.needsReconnect()) + + // OkHttp's own reaction to cancel() -- a failure on its reader thread -- and the relay's + // reaction to the dropped TCP session must both be swallowed. + Thread.sleep(500) + assertEquals("no second report", 1, recorder.closedCount.get()) + assertNull("the cancel's failure must not surface", recorder.failure.get()) + + client.dispatcher.executorService.shutdown() + } + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt index 9095a17bcf..7a2992d0ce 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt @@ -110,10 +110,16 @@ class InternTradeoffBenchmark { } } + /** + * Holds the last measured corpus so the JIT cannot drop the allocations we + * just paid for. A field rather than a local: a local's assignments are + * visible to the compiler's data flow, which then folds the `check` below + * into a constant. + */ + private var sink: Any? = null + @Test fun internCostAndBenefit() { - var sink: Any? = null - println("\n=== corpus: $EVENTS events, $AUTHORS authors, $RELAYS relays ===") // ---- memory ---- diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt index 8f44b686cb..8cfa6fef3c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt @@ -131,7 +131,7 @@ class IndexableContentGoldenTest { } } val rejoined = visited.joinToString(event.indexableSeparator()) - if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)!!} content=${event.indexableContent().take(120)}" + if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)} content=${event.indexableContent().take(120)}" } assertEquals("visitor and indexed content disagree", emptyList(), disagreements) } diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.linux.kt new file mode 100644 index 0000000000..4bf7fc3821 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.linux.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +actual fun BigDecimal.toLongValue(): Long = toLong() diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt index c1a20bed55..929b2ef6c4 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt @@ -203,9 +203,7 @@ class Http3GetClient( body += frame.body } - else -> { - Unit - } + else -> {} } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 260b92e6e1..ebc38ddb98 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,7 +25,6 @@ dependencyResolutionManagement { google() mavenCentral() maven { url = uri("https://jitpack.io") } - maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") } } } @@ -37,6 +36,7 @@ include(":baselineprofile") include(":quartz") include(":geode") include(":commons") +include(":commonsUI") include(":quic") include(":nestsClient") include(":marmotQuic") diff --git a/tools/arti-build/ANDROID_NDK_VERSION b/tools/arti-build/ANDROID_NDK_VERSION new file mode 100644 index 0000000000..e7aa49e341 --- /dev/null +++ b/tools/arti-build/ANDROID_NDK_VERSION @@ -0,0 +1 @@ +30.0.16248370 diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION index 5c804c82cf..b5268d6729 100644 --- a/tools/arti-build/ARTI_VERSION +++ b/tools/arti-build/ARTI_VERSION @@ -1 +1 @@ -arti-v2.3.0 \ No newline at end of file +arti-v2.6.0 diff --git a/tools/arti-build/CARGO_NDK_VERSION b/tools/arti-build/CARGO_NDK_VERSION new file mode 100644 index 0000000000..4d0dcda01c --- /dev/null +++ b/tools/arti-build/CARGO_NDK_VERSION @@ -0,0 +1 @@ +4.1.2 diff --git a/tools/arti-build/Cargo.lock b/tools/arti-build/Cargo.lock index eee336fa97..ba7323c7f0 100644 --- a/tools/arti-build/Cargo.lock +++ b/tools/arti-build/Cargo.lock @@ -10,21 +10,21 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", "zeroize", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -77,28 +77,28 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-android" -version = "2.3.0" +version = "2.6.0" dependencies = [ "anyhow", "arti-client", @@ -110,7 +110,7 @@ dependencies = [ [[package]] name = "arti-client" -version = "0.42.0" +version = "0.46.0" dependencies = [ "async-trait", "cfg-if", @@ -126,11 +126,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.9.4", + "rand 0.10.2", "safelog", "serde", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -177,7 +177,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -188,7 +188,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -200,7 +200,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -211,9 +211,9 @@ checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" dependencies = [ "compression-codecs", "compression-core", @@ -223,13 +223,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] @@ -289,9 +289,9 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64ct" @@ -311,9 +311,15 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" @@ -335,7 +341,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -347,6 +353,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "bs58" version = "0.5.1" @@ -358,9 +374,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -381,9 +397,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -393,19 +409,19 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "caret" -version = "0.9.0" +version = "0.10.1" [[package]] name = "cc" -version = "1.2.65" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", @@ -413,18 +429,23 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -444,10 +465,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", "zeroize", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "coarsetime" version = "0.1.37" @@ -461,9 +499,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -471,9 +509,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" dependencies = [ "compression-core", "flate2", @@ -484,18 +522,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "const-oid" @@ -533,6 +562,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -544,36 +579,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-bigint" @@ -608,11 +643,20 @@ dependencies = [ [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -639,7 +683,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -664,12 +708,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -696,20 +740,20 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] @@ -731,25 +775,56 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] [[package]] name = "der" @@ -787,9 +862,9 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.11.3" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc153c91ebf221a2e7feb166aee259acf8a00ecf35c83df8b79fe8f5c3861d7" +checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" dependencies = [ "derive-deftly-macros", "heck", @@ -797,19 +872,19 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.3" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1747ed5fb4ab3a9f8253da3401efe7ca8f8e5ec373b2e700ff55c3304d84e31f" +checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", - "indexmap 2.14.0", - "itertools 0.15.0", + "indexmap 2.14.2", + "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3 0.12.0", "strum", - "syn 2.0.118", + "syn 3.0.5", "unicode-ident", "void", ] @@ -864,7 +939,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -874,7 +949,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", @@ -922,13 +997,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] @@ -997,9 +1072,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1030,7 +1105,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1042,14 +1117,14 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "enumset" -version = "1.1.13" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" dependencies = [ "enumset_derive", ] @@ -1063,7 +1138,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1084,15 +1159,25 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1107,9 +1192,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" @@ -1152,18 +1237,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1201,25 +1287,26 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.1" +version = "0.15.1" dependencies = [ "derive_builder_fork_arti", "dirs", + "extend", "libc", "pwd-grp", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", + "void", "walkdir", ] [[package]] -name = "fslock" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +name = "fslock-guard" +version = "0.8.2" dependencies = [ "libc", - "winapi", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] @@ -1230,9 +1317,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1245,9 +1332,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1255,15 +1342,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1272,19 +1359,19 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] @@ -1300,21 +1387,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1375,6 +1462,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1386,7 +1474,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1483,9 +1571,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1505,9 +1593,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "humantime-serde" @@ -1521,11 +1609,12 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", + "zeroize", ] [[package]] @@ -1554,9 +1643,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1568,9 +1657,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1581,9 +1670,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1595,16 +1684,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1615,15 +1705,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1684,9 +1774,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1696,20 +1786,20 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags", + "bitflags 2.13.2", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -1723,6 +1813,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inventory" version = "0.3.24" @@ -1733,12 +1832,18 @@ dependencies = [ ] [[package]] -name = "itertools" -version = "0.14.0" +name = "ipnet" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "iprange" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" dependencies = [ - "either", + "ipnet", ] [[package]] @@ -1757,28 +1862,87 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "jni" -version = "0.21.1" +name = "jiff" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jiff-core" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ - "jni-sys 0.4.1", + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] @@ -1797,24 +1961,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -1832,19 +1996,19 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -1856,7 +2020,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags", + "bitflags 2.13.2", "libc", ] @@ -1871,24 +2035,24 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "liblzma" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +checksum = "2fe0a34ca854fd4f20c07f696fc8675aec78f87d88d29f5e10257a7490a1b2e1" dependencies = [ "liblzma-sys", ] [[package]] name = "liblzma-sys" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +checksum = "a0dad045e4b1b7b170be4b60b54b780cafb4490165461bac7d1cf7b703f61d5f" dependencies = [ "cc", "libc", @@ -1903,9 +2067,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] @@ -1929,9 +2093,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1944,9 +2108,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchers" @@ -1959,9 +2123,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -1992,9 +2156,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -2002,9 +2166,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -2034,7 +2198,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags", + "bitflags 2.13.2", "inotify", "kqueue", "libc", @@ -2051,7 +2215,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -2074,9 +2238,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2093,7 +2257,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2106,20 +2270,19 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2153,7 +2316,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2162,7 +2325,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -2183,7 +2346,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneshot-fused-workaround" -version = "0.6.0" +version = "0.7.1" dependencies = [ "futures", ] @@ -2302,9 +2465,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2313,9 +2476,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2323,22 +2486,22 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2360,7 +2523,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2392,9 +2555,35 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] [[package]] name = "postage" @@ -2413,9 +2602,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2451,7 +2640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", ] @@ -2461,14 +2650,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2482,14 +2671,14 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2514,9 +2703,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -2524,12 +2713,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2544,12 +2734,12 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -2563,31 +2753,35 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_jitter" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +checksum = "3fdcd80e68f0a8f9ca5ec7cfd02fd5fbb8fbe6ef4e9b90ea2f48bb929b74f88e" dependencies = [ "libc", - "rand_core 0.9.5", - "winapi", + "rand_core 0.10.1", + "windows-sys 0.59.0", ] [[package]] -name = "rdrand" -version = "0.8.3" +name = "rangemap" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rdrand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" dependencies = [ - "rand_core 0.6.4", + "libc", + "rand_core 0.10.1", ] [[package]] @@ -2596,7 +2790,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -2607,34 +2801,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2644,9 +2838,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2659,9 +2853,18 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reseeding_rng" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecfc8e855ab517235426ec1290bfb0745e73fa478b29619eb66a2a81be9d8716" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "retry-error" -version = "0.12.0" +version = "0.13.1" dependencies = [ "humantime", "web-time", @@ -2719,7 +2922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2728,7 +2931,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2762,7 +2965,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -2771,9 +2974,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "log", "once_cell", @@ -2786,18 +2989,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -2806,19 +3009,19 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safelog" -version = "0.8.2" +version = "0.9.1" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2841,9 +3044,9 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" +checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" [[package]] name = "schemars" @@ -2859,9 +3062,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -2897,9 +3100,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2917,22 +3120,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] @@ -2947,9 +3150,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2978,18 +3181,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ "base64", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -2998,21 +3202,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3047,7 +3251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", "sponge-cursor", ] @@ -3099,9 +3303,25 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" @@ -3127,26 +3347,26 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.7.0" +version = "0.8.1" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.18", + "thiserror 2.0.20", "void", ] [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3154,9 +3374,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spki" @@ -3192,7 +3412,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" dependencies = [ - "cipher", + "cipher 0.4.4", "ssh-encoding-fork-arti", ] @@ -3270,7 +3490,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3292,9 +3512,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -3315,7 +3546,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3362,11 +3593,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3377,34 +3608,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -3423,9 +3654,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3433,9 +3664,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -3444,9 +3675,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -3459,9 +3690,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3475,20 +3706,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -3512,17 +3743,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -3549,7 +3780,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -3559,23 +3790,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -3586,47 +3817,61 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.42.0" +version = "0.46.0" dependencies = [ + "cfg-if", "derive-deftly", "educe", + "extend", "futures", "oneshot-fused-workaround", "pin-project", "postage", - "thiserror 2.0.18", + "slotmap-careful", + "sync_wrapper", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-basic-utils" -version = "0.42.0" +version = "0.46.0" dependencies = [ + "derive-deftly", "derive_more", + "educe", + "extend", "getrandom 0.4.3", "hex", - "itertools 0.14.0", + "itertools", "libc", "paste", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.2", + "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tracing", "weak-table", "web-time-compat", ] [[package]] name = "tor-bytes" -version = "0.42.0" +version = "0.46.0" dependencies = [ "bytes", "derive-deftly", @@ -3634,7 +3879,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "zeroize", @@ -3642,20 +3887,20 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", - "bitflags", + "bitflags 2.13.2", "bytes", "caret", "derive-deftly", "derive_more", "educe", - "itertools 0.14.0", + "itertools", "paste", - "rand 0.9.4", + "rand 0.10.2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -3671,13 +3916,14 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.42.0" +version = "0.46.0" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "thiserror 2.0.18", + "saturating-time", + "thiserror 2.0.20", "tor-bytes", "tor-checkable", "tor-error", @@ -3687,7 +3933,7 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.42.0" +version = "0.46.0" dependencies = [ "async-trait", "base64ct", @@ -3701,11 +3947,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.9.4", + "rand 0.10.2", "safelog", "serde", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -3728,18 +3974,19 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.42.0" +version = "0.46.0" dependencies = [ "humantime", + "itertools", "signature", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "async-trait", @@ -3752,15 +3999,15 @@ dependencies = [ "educe", "futures", "humantime-serde", - "itertools 0.14.0", + "itertools", "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.9.4", + "rand 0.10.2", "retry-error", "safelog", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -3787,7 +4034,7 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "cfg-if", @@ -3795,11 +4042,12 @@ dependencies = [ "derive_builder_fork_arti", "educe", "either", + "extend", "figment", "fs-mistrust", "futures", "humantime-serde", - "itertools 0.14.0", + "itertools", "notify", "paste", "postage", @@ -3808,8 +4056,8 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -3819,26 +4067,26 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.42.0" +version = "0.46.0" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-general-addr", ] [[package]] name = "tor-consdiff" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive_more", "digest 0.10.7", "hex", "imara-diff", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -3846,7 +4094,7 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.42.0" +version = "0.46.0" dependencies = [ "async-compression", "base64ct", @@ -3856,9 +4104,9 @@ dependencies = [ "http", "httparse", "httpdate", - "itertools 0.14.0", + "itertools", "memchr", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -3873,7 +4121,7 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.42.0" +version = "0.46.0" dependencies = [ "base64ct", "derive-deftly", @@ -3892,7 +4140,7 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.42.0" +version = "0.46.0" dependencies = [ "async-trait", "base64ct", @@ -3902,17 +4150,17 @@ dependencies = [ "educe", "event-listener", "fs-mistrust", - "fslock", + "fslock-guard", "futures", "hex", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools", "memmap2", "oneshot-fused-workaround", "paste", "postage", - "rand 0.9.4", + "rand 0.10.2", "rusqlite", "safelog", "scopeguard", @@ -3921,7 +4169,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -3947,7 +4195,7 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive_more", "futures", @@ -3955,7 +4203,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "void", "web-time-compat", @@ -3963,16 +4211,16 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive_more", - "thiserror 2.0.18", + "thiserror 2.0.20", "void", ] [[package]] name = "tor-guardmgr" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "base64ct", @@ -3984,16 +4232,16 @@ dependencies = [ "futures", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools", "num_enum", "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.4", + "rand 0.10.2", "safelog", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4014,23 +4262,24 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.42.0" +version = "0.46.0" dependencies = [ "async-trait", + "cfg-if", "derive-deftly", "derive_more", "educe", "either", "futures", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "postage", - "rand 0.9.4", + "rand 0.10.2", "retry-error", "safelog", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4057,7 +4306,7 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.42.0" +version = "0.46.0" dependencies = [ "data-encoding", "derive-deftly", @@ -4065,14 +4314,14 @@ dependencies = [ "digest 0.10.7", "hex", "humantime", - "itertools 0.14.0", + "itertools", "paste", - "rand 0.9.4", + "rand 0.10.2", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4086,17 +4335,17 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.9.4", + "rand 0.10.2", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-bytes", "tor-cert", "tor-checkable", @@ -4106,7 +4355,7 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "arrayvec", @@ -4120,13 +4369,13 @@ dependencies = [ "glob-match", "humantime", "inventory", - "itertools 0.14.0", - "rand 0.9.4", + "itertools", + "rand 0.10.2", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4145,7 +4394,7 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.42.0" +version = "0.46.0" dependencies = [ "base64ct", "by_address", @@ -4154,12 +4403,12 @@ dependencies = [ "derive_builder_fork_arti", "derive_more", "hex", - "itertools 0.14.0", + "itertools", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4170,7 +4419,7 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.42.0" +version = "0.46.0" dependencies = [ "aes", "base64ct", @@ -4186,12 +4435,13 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.3", "hex", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", "rand_core 0.6.4", - "rand_core 0.9.5", "rand_jitter", "rdrand", + "reseeding_rng", "rsa", "safelog", "serde", @@ -4200,7 +4450,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-memquota-cost", "visibility", @@ -4210,11 +4460,11 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.42.0" +version = "0.46.0" dependencies = [ "futures", "humantime", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-rtcompat", "tracing", @@ -4224,22 +4474,23 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.42.0" +version = "0.46.0" dependencies = [ "cfg-if", "derive-deftly", "derive_more", "dyn-clone", "educe", + "extend", "futures", - "itertools 0.14.0", + "itertools", "paste", "pin-project", "serde", "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4253,31 +4504,31 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive-deftly", - "itertools 0.14.0", + "itertools", "paste", "void", ] [[package]] name = "tor-netdir" -version = "0.42.0" +version = "0.46.0" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.13.2", "derive_more", "digest 0.10.7", "futures", "hex", "humantime", - "itertools 0.14.0", + "itertools", "num_enum", - "rand 0.9.4", + "rand 0.10.2", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-basic-utils", "tor-error", @@ -4294,25 +4545,29 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "base64ct", - "cipher", + "cipher 0.5.2", "derive-deftly", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", "educe", "enumset", + "extend", "hex", "hostname-validator", "humantime", - "itertools 0.14.0", + "ipnet", + "iprange", + "itertools", "memchr", "paste", "phf", - "rand 0.9.4", + "rand 0.10.2", + "rangemap", "saturating-time", "serde", "serde_with", @@ -4320,7 +4575,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tinystr", "tor-basic-utils", @@ -4341,21 +4596,22 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive-deftly", "derive_more", + "extend", "filetime", "fs-mistrust", - "fslock", + "fslock-guard", "futures", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "paste", "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -4367,8 +4623,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.42.0" +version = "0.46.0" dependencies = [ + "aes", "amplify", "async-trait", "asynchronous-codec", @@ -4376,8 +4633,9 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher", + "cipher 0.5.2", "coarsetime", + "ctr", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -4388,21 +4646,21 @@ dependencies = [ "futures-util", "hkdf", "hmac", - "itertools 0.14.0", + "itertools", "nonany", "oneshot-fused-workaround", "pin-project", + "polyval", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.2", + "rand_core 0.10.1", "safelog", "slotmap-careful", "smallvec", "static_assertions", "strum", "subtle", - "sync_wrapper", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-async-utils", @@ -4432,21 +4690,22 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.42.0" +version = "0.46.0" dependencies = [ "caret", + "derive-deftly", "paste", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-selection" -version = "0.42.0" +version = "0.46.0" dependencies = [ - "rand 0.9.4", + "rand 0.10.2", "serde", "tor-basic-utils", "tor-linkspec", @@ -4456,13 +4715,15 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.42.0" +version = "0.46.0" dependencies = [ + "amplify", "async-trait", "async_executors", "asynchronous-codec", "cfg-if", "coarsetime", + "derive_builder_fork_arti", "derive_more", "dyn-clone", "educe", @@ -4476,7 +4737,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-error", @@ -4489,7 +4750,7 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "assert_matches", @@ -4499,13 +4760,13 @@ dependencies = [ "educe", "futures", "humantime", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "pin-project", "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -4517,7 +4778,7 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.42.0" +version = "0.46.0" dependencies = [ "amplify", "caret", @@ -4525,19 +4786,19 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.42.0" +version = "0.46.0" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-memquota", ] @@ -4560,7 +4821,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4620,7 +4881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4666,6 +4927,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4722,7 +4993,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4767,9 +5038,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4780,9 +5051,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4790,31 +5061,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "weak-table" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] [[package]] name = "web-time" @@ -4828,7 +5103,7 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.1.0" +version = "0.2.1" dependencies = [ "web-time", ] @@ -4917,7 +5192,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4928,7 +5203,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4967,18 +5242,18 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.45.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.42.2", + "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] @@ -5001,21 +5276,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -5058,12 +5318,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5076,12 +5330,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5094,12 +5342,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5124,12 +5366,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5142,12 +5378,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5160,12 +5390,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5178,12 +5402,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5207,9 +5425,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5222,9 +5440,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -5266,28 +5484,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5307,7 +5525,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -5328,14 +5546,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5344,9 +5562,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -5356,44 +5574,50 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] -name = "zmij" -version = "1.0.21" +name = "zlib-rs" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml index b7d8c377e8..741f065b94 100644 --- a/tools/arti-build/Cargo.toml +++ b/tools/arti-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "arti-android" -version = "2.3.0" +version = "2.6.0" edition = "2021" [lib] @@ -9,20 +9,21 @@ crate-type = ["cdylib"] [workspace] [dependencies] -arti-client = { version = "0.42", default-features = false, features = [ +arti-client = { version = "0.46", default-features = false, features = [ "tokio", "rustls", "compression", "onion-service-client", "static-sqlite", ] } -tor-rtcompat = { version = "0.42", default-features = false, features = ["tokio", "rustls"] } +tor-rtcompat = { version = "0.46", default-features = false, features = ["tokio", "rustls"] } # Direct dep on rustls so we can install the `ring` crypto provider ourselves — -# arti-v2.3.0's tor-rtcompat no longer installs one implicitly. `ring` matches -# what arti-v2.2.0 effectively used and avoids the Android build pain of -# aws-lc-rs (which became Arti's default in 2.3.0). +# since arti-v2.3.0 tor-rtcompat no longer installs one implicitly. `ring` +# matches what arti-v2.2.0 effectively used and avoids the Android build pain +# of aws-lc-rs (Arti's default since 2.3.0), which `default-features = false` +# keeps out of the build entirely. rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } -jni = "0.21" +jni = "0.22" tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] } anyhow = "1" diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index 90f333ebea..492724db5e 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -9,9 +9,9 @@ JNI wrapper built directly from Arti source. | | Guardian Project AAR | Custom build | |---|---|---| | **Size** | ~140MB | ~11MB | -| **16KB pages** | No | Yes (NDK 25+) | +| **16KB pages** | No | Yes (rustc aligns Android targets to 16 KiB) | | **Stop/restart** | Broken (state file lock) | Works (TorClient persists, only SOCKS proxy stops) | -| **Version** | Behind | Pinned to latest (currently 1.9.0) | +| **Version** | Behind | Pinned to latest (see [`ARTI_VERSION`](ARTI_VERSION)) | ## Quick start @@ -22,15 +22,34 @@ rebuild if you want to verify binaries, update the Arti version, or modify the J The shipped `.so` is **built to be reproducible** so anyone — F-Droid, Zapstore, or an independent auditor — can rebuild it from this tag and confirm the -committed binary wasn't tampered with. **Four** things have to be fixed: +committed binary wasn't tampered with. **Five** things have to be fixed: | Source of non-determinism | Pinned by | |---|---| | `rustc` / cargo version | [`rust-toolchain.toml`](rust-toolchain.toml) (rustup auto-installs it) | +| **Android NDK revision** | [`ANDROID_NDK_VERSION`](ANDROID_NDK_VERSION); `build-arti.sh` refuses to build with any other revision | | transitive dependency versions | committed [`Cargo.lock`](Cargo.lock); builds run `cargo --locked` | | absolute paths *embedded* in the binary | `--remap-path-prefix` in [`repro-env.sh`](repro-env.sh) | | codegen/link **ordering** keyed on the real build path | **canonical build path** (`build-arti.sh` builds in `/tmp/amethyst-arti-build`) | +> **Why the NDK is pinned.** It is not just an SDK detail: the NDK supplies the +> clang that compiles Arti's C dependencies (`ring`, `zstd-sys`, +> `libsqlite3-sys`) and the `lld` that links the final `cdylib`, both of which +> stamp themselves into the binary's `.comment` section next to `rustc`'s own +> version. Swapping the NDK changes the bytes exactly like swapping `rustc` +> would. Before this was pinned the build picked the first directory matching +> `~/Android/Sdk/ndk/*/`, so the committed libraries were produced by r25b while +> this file told everyone to install r27 — two verifiers could both follow the +> README and get different, equally "correct" results. `build-arti.sh` now +> reads each candidate's `source.properties` and keeps looking until it finds +> the pinned revision, then re-checks the `.note.android.ident` stamp of every +> `.so` it produced. +> +> [`CARGO_NDK_VERSION`](CARGO_NDK_VERSION) records the `cargo-ndk` release the +> pinned output was verified with. `cargo-ndk` only wraps the NDK, so a mismatch +> is a warning rather than an error — but it is the next thing to check if your +> rebuild does not match. + `repro-env.sh` (sourced by both build scripts) also sets `CARGO_INCREMENTAL=0` and a fixed `SOURCE_DATE_EPOCH` derived from the Arti tag. The size-optimized release profile in `Cargo.toml` (`lto`, `codegen-units = 1`, `strip`, @@ -74,20 +93,27 @@ repo is checked out. rustup target add aarch64-linux-android x86_64-linux-android ``` -3. **cargo-ndk** +3. **cargo-ndk** — the release the pinned output was verified with: ```bash - cargo install cargo-ndk + cargo install cargo-ndk --version "$(cat CARGO_NDK_VERSION)" --locked ``` -4. **Android NDK 25+** (required for 16KB page size support) +4. **Android NDK** — the exact revision in [`ANDROID_NDK_VERSION`](ANDROID_NDK_VERSION) + (currently **30.0.16248370**, r30). Any other revision is refused: it would + produce a `.so` that does not match the committed one. ```bash # Via Android Studio: SDK Manager → SDK Tools → NDK (Side by side) # Or via command line: - sdkmanager "ndk;27.0.12077973" - - # Set environment variable - export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/27.0.12077973" + sdkmanager "ndk;$(cat ANDROID_NDK_VERSION)" ``` + `build-arti.sh` finds it automatically under `$ANDROID_HOME/ndk/`, + `$ANDROID_SDK_ROOT/ndk/`, `~/Android/Sdk/ndk/`, `~/Library/Android/sdk/ndk/` + or `/usr/local/lib/android/sdk/ndk/`. `ANDROID_NDK_HOME` and + `ANDROID_NDK_ROOT` are tried first when set, but they are only hints: every + candidate is checked against its own `source.properties`, and one at the + wrong revision is reported and skipped rather than failing the build. CI + images (GitHub runners among them) export both at a bundled NDK that is not + ours. ## Building @@ -130,14 +156,38 @@ Google Play requires 16KB page-aligned native libraries. Verify with: readelf -l amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD ``` -The first LOAD segment alignment should be `0x4000` (16384 bytes). +The first LOAD segment alignment should be `0x4000` (16384 bytes). This comes +from rustc's Android target spec (`max-page-size=16384`), not from the NDK, so +it holds for every NDK revision we could build with. + +## Checking which toolchain built a `.so` + +The shipped binaries say so themselves — useful when a rebuild does not match, or +when auditing a `.so` you did not build: + +```bash +# NDK release name + build number (the last component of the pinned revision) +readelf -p .note.android.ident amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so + +# clang / lld (from the NDK) and rustc versions +readelf -p .comment amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so +``` + +For the pinned toolchain the first command prints `r30` and build `16248370`. +The second prints the `rustc` version from `rust-toolchain.toml`, the NDK's +`clang` and `LLD`, and a second, different clang string that comes from the +prebuilt runtime objects the NDK links in — two clang lines there is normal. +`build-arti.sh` runs the first check itself after every build, using the NDK's +own `llvm-readelf` so it works the same on macOS. ## Directory structure ``` tools/arti-build/ ├── README.md # This file -├── ARTI_VERSION # Pinned Arti git tag (e.g., arti-v1.9.0) +├── ARTI_VERSION # Pinned Arti git tag (e.g., arti-v2.6.0) +├── ANDROID_NDK_VERSION # Pinned NDK revision — enforced by build-arti.sh (reproducibility) +├── CARGO_NDK_VERSION # cargo-ndk release the pinned output was verified with ├── rust-toolchain.toml # Pinned rustc version + Android targets (reproducibility) ├── Cargo.toml # Rust dependencies and build profile ├── Cargo.lock # Pinned transitive dependency versions (reproducibility) @@ -243,13 +293,18 @@ panic = "abort" # No unwinding (smaller binary) ### `cargo-ndk` not found ```bash -cargo install cargo-ndk +cargo install cargo-ndk --version "$(cat CARGO_NDK_VERSION)" --locked ``` -### NDK not found +### NDK not found, or "wrong revision" +Install the pinned revision — the build refuses any other, and the error lists +every directory it looked at and what it found there: ```bash -export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/" +sdkmanager "ndk;$(cat ANDROID_NDK_VERSION)" ``` +Point `ANDROID_NDK_HOME` at it only if it lives outside the standard SDK +layouts; an `ANDROID_NDK_HOME` left over from another project is skipped, not +fatal. ### Rust targets not installed ```bash diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index be7d1cacdd..babc4e48cc 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -6,7 +6,8 @@ # - Rust toolchain: rustup, cargo # - Android targets: rustup target add aarch64-linux-android x86_64-linux-android # - cargo-ndk: cargo install cargo-ndk -# - Android NDK 25+ (for 16KB page size support) +# - Android NDK: the exact revision pinned in ANDROID_NDK_VERSION +# (sdkmanager "ndk;") — see README.md -> "Reproducible builds" # # Usage: # ./build-arti.sh # Build for all targets (arm64 + x86_64) @@ -26,6 +27,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" ARTI_VERSION=$(cat "$SCRIPT_DIR/ARTI_VERSION" | tr -d '[:space:]') +# Reproducibility: the NDK ships the clang that compiles Arti's C dependencies +# (ring, zstd-sys, libsqlite3-sys) and the lld that links the whole cdylib, so +# its revision is baked into the output bytes exactly like rustc's is — both +# land in the .comment section of the shipped .so. Pin it here and refuse to +# build with anything else; the old glob over ~/Android/Sdk/ndk/*/ silently +# picked up whatever happened to be installed first. +NDK_VERSION=$(cat "$SCRIPT_DIR/ANDROID_NDK_VERSION" | tr -d '[:space:]') +# The NDK build number (last component of the revision) is what the linker +# stamps into .note.android.ident, so it is how we verify the output afterwards. +NDK_BUILD_NUMBER="${NDK_VERSION##*.}" +# cargo-ndk only wraps the NDK (it sets CC/AR/linker and the --platform flags), +# but those flags reach the linker, so record the version we verified with and +# warn when it differs. Not a hard error: unlike the NDK itself, it has no +# proven effect on the bytes. +CARGO_NDK_VERSION=$(cat "$SCRIPT_DIR/CARGO_NDK_VERSION" | tr -d '[:space:]') + # Reproducibility: rustc bakes the *real* (un-remapped) absolute paths of the # build artifacts into its codegen/link ORDERING, so --remap-path-prefix alone # is not enough — the .so only reproduces byte-for-byte when the compile happens @@ -68,33 +85,89 @@ print_info() { echo -e "${YELLOW}→ $1${NC}"; } # Prerequisites # ============================================================================ +# Pkg.Revision of an NDK install, or empty if the directory is not one. +ndk_revision() { + sed -n 's/^Pkg\.Revision *= *//p' "$1/source.properties" 2>/dev/null | tr -d '[:space:]' || true +} + +# Path to an ELF tool, preferring the pinned NDK's own llvm-* copy. The NDK +# ships them on every platform, which keeps the post-build checks working on +# macOS: there is no readelf in the Xcode command line tools, and Apple's nm +# cannot read ELF at all, so the checks would otherwise skip or report every +# symbol missing on exactly the machines most likely to have the wrong NDK. +ndk_tool() { + local name="$1" candidate + for candidate in "${ANDROID_NDK_HOME:-}"/toolchains/llvm/prebuilt/*/bin/"llvm-$name"; do + if [ -x "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + command -v "$name" 2>/dev/null && return 0 + command -v "g$name" 2>/dev/null && return 0 + return 1 +} + check_prerequisites() { print_header "Checking prerequisites" command -v git >/dev/null 2>&1 || { print_error "git not found"; exit 1; } command -v rustup >/dev/null 2>&1 || { print_error "rustup not found"; exit 1; } command -v cargo >/dev/null 2>&1 || { print_error "cargo not found"; exit 1; } - command -v cargo-ndk >/dev/null 2>&1 || { print_error "cargo-ndk not found. Install: cargo install cargo-ndk"; exit 1; } + command -v cargo-ndk >/dev/null 2>&1 || { print_error "cargo-ndk not found. Install: cargo install cargo-ndk --version $CARGO_NDK_VERSION --locked"; exit 1; } - if [ -z "${ANDROID_NDK_HOME:-}" ]; then - # Try common locations - for candidate in \ - "$HOME/Android/Sdk/ndk/"*/ \ - "$HOME/Library/Android/sdk/ndk/"*/ \ - "/usr/local/lib/android/sdk/ndk/"*/; do - if [ -d "$candidate" ]; then - export ANDROID_NDK_HOME="${candidate%/}" - break - fi - done + local found_cargo_ndk + found_cargo_ndk="$(cargo ndk --version 2>/dev/null | awk '{print $2}' || true)" + if [ "$found_cargo_ndk" != "$CARGO_NDK_VERSION" ]; then + print_info "cargo-ndk ${found_cargo_ndk:-unknown} != pinned $CARGO_NDK_VERSION — if the" + print_info " output does not match the committed .so, try: cargo install cargo-ndk --version $CARGO_NDK_VERSION --locked" + else + print_success "cargo-ndk: $CARGO_NDK_VERSION" fi - if [ -z "${ANDROID_NDK_HOME:-}" ]; then - print_error "ANDROID_NDK_HOME not set and NDK not found in common locations" + # Find the pinned revision wherever it lives, checking each candidate's own + # source.properties and moving on when it does not match. An exported + # ANDROID_NDK_HOME / ANDROID_NDK_ROOT is only a hint: CI images (GitHub + # runners export both) and IDE installs routinely point them at a bundled + # NDK that is not ours, and failing outright there would reject a machine + # that has the pinned revision installed right next to it. No wildcard + # anywhere: picking "some NDK" is what let the committed binaries be built + # with r25b while the docs asked for r27. + local candidate revision found_ndk="" rejected="" + for candidate in \ + "${ANDROID_NDK_HOME:-}" \ + "${ANDROID_NDK_ROOT:-}" \ + "${ANDROID_HOME:-}/ndk/$NDK_VERSION" \ + "${ANDROID_SDK_ROOT:-}/ndk/$NDK_VERSION" \ + "${HOME:-}/Android/Sdk/ndk/$NDK_VERSION" \ + "${HOME:-}/Library/Android/sdk/ndk/$NDK_VERSION" \ + "/usr/local/lib/android/sdk/ndk/$NDK_VERSION"; do + [ -n "$candidate" ] || continue + [ -d "$candidate" ] || continue + + revision="$(ndk_revision "$candidate")" + if [ "$revision" = "$NDK_VERSION" ]; then + found_ndk="${candidate%/}" + break + fi + rejected="${rejected} ${candidate%/} is ${revision:-not an NDK}"$'\n' + done + + if [ -z "$found_ndk" ]; then + print_error "Android NDK $NDK_VERSION not found" + echo " It is pinned because another revision produces a .so that does not" + echo " match the committed one (tools/arti-build/ANDROID_NDK_VERSION)." + if [ -n "$rejected" ]; then + echo " Looked at, wrong revision:" + printf '%s' "$rejected" + fi + echo " Install it: sdkmanager \"ndk;$NDK_VERSION\"" + echo " Or point ANDROID_NDK_HOME at an existing $NDK_VERSION install." exit 1 fi - print_success "NDK: $ANDROID_NDK_HOME" + export ANDROID_NDK_HOME="$found_ndk" + print_success "NDK: $ANDROID_NDK_HOME ($NDK_VERSION)" for target in "${TARGETS[@]}"; do if ! rustup target list --installed | grep -q "$target"; then @@ -180,17 +253,22 @@ PATCH # Build # ============================================================================ +# Android ABI directory (as laid out under jniLibs/) for a Rust target triple. +abi_dir_for() { + case "$1" in + aarch64-linux-android) echo "arm64-v8a" ;; + x86_64-linux-android) echo "x86_64" ;; + armv7-linux-androideabi) echo "armeabi-v7a" ;; + i686-linux-android) echo "x86" ;; + esac +} + build_for_target() { local target="$1" print_header "Building for $target" local arch_dir - case "$target" in - aarch64-linux-android) arch_dir="arm64-v8a" ;; - x86_64-linux-android) arch_dir="x86_64" ;; - armv7-linux-androideabi) arch_dir="armeabi-v7a" ;; - i686-linux-android) arch_dir="x86" ;; - esac + arch_dir="$(abi_dir_for "$target")" local out_dir="$OUTPUT_DIR/$arch_dir" mkdir -p "$out_dir" @@ -229,15 +307,32 @@ verify_jni_symbols() { "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy" ) - for arch_dir in "$OUTPUT_DIR"/*/; do - local lib="$arch_dir$LIB_NAME" + local nm_bin + nm_bin="$(ndk_tool nm || true)" + if [ -z "$nm_bin" ]; then + print_error "no nm found (looked in the NDK and on PATH) — cannot verify the JNI exports" + exit 1 + fi + + local failed=0 + for target in "${TARGETS[@]}"; do + local arch + arch="$(abi_dir_for "$target")" + local lib="$OUTPUT_DIR/$arch/$LIB_NAME" [ -f "$lib" ] || continue - local arch=$(basename "$arch_dir") local missing=0 + # Read the dynamic symbol table once, into a variable. Piping nm into + # `grep -q` per symbol looks equivalent but is not: grep exits on the + # first match, nm dies of SIGPIPE (141), and `set -o pipefail` then + # reports the pipeline as failed — so every symbol that IS exported gets + # reported as missing. (Reproducible on any build, old or new.) + local syms + syms="$("$nm_bin" -D "$lib" 2>/dev/null || true)" + for sym in "${expected_symbols[@]}"; do - if ! nm -D "$lib" 2>/dev/null | grep -q "$sym"; then + if [[ "$syms" != *"$sym"* ]]; then print_error "$arch: Missing symbol $sym" missing=1 fi @@ -245,6 +340,52 @@ verify_jni_symbols() { if [ "$missing" -eq 0 ]; then print_success "$arch: All JNI symbols present" + else + failed=1 + fi + done + + # Hard failure: a library missing these exports still loads, and then every + # ArtiNative call throws UnsatisfiedLinkError at runtime instead. + if [ "$failed" -ne 0 ]; then + print_error "JNI exports missing — refusing to leave this .so in jniLibs" + exit 1 + fi +} + +verify_ndk_stamp() { + print_header "Verifying NDK stamp" + + local readelf_bin + readelf_bin="$(ndk_tool readelf || true)" + if [ -z "$readelf_bin" ]; then + print_error "no readelf found (looked in the NDK and on PATH) — cannot verify the NDK stamp" + exit 1 + fi + + # Every NDK-linked shared object carries .note.android.ident, which records + # the target API level, the NDK release name (e.g. r27d) and the NDK build + # number. Reading it back proves which toolchain actually produced the + # binary, independently of what the environment claimed — this is how the + # committed r25b libraries were identified in the first place. + for target in "${TARGETS[@]}"; do + local arch + arch="$(abi_dir_for "$target")" + local lib="$OUTPUT_DIR/$arch/$LIB_NAME" + [ -f "$lib" ] || continue + + # Read the note once into a variable: `readelf | grep -q` would let grep + # exit first, kill readelf with SIGPIPE, and fail the pipeline under + # `set -o pipefail` — the same trap that made the symbol check above + # report every exported symbol as missing. + local note + note="$("$readelf_bin" -p .note.android.ident "$lib" 2>/dev/null || true)" + if grep -qw "$NDK_BUILD_NUMBER" <<< "$note"; then + print_success "$arch: built by NDK $NDK_VERSION" + else + print_error "$arch: not stamped with NDK build $NDK_BUILD_NUMBER — wrong toolchain?" + printf '%s\n' "$note" + exit 1 fi done } @@ -280,6 +421,7 @@ main() { done verify_jni_symbols + verify_ndk_stamp print_header "Build complete" echo "" diff --git a/tools/arti-build/rust-toolchain.toml b/tools/arti-build/rust-toolchain.toml index cf48bd6cb0..cfb3a8367d 100644 --- a/tools/arti-build/rust-toolchain.toml +++ b/tools/arti-build/rust-toolchain.toml @@ -7,7 +7,7 @@ # build scripts. Bump this in lockstep with ARTI_VERSION / Cargo.lock and # re-verify (see README.md → "Reproducible builds"). [toolchain] -channel = "1.94.1" +channel = "1.98.1" profile = "minimal" components = ["rustc", "cargo", "rust-std"] # Only the two ABIs we actually ship libarti_android.so for (see jniLibs/). If diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs index 0e4a2bc09c..7d49f4d5b8 100644 --- a/tools/arti-build/src/lib.rs +++ b/tools/arti-build/src/lib.rs @@ -1,6 +1,7 @@ -use jni::JNIEnv; -use jni::objects::{JClass, JString, JObject, GlobalRef}; -use jni::sys::{jint, jstring}; +use jni::{jni_sig, jni_str, EnvUnowned}; +use jni::errors::{LogErrorAndDefault, Result as JniResult, ThrowRuntimeExAndDefault}; +use jni::objects::{Global, JClass, JObject, JString}; +use jni::sys::jint; use jni::JavaVM; use arti_client::{BootstrapBehavior, TorClient}; @@ -21,7 +22,7 @@ use anyhow::Result; static ARTI_CLIENT: Mutex>>> = Mutex::new(None); static TOKIO_RUNTIME: Mutex> = Mutex::new(None); static JAVA_VM: Mutex> = Mutex::new(None); -static LOG_CALLBACK: Mutex> = Mutex::new(None); +static LOG_CALLBACK: Mutex>>> = Mutex::new(None); static SOCKS_TASK: Mutex>> = Mutex::new(None); // The background directory download started by initialize(). It holds an Arc, so // destroy() must abort it too — otherwise the client cannot drop, the state file lock is never @@ -42,16 +43,21 @@ fn send_log_to_java(message: String) { let callback_opt = LOG_CALLBACK.lock().unwrap(); if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) { - if let Ok(mut env) = vm.attach_current_thread() { + // jni 0.22 only hands out an `Env` inside a closure, borrowed from an + // attachment pinned to the stack; it also pushes a local-reference frame + // per call, so `jmessage` is released when the closure returns instead of + // accumulating on this long-lived logging thread. + let _ = vm.attach_current_thread(|env| -> JniResult<()> { if let Ok(jmessage) = env.new_string(&message) { let _ = env.call_method( - callback.as_obj(), - "onLogLine", - "(Ljava/lang/String;)V", - &[(&jmessage).into()] + &**callback, + jni_str!("onLogLine"), + jni_sig!("(Ljava/lang/String;)V"), + &[(&jmessage).into()], ); } - } + Ok(()) + }); } } @@ -74,52 +80,92 @@ macro_rules! log_error { // ============================================================================ #[no_mangle] -pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion( - env: JNIEnv, - _class: JClass, -) -> jstring { - if JAVA_VM.lock().unwrap().is_none() { - if let Ok(vm) = env.get_java_vm() { - *JAVA_VM.lock().unwrap() = Some(vm); +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, +) -> JString<'caller> { + // jni 0.22: the raw environment pointer is FFI-only (`EnvUnowned`); JNI calls + // need the `Env` that `with_env` borrows for the closure. `resolve` maps an + // `Err` to the policy — here a Java RuntimeException plus a null return — + // rather than losing it. + // + // Only the `Err` half is live: the policy's panic half runs through + // `catch_unwind`, which catches nothing under this crate's + // `panic = "abort"` release profile, so a panic in here still takes the + // process down exactly as it did before the migration. + env.with_env(|env| -> JniResult> { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } } - } - let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); - let output = env.new_string(version).expect("Couldn't create java string!"); - output.into_raw() + let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); + env.new_string(version) + }) + .resolve::() } #[no_mangle] -pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback( - env: JNIEnv, - _class: JClass, - callback: JObject, +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + callback: JObject<'caller>, ) { - if JAVA_VM.lock().unwrap().is_none() { - if let Ok(vm) = env.get_java_vm() { - *JAVA_VM.lock().unwrap() = Some(vm); + env.with_env(|env| -> JniResult<()> { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } } - } - if let Ok(global_ref) = env.new_global_ref(callback) { - *LOG_CALLBACK.lock().unwrap() = Some(global_ref); - log_info!("Log callback registered"); - } + if let Ok(global_ref) = env.new_global_ref(&callback) { + *LOG_CALLBACK.lock().unwrap() = Some(global_ref); + log_info!("Log callback registered"); + } + Ok(()) + }) + .resolve::() } /// Initialize Arti runtime and bootstrap the TorClient. /// The TorClient is created once and reused for the app's lifetime. #[no_mangle] -pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( - mut env: JNIEnv, - _class: JClass, - data_dir: JString, +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + data_dir: JString<'caller>, ) -> jint { - if JAVA_VM.lock().unwrap().is_none() { - if let Ok(vm) = env.get_java_vm() { - *JAVA_VM.lock().unwrap() = Some(vm); - } - } + // Everything JNI-owned is read inside this closure; the rest of the function + // is pure Rust that blocks on Tokio, which must not hold an `Env`. + // + // `None` carries a failed read, because it is `Option::default()` and so is + // also what the policy yields for an `Err`. Both end at the same `-1` the + // old `Err` arm returned. Resolving to `jint` directly would have defaulted + // to `0`, the value this API reports as success. + // + // The already-initialized check deliberately stays *outside* the closure, + // against the whole `Option`: threading it through as a sentinel value + // would leave that sentinel to be re-tested after the closure, and a + // concurrent destroy() landing in between would let it through as the data + // directory. + let data_dir_str: Option = env + .with_env(|env| -> JniResult> { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + Ok(match data_dir.try_to_string(env) { + Ok(s) => Some(s), + Err(e) => { + log_error!("Failed to convert data_dir: {:?}", e); + None + } + }) + }) + .resolve::(); // Already initialized — skip if ARTI_CLIENT.lock().unwrap().is_some() { @@ -127,12 +173,9 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( return 0; } - let data_dir_str: String = match env.get_string(&data_dir) { - Ok(s) => s.into(), - Err(e) => { - log_error!("Failed to convert data_dir: {:?}", e); - return -1; - } + let data_dir_str: String = match data_dir_str { + Some(s) => s, + None => return -1, }; log_info!("Initializing Arti with data directory: {}", data_dir_str); @@ -220,7 +263,9 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( .create_unbootstrapped_async() .await { - Ok(c) => Arc::new(c), + // Arti 2.4.0 made every TorClient constructor return an Arc + // (TorClient itself is no longer Clone), so there is nothing to wrap here. + Ok(c) => c, Err(e) => { log_error!("Failed to create Tor client: {:?}", e); return -3; @@ -267,7 +312,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( /// Can be called multiple times — stops any existing listener first. #[no_mangle] pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy( - _env: JNIEnv, + _env: EnvUnowned, _class: JClass, port: jint, ) -> jint { @@ -494,7 +539,7 @@ async fn handle_socks_connection( /// Stop the SOCKS proxy listener. The TorClient stays alive. #[no_mangle] pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy( - _env: JNIEnv, + _env: EnvUnowned, _class: JClass, ) -> jint { log_info!("Stopping SOCKS proxy..."); @@ -532,7 +577,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro /// measurable signal for a guess. #[no_mangle] pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_bootstrapProgressPermille( - _env: JNIEnv, + _env: EnvUnowned, _class: JClass, ) -> jint { match ARTI_CLIENT.lock().unwrap().as_ref() { @@ -550,7 +595,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_bootstrapPro /// leaving the UI wrong and the exit-rotation self-heal disabled. #[no_mangle] pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_isBootstrapped( - _env: JNIEnv, + _env: EnvUnowned, _class: JClass, ) -> jint { match ARTI_CLIENT.lock().unwrap().as_ref() { @@ -573,7 +618,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_isBootstrapp /// (and re-bootstrap). #[no_mangle] pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy( - _env: JNIEnv, + _env: EnvUnowned, _class: JClass, ) -> jint { log_info!("Destroying Arti client"); diff --git a/tools/arti-build/verify-reproducible.sh b/tools/arti-build/verify-reproducible.sh index f83d12ea7a..d9bc45d9e7 100755 --- a/tools/arti-build/verify-reproducible.sh +++ b/tools/arti-build/verify-reproducible.sh @@ -12,7 +12,9 @@ # ./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) # ./verify-reproducible.sh --release # arm64-v8a only (faster) # -# Prerequisites are the same as build-arti.sh (rustup, cargo-ndk, Android NDK). +# Prerequisites are the same as build-arti.sh (rustup, cargo-ndk, and the exact +# Android NDK revision pinned in ANDROID_NDK_VERSION — a different revision is +# refused, because it would change the output bytes). # Exit 0 = reproducible, exit 1 = builds differ. set -euo pipefail @@ -26,15 +28,25 @@ sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi } -# sha256 of every built .so, keyed by ABI dir (relative paths → stable keys). -# `find | sort` (plain text sort) is portable across GNU and BSD userlands. +# Only the ABIs this run actually rebuilds. Hashing everything under jniLibs/ +# (what `find` used to do) made `--release` look like it had verified the +# x86_64 library: that build never touches it, so the untouched file hashed +# identically in both runs and the script reported the whole tree reproducible +# and matching the commit. +ABIS="arm64-v8a x86_64" +for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do + [ "$arg" = "--release" ] && ABIS="arm64-v8a" +done + +# sha256 of each built .so, keyed by ABI dir (relative paths → stable keys). hashes() { - ( cd "$JNILIBS" && find . -name libarti_android.so | sort | while IFS= read -r f; do - sha256 "$f" + ( cd "$JNILIBS" && for abi in $ABIS; do + [ -f "$abi/libarti_android.so" ] && sha256 "$abi/libarti_android.so" done ) } echo "### Reproducibility check for libarti_android.so" +echo "### ABIs: $ABIS" echo "### Canonical build path: ${ARTI_REPRO_DIR:-/tmp/amethyst-arti-build}" echo @@ -59,11 +71,17 @@ fi # Informational: is the binary committed in git already the reproducible one? echo echo "### vs. the committed binaries:" -if git -C "$PROJECT_ROOT" diff --quiet -- amethyst/src/main/jniLibs/; then +BUILT_PATHS="" +for abi in $ABIS; do + BUILT_PATHS="$BUILT_PATHS amethyst/src/main/jniLibs/$abi/libarti_android.so" +done + +# shellcheck disable=SC2086 # BUILT_PATHS is a deliberate multi-path list +if git -C "$PROJECT_ROOT" diff --quiet -- $BUILT_PATHS; then echo "✓ The reproducible build matches what's committed — the shipped .so is verifiable as-is." else echo "⚠ The reproducible build differs from the committed .so (e.g. the committed one" echo " predates this toolchain). Commit the rebuilt binaries so the shipped artifact" echo " is itself a reproducible build:" - echo " git -C \"$PROJECT_ROOT\" add amethyst/src/main/jniLibs && git commit" + echo " git -C \"$PROJECT_ROOT\" add$BUILT_PATHS && git commit" fi diff --git a/tools/icon-font/README.md b/tools/icon-font/README.md index f669635728..5074fdb82c 100644 --- a/tools/icon-font/README.md +++ b/tools/icon-font/README.md @@ -1,7 +1,7 @@ # Amethyst icon font Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in -`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`. +`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`. ## Why diff --git a/tools/ime-test/shim-events.mjs b/tools/ime-test/shim-events.mjs index acb5a62dc1..a2bb794ca1 100644 --- a/tools/ime-test/shim-events.mjs +++ b/tools/ime-test/shim-events.mjs @@ -1,6 +1,6 @@ // Regression test for the embedded-WebView IME relay's page→host protocol. // -// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium +// Loads the REAL shim (commonsUI/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium // with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*` // envelopes it emits. This is the only honest automated coverage for this code: the half worth protecting // is the page↔host contract (real browser focus/gesture behavior), which no JVM unit test of the host-side @@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' const HERE = dirname(fileURLToPath(import.meta.url)) -const SHIM = process.argv[2] ?? resolve(HERE, '../../commons/src/commonMain/composeResources/files/napplet/shim.js') +const SHIM = process.argv[2] ?? resolve(HERE, '../../commonsUI/src/commonMain/composeResources/files/napplet/shim.js') const CHROMIUM = process.env.CHROMIUM_PATH ?? '/opt/pw-browsers/chromium-1194/chrome-linux/chrome' const HTML = `ime diff --git a/tools/material-symbols-subset/README.md b/tools/material-symbols-subset/README.md index 9db8fea879..c3c428c8c8 100644 --- a/tools/material-symbols-subset/README.md +++ b/tools/material-symbols-subset/README.md @@ -1,11 +1,11 @@ # Material Symbols Subset The font shipped at -`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` +`commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` is a **subset** of Google's [Material Symbols Outlined](https://github.com/google/material-design-icons) variable font, trimmed to only the codepoints referenced from -`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`. +`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`. | | Full upstream | Subset | |---|---|---| diff --git a/tools/material-symbols-subset/subset.sh b/tools/material-symbols-subset/subset.sh index 640964d342..8f9fe9f365 100755 --- a/tools/material-symbols-subset/subset.sh +++ b/tools/material-symbols-subset/subset.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Subset commons/.../material_symbols_outlined.ttf to only the codepoints +# Subset commonsUI/.../material_symbols_outlined.ttf to only the codepoints # referenced from MaterialSymbols.kt. The full Google variable font is ~11 MB # (≈3500 glyphs); the subset is ~410 KB. # @@ -13,13 +13,13 @@ # font from Google Fonts. # # Output: -# Overwrites commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf +# Overwrites commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -SYMBOLS_KT="$REPO_ROOT/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt" -TARGET="$REPO_ROOT/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf" +SYMBOLS_KT="$REPO_ROOT/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt" +TARGET="$REPO_ROOT/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf" if ! command -v pyftsubset >/dev/null 2>&1; then echo "pyftsubset not found. Install with: pip install fonttools brotli" >&2 diff --git a/tools/strings-migrate/README.md b/tools/strings-migrate/README.md index 16aa92657b..7611efed3d 100644 --- a/tools/strings-migrate/README.md +++ b/tools/strings-migrate/README.md @@ -1,7 +1,7 @@ # strings-migrate Moves string keys from the Android app's `res/` into commons Compose -resources (`commons/src/commonMain/composeResources`) across **all** +resources (`commonsUI/src/commonMain/composeResources`) across **all** locales in one shot, preserving each element byte-for-byte so Crowdin sees a pure move. Both trees are Crowdin-managed with the same android layout (see `crowdin.yml`), so a migrated key keeps its translations. diff --git a/tools/strings-migrate/migrate.py b/tools/strings-migrate/migrate.py index f7cac05e89..151eeb1f85 100755 --- a/tools/strings-migrate/migrate.py +++ b/tools/strings-migrate/migrate.py @@ -7,7 +7,7 @@ For the default locale and every values-* locale dir, each named or element is removed from amethyst/src/main/res//strings.xml and appended (before ) to - commons/src/commonMain/composeResources//strings.xml + commonsUI/src/commonMain/composeResources//strings.xml creating the commons locale file when a translation exists that commons doesn't carry yet. Elements are moved verbatim (raw XML slice), so escaping, CDATA and comments inside values survive byte-for-byte and @@ -25,7 +25,7 @@ import sys from fix_escapes import STRING_EL, fix_text, strip_android_only_attrs APP_RES = "amethyst/src/main/res" -COMMONS_RES = "commons/src/commonMain/composeResources" +COMMONS_RES = "commonsUI/src/commonMain/composeResources" NEW_FILE_TEMPLATE = '\n\n\n'