diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index aedfbdf9dd..c81a08ec78 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -46,8 +46,7 @@ amethyst/ │ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack ├── desktopApp/ # Desktop JVM application (layouts, navigation) ├── amethyst/ # Android app (layouts, navigation) -├── cli/ # Amy — non-interactive CLI (JVM only, no Compose) -└── ammolite/ # Support module (unused) +└── cli/ # Amy — non-interactive CLI (JVM only, no Compose) ``` **Sharing Philosophy:** @@ -99,6 +98,28 @@ Specialized skills provide domain expertise with bundled resources and patterns: | `find-missing-translations` | Utility | Extract untranslated Android strings | | `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads | +### Technique-layer skills (Compose / Kotlin best practices) + +These are general Compose/Kotlin decision-framework skills (vendored from +`chrisbanes/skills`). The skills above are **codebase-oriented** ("where is X in +Amethyst, what pattern do we use"); these are **technique-oriented** ("what is +the correct Compose/Kotlin design here"). They complement — not replace — the +codebase skills: e.g. `compose-expert` tells you where shared composables live, +`compose-slot-api-pattern` tells you how to shape their public API. + +| Skill | Expertise | Complements | +|-------|-----------|-------------| +| `compose-recomposition-performance` | Router: which recomposition axis is the problem | `compose-expert` | +| `compose-stability-diagnostics` | Compiler reports, strong skipping, `ImmutableList` at UI boundaries | `compose-expert`, `kotlin-expert` | +| `compose-state-deferred-reads` | Phase-aware state reads, block-form modifiers, provider lambdas | `compose-expert` | +| `compose-slot-api-pattern` | `@Composable` slot design for reusable components | `compose-expert` | +| `compose-modifier-and-layout-style` | `modifier` parameter conventions, chain construction, conditional hoisting | `compose-expert` | +| `compose-side-effects` | `LaunchedEffect`/`DisposableEffect`/`SideEffect`, keys, `rememberUpdatedState` | `compose-expert` | +| `compose-state-holder-ui-split` | State-holder vs plain-UI composable split | `compose-expert`, `feed-patterns` | +| `kotlin-flow-state-event-modeling` | StateFlow/SharedFlow/Channel choice, sentinels, `stateIn`, `update {}` | `kotlin-expert` | +| `kotlin-coroutines-structured-concurrency` | Stored-scope anti-pattern, `suspend` boundaries, `runBlocking`, cancellation | `kotlin-coroutines` | +| `kotlin-types-value-class` | `@JvmInline value class` vs `data class`, Compose stability | `kotlin-expert` | + ## Workflow **When you ask for a feature:** @@ -292,6 +313,16 @@ After completing any task that modifies Kotlin files, always run: ``` Do this before considering the task complete. +### Kotlin Style + +- **Never write fully-qualified class names inline in function bodies.** Add an + `import` for the class and reference it by its simple name. Write + `Event` (with `import com.vitorpamplona.quartz...Event`), not + `com.vitorpamplona.quartz...Event` in the middle of code. +- The only acceptable inline fully-qualified names are: a genuine name + collision (prefer `import ... as Alias` instead), or where the language + requires it. Comments, KDoc, and string literals are exempt. + ### Navigation Shell - **Desktop**: Sidebar + main content area - **Android**: Bottom navigation diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 9c63073e38..6b0ee15773 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -745,8 +745,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 - versionCode = 442 - versionName = "1.08.0" + versionCode = 444 + versionName = "1.09.2" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/compose-modifier-and-layout-style/SKILL.md b/.claude/skills/compose-modifier-and-layout-style/SKILL.md new file mode 100644 index 0000000000..1f446843f8 --- /dev/null +++ b/.claude/skills/compose-modifier-and-layout-style/SKILL.md @@ -0,0 +1,333 @@ +--- +name: compose-modifier-and-layout-style +description: Use when writing or reviewing Jetpack Compose layout APIs, modifier parameters, modifier chain construction, hardcoded root layout decisions, or layout wrappers around a single conditional. Technique-layer skill — complements the codebase-specific compose-expert. +--- + +# Compose modifier and layout style + +## Core principle + +A composable that emits layout is a leaf the *parent* places — the parent decides position, size, alignment, padding. The composable's job is structure (what's inside), not placement (where it goes). Three rules follow: + +- **Declare a `modifier` parameter and apply it to the root**, so the parent can actually do its job. Hardcoding `.fillMaxWidth()` on a composable's root takes that decision away from every future caller. +- **Construct modifier chains as one fluent expression**, not stepwise reassignments. Both compile to the same thing, but the chain *reads* as intent in one pass. +- **Conditional rendering belongs where the condition applies.** A layout call whose only content is one `if` exists solely to hold the condition — push the `if` outside instead. + +These travel together because the same composable usually triggers all three: you declare its parameters (rule 1), the caller constructs a chain to position it (rules 2), and the body has a conditional you might be tempted to wrap (rule 3). + +## When to use this skill + +- You're writing a `@Composable fun` that calls a layout (`Box`, `Column`, `Row`, `LazyColumn`, `Text`, `Image`, `Surface`, `Card`, `Layout { … }`, anything from `compose.foundation.layout` or `compose.material*`) and its signature has no `modifier` parameter, or has one that isn't applied to the root, or has a hardcoded `.fillMaxWidth()`/`.padding(...)` on the root. +- You see `var m = Modifier` followed by `m = m.padding(…)`, `m = m.background(…)`, etc. +- A `modifier = …` argument has three or more chained calls on a single line. +- A composable's body is `Layout { if (cond) Content() }` — one conditional, nothing else. + +## 1. Declare a `modifier` parameter + +For composables that emit layout, prefer a `modifier` parameter after required parameters and before content/lambda parameters, with a default of `Modifier`. The name is exactly `modifier` — not `mod`, not `m`, not `wrapperModifier`. + +```kotlin +// ❌ BAD — no modifier param; caller can't position, size, or constrain this +@Composable +fun HomeScreenHeader(title: String, subtitle: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(title, style = MaterialTheme.typography.headlineLarge) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) + } +} +``` + +```kotlin +// ✅ GOOD — parent decides width and padding; the composable describes structure only +@Composable +fun HomeScreenHeader( + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(title, style = MaterialTheme.typography.headlineLarge) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) + } +} +``` + +The caller now writes `HomeScreenHeader(title, subtitle, Modifier.fillMaxWidth().padding(horizontal = 16.dp))` once, at the home screen — the only place that knows the layout actually wants those. + +## 2. Apply the caller's modifier to the root, and apply it first + +When the root layout already takes other arguments (alignment, arrangement, padding *that's intrinsic to the composable*), the caller-provided modifier still goes on the root layout's `modifier` parameter — and the composable's local chain is appended after. + +```kotlin +// ❌ BAD — modifier accepted but never applied +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Image(painter = rememberAsyncImagePainter(url), contentDescription = null) +} + +// ❌ BAD — applied to a child, not the root; caller's size/position changes don't take +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Box { + Image( + painter = rememberAsyncImagePainter(url), + contentDescription = null, + modifier = modifier, + ) + } +} + +// ❌ BAD — caller's modifier ends up last, so the composable's own size wins +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Image( + painter = rememberAsyncImagePainter(url), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(48.dp) + .then(modifier), + ) +} +``` + +```kotlin +// ✅ GOOD — caller's modifier first, then the composable's intrinsic chain +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Image( + painter = rememberAsyncImagePainter(url), + contentDescription = null, + modifier = modifier + .clip(CircleShape) + .size(48.dp), + ) +} +``` + +Order matters: in a modifier chain, the *earlier* segment is the outer wrapper. The caller's modifier should be the outermost so caller-provided `.size(...)` or `.padding(...)` can override the composable's defaults rather than being overridden by them. + +## 3. Don't hardcode layout decisions on the root + +If the composable's root has `.fillMaxWidth()`, `.padding(horizontal = 16.dp)`, `.height(56.dp)`, etc., the caller can't *not* have them. Those are layout choices the parent should own. + +```kotlin +// ❌ BAD — every caller now fills max width whether they want to or not +@Composable +fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), // ← hardcoded + ) { Text(text) } +} + +// ✅ GOOD — caller adds .fillMaxWidth() if (and only if) they want it +@Composable +fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button(onClick = onClick, modifier = modifier) { Text(text) } +} +``` + +The carve-out is for modifiers that are part of the **identity** of the composable — what makes an `Avatar` an avatar (the `.clip(CircleShape)` and a default `.size(48.dp)`), not where it sits on the screen. Test: can you imagine a caller wanting a version of this composable *without* that modifier? If yes, push it out. If no (an avatar without `clip(CircleShape)` isn't an avatar), keep it — but put it *after* the caller's modifier in the chain (see §2). + +## 4. Construct modifier chains as one fluent expression + +Recomposition re-runs the composable body — every modifier expression is re-evaluated. Reassigning `var modifier =` step-by-step looks plausible but breaks the visual flow, invites further mutation, and produces nothing a chain doesn't. + +```kotlin +// ❌ BAD — visual flow broken into reassignments; `var` invites more mutation +@Composable +fun Demo() { + var m = Modifier + m = m.padding(16.dp) + m = m.fillMaxSize() + Box(m) { } +} + +// ❌ ALSO BAD — same shape, dressed up with .then() +@Composable +fun Demo() { + var m = Modifier + m = m.padding(16.dp) + m = m.then(Modifier.fillMaxSize()) + Box(m) { } +} +``` + +```kotlin +// ✅ GOOD +@Composable +fun Demo() { + val m = Modifier + .padding(16.dp) + .fillMaxSize() + Box(m) { } +} +``` + +`val`, not `var`: once the chain is built, nothing should re-bind it. The reassignment shape is what makes `var` look necessary; the chain shape doesn't need it. + +### Inline at the call site is fine for short chains + +For one or two calls, build the modifier inline. The "extract to a `val`" rule only earns its keep when the chain is long enough to be worth naming, or when the same chain repeats. + +```kotlin +// ✅ GOOD — short chain inline +Box(modifier = Modifier.fillMaxWidth()) { … } +Box(modifier = Modifier.padding(8.dp).background(Color.Red)) { … } +``` + +### Conditional segments stay on the chain + +A common reason to reach for `var` is "the modifier depends on a condition." It doesn't — splice the condition inline: + +```kotlin +// ✅ GOOD — conditional inside the chain, still one expression +Box( + modifier = Modifier + .fillMaxWidth() + .then(if (selected) Modifier.background(Color.Red) else Modifier), +) +``` + +`Modifier` (the empty modifier) is the identity element for `.then` — it lets you keep the chain shape when one branch contributes nothing. + +## 5. Multiline formatting at the call site + +When a `modifier` argument's chain has **three or more** calls, format multiline with one call per line. Indent the chain so the dotted calls align beneath the value. + +```kotlin +// ❌ BAD — three+ calls on one line; hard to scan +Box( + modifier = modifier.fillMaxSize().padding(16.dp).weight(1f), +) + +// ✅ GOOD +Box( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .weight(1f), +) +``` + +One or two calls stay on a single line — the threshold is the call count, not the character count. If a single call has very long arguments, that's a different problem (extract a `val`, or shorten the arguments). + +This applies *only* to a parameter named `modifier`. Other fluent-style arguments aren't covered here. + +## 6. Hoist single conditionals out of the layout + +When a layout's *only* content is one `if`, the layout exists solely to "hold" the conditional. Move the `if` outside — the layout will only exist when it has something to show. + +```kotlin +// ❌ BAD — Column always emitted; only its inner content is conditional +@Composable +fun A() { + Column { + if (showHeader) { + Text("Title") + Text("Subtitle") + } + } +} + +// ✅ GOOD — Column only exists when it has content +@Composable +fun A() { + if (showHeader) { + Column { + Text("Title") + Text("Subtitle") + } + } +} +``` + +The benefit isn't a performance win — the runtime handles both fine — it's that the second form *reads* as "header section, conditionally." The first reads as "always-on column that may or may not have content." + +### The carve-outs (and why) + +- **Layout carries visual semantics that aren't conditional.** When the layout call passes `modifier`, `contentAlignment`, `horizontalArrangement`, or `verticalAlignment`, those arguments describe the *container*, not the content. Hoisting the conditional either loses those (the container collapses with the content) or duplicates them into both branches. Leave it. + + ```kotlin + // ✅ KEEP AS-IS — modifier on the container is doing visible work + @Composable + fun A(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + if (something) { + Text("Bleh1") + Text("Bleh2") + } + } + } + ``` + +- **There are siblings to the `if`.** The layout has other content; the `if` is just one piece. Hoisting either pulls the siblings out (changing the layout) or leaves a different shape behind. Leave it. + +- **`if … else …` with both branches contributing composables.** Both branches do work; nothing to hoist; the layout *is* the shared container. + + ```kotlin + // ✅ KEEP AS-IS — both branches contribute to the layout + Box { + if (something) Text("Hint") else innerTextField() + } + ``` + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `@Composable fun Foo(text: String)` with `Column`/`Box`/`Text` in body | No `modifier` param (§1) | Add `modifier: Modifier = Modifier`; pass to root | +| `modifier: Modifier = Modifier` declared but never referenced | Param ignored (§2) | Apply to root layout's `modifier` arg | +| `modifier` passed to a child, not the root | Wrong target (§2) | Move to the outermost layout's `modifier` | +| `modifier = Modifier.x().y().then(modifier)` | Caller's modifier last (§2) | Reorder: `modifier = modifier.x().y()` | +| `modifier = modifier.fillMaxWidth().padding(...)` on a general-purpose component | Layout hardcoded (§3) | Remove the hardcoded calls; let callers add them | +| Sibling composables in the file don't have `modifier` either | Spreading anti-pattern | Fix this one; fix siblings opportunistically | +| `mod: Modifier = Modifier` or `wrapperModifier: Modifier = Modifier` | Wrong name (§1) | Rename to exactly `modifier` | +| `var m = Modifier` followed by `m = m.xxx()` reassignments | Stepwise modifier construction (§4) | One fluent chain on a `val`, or build inline | +| `var m = Modifier; m = m.then(Modifier.xxx())` | Same shape via `.then` (§4) | Collapse `.then(Modifier.x())` to `.x()` in the chain | +| Modifier branch needs a condition | Reaching for `var` (§4) | `.then(if (c) Modifier.x() else Modifier)` inside the chain | +| `modifier = modifier.a().b().c()` on one line | Long chain not formatted (§5) | One call per line, indented under the value | +| `Layout { if (cond) X() }` with no other content and no layout-tuning args | Hoist (§6) | Move the `if` outside the layout | +| `Box(modifier = …) { if (cond) X() }` | Layout carries semantics — leave (§6 carve-out) | Keep as-is | +| `Box { if (cond) X() else Y() }` | Both branches contribute — leave (§6 carve-out) | Keep as-is | + +## When NOT to apply + +- **Composables that don't emit layout.** A `@Composable fun computeColor(): Color` or a `@Composable @ReadOnlyComposable` accessor doesn't emit a layout node. No `modifier` parameter needed (and a `@ReadOnlyComposable` couldn't accept one anyway). +- **`@Preview` functions.** Previews are throwaway entry points; the framework calls them with no caller. A `modifier` parameter would be unused dead weight. +- **Test-only composables** inside `*Test` sources whose only caller is `composeTestRule.setContent { … }`. Same reasoning as previews. +- **Internal layout primitives that take a `modifier` as their *first required* parameter** (very rare; framework-level). The rule is "first *optional* param"; some private utilities legitimately have `modifier` upfront as required. +- **Modifier assembled imperatively from animation state.** A modifier built by appending values from `Animatable` or other procedural sources may legitimately need intermediate variables. The chain isn't the goal; readability is. If the chain becomes a worse expression, write the imperative form. +- **Slot APIs that store modifiers** in a data class or builder (rare; usually framework-level code). The fluent-chain idea is about user-site construction. +- **Test composables** pinning specific recomposition shapes — usually fine either way; don't refactor test composables purely for style. + +The declaration-side rules (§1–§3) should not be skipped merely because "this composable is internal", "only used in one place", "I'd rather not have the extra parameter on the signature", or "we know all the callers already". Those are exactly the rationalisations that produce composables that become single-use the day someone wants to call them twice. + +## Red flags during review + +| Thought | Reality | +|---|---| +| "This composable is internal-only — adding `modifier` is over-engineering" | The parameter is eight characters and a default. It's not over-engineering; it's the convention. Skipping it is the over-engineering — it's a custom decision against the grain of every Compose API. | +| "It's only used in one place, so I know the layout requirements" | "Only used in one place" describes today. The cost of the parameter is paid once; the cost of refactoring callers when the second use site appears is paid per caller. | +| "The sibling composables in this file don't have `modifier` either, so I'm matching style" | Spreading an anti-pattern isn't matching style. Fix this one. Fix the siblings opportunistically. | +| "The parent always wants `.fillMaxWidth()` here" | Then the parent passes `.fillMaxWidth()`. The composable doesn't decide that for callers it hasn't met yet. | +| "I'll add it when someone needs it" | You're someone. You need it now (for the convention). The next caller won't add it either — they'll work around its absence. | +| "It's a tiny composable — the modifier param is noise" | The param is eight characters at the declaration and zero characters at any call site that doesn't need it. The "noise" is imagined. | +| "I added `modifier` but kept `.fillMaxWidth()` on the root so the home screen doesn't have to" | Then the *not*-home-screen caller can't unset it. Move the `.fillMaxWidth()` to the caller. | +| "I need `var` for the modifier because the chain depends on a condition" | A conditional segment is `.then(if (c) Modifier.x() else Modifier)`, still on one chain. No `var` needed. | +| "Three lines is too few to make multiline" | Three chained calls *is* the threshold. Below three, one line. At or above three, multiline. | +| "The Column adds nothing but I'll keep it for symmetry" | Then hoist the conditional and keep the Column inside the consequent — symmetry preserved, no always-on container. | +| "I'll put the `if` inside because the layout already exists" | "Already exists" is the bug. The layout shouldn't exist when the condition is false. | + +## Related + +- [`compose-slot-api-pattern`](../compose-slot-api-pattern/SKILL.md) — the other half of declaring a reusable composable's public API: take `@Composable () -> Unit` slots for variable content. A reusable component takes both a `modifier` parameter *and* slots — caller owns placement *and* what to place. diff --git a/.claude/skills/compose-recomposition-performance/SKILL.md b/.claude/skills/compose-recomposition-performance/SKILL.md new file mode 100644 index 0000000000..d006ecfffa --- /dev/null +++ b/.claude/skills/compose-recomposition-performance/SKILL.md @@ -0,0 +1,34 @@ +--- +name: compose-recomposition-performance +description: Use when investigating Jetpack Compose recomposition performance, skippable/restartable composables, composables.txt or compiler reports, Layout Inspector recomposition counts, or frame-rate State reads in composition vs layout/draw, and it is not yet clear whether the cause is parameter stability or deferred reads. Technique-layer skill — complements the codebase-specific compose-expert. +--- + +# Compose recomposition performance + +Router only — deep fixes live in [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) and [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md). + +## Two axes + +1. **Parameter stability / skipping** — can Compose skip this restartable composable; are arguments stable and comparable? +2. **Where `State` is read** — is frame-rate `State` read during composition vs layout/draw? + +Either axis can dominate; they combine independently. + +## Route here → focused skill + +| Primary suspicion | Next skill | +|---|---| +| Skipping, unstable params, compiler/`composables.txt` churn | [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) | +| Frame-rate `State` read phase (composition vs layout/draw) | [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) | +| Evidence for both | Apply both skills in parallel | + +## Review order + +1. Decide which axis fits the evidence; open the matching skill. +2. If unclear, sample both — stability churn vs composition-phase reads of fast `State`. +3. Re-measure after changes. + +## When NOT to apply + +- Recomposition tracks real data changes, or the bug is correctness not cost. +- No profiler / compiler signal suggests a problem. diff --git a/.claude/skills/compose-side-effects/SKILL.md b/.claude/skills/compose-side-effects/SKILL.md new file mode 100644 index 0000000000..3b728e2c20 --- /dev/null +++ b/.claude/skills/compose-side-effects/SKILL.md @@ -0,0 +1,173 @@ +--- +name: compose-side-effects +description: Use when writing or reviewing Jetpack Compose code with LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar, navigation, focus requests, analytics, or event Flow collection. Technique-layer skill — complements the codebase-specific compose-expert. +--- + +# Compose: side effects + +## Core principle + +Composable bodies describe UI. They can be recomposed, skipped, or abandoned. Work that changes the outside world belongs in an effect API whose lifecycle matches the work. + +## Pick the smallest effect + +| Need | API | +|---|---| +| Publish Compose state to non-Compose code after every successful recomposition | `SideEffect` | +| Register/unregister a listener, callback, observer, or resource | `DisposableEffect(keys...)` | +| Run suspending, deferred, or keyed one-shot work | `LaunchedEffect(keys...)` | +| Launch suspending work from a user event callback | `rememberCoroutineScope()` | +| Convert Compose snapshot reads into a Flow inside a coroutine | `snapshotFlow { ... }` inside `LaunchedEffect` | + +## Effect keys + +Keys define restart identity. When any key changes, the old effect is cancelled/disposed and a new one starts. + +```kotlin +// ✅ Restart collection when userId changes +LaunchedEffect(userId) { + repository.events(userId).collect { event -> handle(event) } +} + +// ❌ Unit hides a changing input; collection keeps using the first userId +LaunchedEffect(Unit) { + repository.events(userId).collect { event -> handle(event) } +} +``` + +Use stable, semantic keys: + +- Use the thing whose lifecycle the effect follows: `userId`, `screenId`, `lifecycleOwner`, `focusRequester`. +- Do not use broad objects (`state`, `viewModel`) when only one property matters. +- Do not add changing lambdas as keys unless you really want restarts on every lambda change. + +## Avoid stale captures + +For long-running effects that should not restart but need the latest callback or value, use `rememberUpdatedState`. + +```kotlin +@Composable +fun Timeout(onTimeout: () -> Unit) { + val latestOnTimeout by rememberUpdatedState(onTimeout) + + LaunchedEffect(Unit) { + delay(1_000) + latestOnTimeout() + } +} +``` + +Use this when the lifecycle is "start once" but the invoked lambda should stay fresh. Common cases: + +- A timeout or splash effect should not restart when `onTimeout` changes, but it should call the latest callback. +- A lifecycle observer should stay registered to the same owner, but invoke the latest `onStart` / `onStop` lambdas. +- A long-running collector should keep its collection lifecycle, but call the latest event handler. + +Do not use `rememberUpdatedState` to avoid choosing proper keys. If the changed value should restart the work, make it a key instead: + +```kotlin +// BAD: userId changes should restart the collection, not update a captured value. +val latestUserId by rememberUpdatedState(userId) +LaunchedEffect(Unit) { + repository.events(latestUserId).collect { event -> handle(event) } +} + +// GOOD: the collection lifecycle follows userId. +LaunchedEffect(userId) { + repository.events(userId).collect { event -> handle(event) } +} +``` + +`rememberUpdatedState` also does not make render state "non-recomposing." If the UI needs to display a changing value, read normal `State` in composition or use the deferred-read patterns in [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) for frame-rate values. + +## Collecting Flow + +Use `LaunchedEffect` for **side-effect/event flows**: snackbars, navigation events, analytics events, focus commands, or other streams where each emission triggers imperative work. + +```kotlin +LaunchedEffect(events) { + events.collect { event -> + snackbarHostState.showSnackbar(event.message) + } +} +``` + +Do not collect render state imperatively just to mutate local state. For UI state, collect near the state holder and pass plain values into the UI composable—the **state-holder vs UI split**, `collectAsStateWithLifecycle()` / `collectAsState()`, and preview-friendly wiring are covered in [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md). Do not duplicate that architecture here. + +On Android, prefer lifecycle-aware collection where available; use `collectAsState()` on targets without lifecycle-aware APIs. + +For Compose state reads, use `snapshotFlow`: + +```kotlin +LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { index -> analytics.visibleIndex(index) } +} +``` + +`snapshotFlow { ... }.map { ... }` without a terminal `collect` does nothing. + +## User events + +Use `rememberCoroutineScope()` when a click or gesture starts suspending work: + +```kotlin +@Composable +fun SaveButton(snackbarHostState: SnackbarHostState) { + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + snackbarHostState.showSnackbar("Saved") + } + }, + ) { + Text("Save") + } +} +``` + +Avoid "event flag" state just to trigger a `LaunchedEffect`. The click already is the event. + +## Registration and cleanup + +Use `DisposableEffect` for paired setup/teardown: + +```kotlin +@Composable +fun ObserveLifecycle(owner: LifecycleOwner, observer: LifecycleObserver) { + DisposableEffect(owner, observer) { + owner.lifecycle.addObserver(observer) + onDispose { + owner.lifecycle.removeObserver(observer) + } + } +} +``` + +Every registration path should have a matching `onDispose` cleanup path. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Network request directly in the composable body | Usually move to a ViewModel/state holder; use `LaunchedEffect` only for UI-owned keyed work | +| Analytics property written from the composable body | Use `SideEffect` when it should publish after every successful recomposition | +| Impression/event logged from the composable body | Use `LaunchedEffect(key)` when it should run once for that key | +| `LaunchedEffect(Unit)` captures changing `id` | Key by `id`, or use `rememberUpdatedState` if it must not restart | +| `rememberUpdatedState(id)` used so `LaunchedEffect(Unit)` keeps running after `id` changes | Hidden lifecycle bug | Key the effect by `id` | +| Long-lived effect invokes an old callback after recomposition | Stale capture | Wrap the callback with `rememberUpdatedState` and call the wrapper inside the effect | +| `LaunchedEffect(state) { ... }` restarts too often | Key by the specific property | +| `LaunchedEffect(...) { nonSuspendSetter() }` | Usually `SideEffect`; keep `LaunchedEffect` only for keyed one-shot/deferred work | +| Listener added in `LaunchedEffect` with no cleanup | Use `DisposableEffect` | +| Launching from click by setting `shouldShowSnackbar = true` | Use `rememberCoroutineScope()` in the click callback | + +## Red flags during review + +- "This only runs once" about code in a composable body. +- `LaunchedEffect(Unit)` in a function with changing parameters. +- A flow chain inside an effect with no terminal collection. +- Effects whose keys are chosen to silence lint instead of model lifecycle. +- Callback lambdas used from long-lived effects without either a key or `rememberUpdatedState`. diff --git a/.claude/skills/compose-slot-api-pattern/SKILL.md b/.claude/skills/compose-slot-api-pattern/SKILL.md new file mode 100644 index 0000000000..85296cf5ff --- /dev/null +++ b/.claude/skills/compose-slot-api-pattern/SKILL.md @@ -0,0 +1,198 @@ +--- +name: compose-slot-api-pattern +description: Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. Technique-layer skill — complements the codebase-specific compose-expert. +--- + +# Compose: slot API pattern + +## Core principle + +A reusable Compose component's job is to lay things out, not to enumerate what it lays out. The moment you write `title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …`, the component has stopped describing a layout and started enumerating call sites — and the next call site will need a parameter the component doesn't have. + +The fix is to **delegate content to the caller** via `@Composable` lambda parameters. The component contributes structure (where the leading bit, headline, supporting bit, trailing bit go). The caller contributes everything that goes *in* those slots. + +Material 3's `ListItem` is the canonical example: every visual piece is a slot (`headlineContent`, `supportingContent`, `leadingContent`, `trailingContent`, `overlineContent`), not a primitive. That's not over-engineering — it's the design that scales to every list-item shape the design system needs without ever editing `ListItem` again. + +## When to use this skill + +You're designing or reviewing a Compose component intended for reuse (more than one call site, now or planned), its visual content varies by caller, and any of these is true: + +- Its signature has `title: String`, `icon: ImageVector`, `actionText: String?`, etc. — primitive types describing *content*. +- It has multiple optional-content parameters that vary by call site (`subtitle: String?`, `leadingIcon: ImageVector?`, `trailingText: String?`). +- It has boolean flags whose only purpose is to switch between content shapes (`showChevron: Boolean`, `showSwitch: Boolean`, `mode: Mode.Text | Mode.Switch | …`). +- It accepts a `String` parameter where one caller would want a `Text` with custom style, a second caller a `Text` with a `Badge`, a third caller a row of icons. +- It already has *one* slot (often `trailing` or `content`) and the rest of the parameters are still primitives. + +## 1. Replace primitive content with `@Composable` slots + +Where the component asks for caller-controlled *content*, prefer a `@Composable () -> Unit` slot. Where the slot is structurally required, leave it non-nullable with no default. Where it's optional, make it nullable with a `null` default. + +```kotlin +// ❌ BAD — primitive parameters; trailing area is the only slot; everything else is locked +@Composable +fun SettingsRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + leadingIcon: ImageVector? = null, + trailing: (@Composable () -> Unit)? = null, +) { … } +``` + +This shape *seems* fine because the call sites today fit (`title` is always single-line text, `leadingIcon` is always an `ImageVector`). The problem is the *next* call site: a row with a `Badge` next to the title, a leading slot that's a circular avatar (not an `ImageVector`), a subtitle that's a row of chips. Each forces either a new parameter, a new flag, or a workaround. + +```kotlin +// ✅ GOOD — every visual region is a slot; the row describes structure, not content +@Composable +fun SettingsRow( + headlineContent: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, +) { … } +``` + +Call sites stay short because the typical content is a one-liner: + +```kotlin +SettingsRow( + headlineContent = { Text("Account") }, + leadingContent = { Icon(Icons.Default.Person, contentDescription = null) }, + trailingContent = { SettingsRowDefaults.Chevron() }, + onClick = { … }, +) +``` + +And the awkward cases that *would* have required new primitive parameters now don't: + +```kotlin +SettingsRow( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Inbox") + Spacer(Modifier.width(8.dp)) + Badge { Text("3") } + } + }, + onClick = { … }, +) +``` + +### Slot naming + +- Use `xxxContent` for free-form `@Composable () -> Unit` slots (`headlineContent`, `supportingContent`, `trailingContent`) — matches Material 3. +- Use a singular noun (`title`, `icon`, `actions`) when the slot is semantically constrained and the component name disambiguates (`Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })`). +- Don't use `content` *and* other `xxxContent` slots together — pick one convention per component. + +## 2. Scope receivers when the slot emits into a layout + +If the slot's content will sit inside a `Row`/`Column`/`Box` whose layout features (`Modifier.weight`, `BoxScope.matchParentSize`, alignment) should be available to the caller, declare the slot as a receiver lambda: `@Composable RowScope.() -> Unit`. + +```kotlin +// ❌ BAD — actions render inside a Row, but callers can't use RowScope.weight() +@Composable +fun MyTopBar( + title: @Composable () -> Unit, + actions: @Composable () -> Unit = {}, // ← caller has no Row scope +) +``` + +```kotlin +// ✅ GOOD — caller gets RowScope; .weight() and alignment-by works inside +@Composable +fun MyTopBar( + title: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, +) +``` + +This is what makes `TopAppBar(actions = { IconButton(…); IconButton(…) })` work — the caller is implicitly inside a `RowScope`. + +Don't bolt a scope receiver onto every slot reflexively. The receiver should match the actual parent layout the slot emits into. If the slot is rendered inside a `Box`, use `BoxScope`. If it's inside a `Column`, use `ColumnScope`. If the parent is not a standard layout (or none of its scope APIs are useful in slot content), no receiver. + +## 3. Optional slots — nullable with `null` default + +For slots that may be absent, prefer `(@Composable () -> Unit)? = null` over `@Composable () -> Unit = {}`: + +```kotlin +// ❌ BAD — empty default; "no leading content" is the empty lambda +leadingContent: @Composable () -> Unit = {} + +// ✅ GOOD — null means "no slot"; the component can omit space/padding when absent +leadingContent: (@Composable () -> Unit)? = null +``` + +Why: with a nullable slot, the *component* can branch on `leadingContent != null` and skip the slot's container, spacing, padding entirely. With an empty default, the layout still allocates the slot — sometimes you see a stray padding or spacer around content that turned out to be nothing. The nullable form makes the "absent" case structurally distinct, which is almost always what you want. + +The trade-off: callers who pass an explicit empty `{}` to silence a slot now have to pass `null` or omit the argument. That's the right answer either way — they shouldn't be passing `{}`. + +## 4. Defaults live in `XxxDefaults` + +When you find yourself documenting "the trailing slot should usually be a chevron" or "pass `MaterialTheme.colorScheme.surface` for the default background", co-locate the helpers in a `XxxDefaults` object next to the component: + +```kotlin +object SettingsRowDefaults { + @Composable + fun Chevron() = Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + + @Composable + fun TrailingValue(text: String) = Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} +``` + +Call sites stay declarative for the common cases and the slot is still fully open for one-offs: + +```kotlin +SettingsRow( + headlineContent = { Text("Notifications") }, + trailingContent = { SettingsRowDefaults.Chevron() }, + onClick = { … }, +) +``` + +This matches Material 3's `ButtonDefaults`, `TopAppBarDefaults`, etc. — defaults that are themselves composable belong here, not as new component parameters with `MaterialTheme.x.y` defaults expanded inline. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `title: String, subtitle: String?, leadingIcon: ImageVector?` on a reusable component | Primitive content params (§1) | Convert to `xxxContent: (@Composable () -> Unit)?` slots | +| Multiple boolean flags (`showChevron`, `showSwitch`) selecting trailing shapes | Enumerating shapes (§1) | One `trailingContent: (@Composable () -> Unit)?` slot | +| A `mode: Mode.Sealed` parameter listing variants | Same as flag soup (§1) | Slot it | +| `actions: @Composable () -> Unit = {}` inside a `Row` body | Missing scope receiver (§2) | `actions: @Composable RowScope.() -> Unit = {}` | +| `slot: @Composable () -> Unit = {}` for an optional area | Empty-lambda default (§3) | `slot: (@Composable () -> Unit)? = null` and branch on it | +| Component param `defaultColor: Color = MaterialTheme.colorScheme.surface` | Defaults inlined (§4) | Move to `XxxDefaults.color` and reference it | +| Common trailing content repeats at every call site | Missing default helper (§4) | Add `XxxDefaults.Chevron()` etc. | + +## When NOT to apply + +- **Single-use components.** A composable used in exactly one place, with no plan to reuse, doesn't benefit from slot flexibility — and the slot indirection makes the code harder to read for the one reader. Primitive params + inline content is fine. (As soon as a second call site appears, slot it.) +- **Design-system primitives where every caller must look identical.** A `Heading2(text: String)` exists *because* you want every H2 to look the same; making it `headlineContent: @Composable () -> Unit` invites callers to break the rule. Keep it primitive. (Conversely: if `Heading2` ever needs a badge inline, slot it.) +- **Semantic parameters the component intentionally owns.** If the component owns typography, iconography, accessibility wording, or product consistency, a primitive parameter may be the constraint you want. +- **Constrained-type parameters that genuinely are constrained.** A `Switch(checked: Boolean, onCheckedChange: ...)` doesn't need its checked indicator to be a slot. Booleans-with-callbacks are not "content." +- **Performance-critical fast paths** (rare in app code; common in framework primitives). A slot is an allocated lambda. In the deepest LazyList item layer, sometimes primitives win. If you're not writing the framework, this doesn't apply. + +## Red flags during review + +| Thought | Reality | +|---|---| +| "Title is *always* a String — making it a slot is over-engineering" | "Always today" is the trap. Material's `ListItem.headlineContent` exists because tomorrow someone wants a `Text + Badge`. The slot is `8` characters of extra wrapping at every call site (`{ Text(…) }`); the refactor to add a slot later edits every existing call site. | +| "Lambdas are heavier than strings" | At the scale of typical Compose UI, this isn't measurable — and the framework's own components (`Button`, `ListItem`, `TopAppBar`, `Scaffold`) all slot. If your component is in the hottest of hot paths, see "When NOT to apply." | +| "I'll add a slot later if someone asks" | The slot turns one parameter into two parameters (the slot itself + maybe an internal flag) and edits every call site. The shape change isn't a "later" change. | +| "I'll model the variants with a sealed `Trailing` type instead" | Sealed enumeration is bounded; slots are unbounded. A sealed type works *until* the day someone needs a variant you didn't anticipate — at which point you're back to editing the component. The slot avoids the cycle. | +| "The leading area is *always* an icon, the trailing area varies — I'll slot only the trailing" | This is the partial-slot trap. The "always-an-icon" assumption breaks the first time a row needs an avatar or a flag emoji or a coloured shape. Slot leading too. | +| "There's only one call site today" | If there's only one call site, you're probably not designing a reusable component yet. See "When NOT to apply" — primitives are fine for a true single-use. The moment you copy-paste it, slot it. | + +## Related + +- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) — the modifier-parameter rule (§1–§3 there) travels with slot APIs. A reusable component takes a `modifier` parameter *and* slots its content; the caller owns both placement and what to place. diff --git a/.claude/skills/compose-stability-diagnostics/SKILL.md b/.claude/skills/compose-stability-diagnostics/SKILL.md new file mode 100644 index 0000000000..246cfd3907 --- /dev/null +++ b/.claude/skills/compose-stability-diagnostics/SKILL.md @@ -0,0 +1,140 @@ +--- +name: compose-stability-diagnostics +description: Use when writing or reviewing Jetpack Compose parameter stability, compiler reports, skippability, unstable UI state classes, collection parameters, or Kotlin 2.0+ strong skipping behavior. Technique-layer skill — complements the codebase-specific compose-expert and kotlin-expert. +--- + +# Compose stability diagnostics + +## Core principle + +Compose performance problems from parameters are about **whether inputs compare cheaply and predictably across recompositions**. With Kotlin 2.0.20+ strong skipping is enabled by default, so unstable parameters no longer automatically make restartable composables non-skippable. That does not make stability irrelevant: unstable parameters are compared by instance identity (`===`), stable parameters by equality (`equals`), and churny instances can still defeat skipping. + +First identify the compiler mode you are on, then read reports in that context. + +## When to use this skill + +- A composable or screen recomposes more than expected and parameter churn is suspected. +- A UI-state/model class is passed to composables and contains `List`, `Set`, `Map`, ranges, Java time/money types, or third-party types. +- `composables.txt` / `classes.txt` shows unstable parameters or non-skippable composables. +- A project uses Kotlin < 2.0.20, disables strong skipping, or has old Compose compiler report guidance. + +## 1. Start with strong skipping + +On Kotlin 2.0.20+, strong skipping is enabled by default. In that mode: + +- Restartable composables are skippable even when parameters are unstable, unless explicitly opted out. +- Stable parameters compare with `equals`. +- Unstable parameters compare with instance equality (`===`). +- Lambdas inside composables are automatically remembered based on captures. + +That means the question changes from "is this composable skippable at all?" to "will these parameters compare the way I expect, and are callers creating new unstable instances every frame?" + +For older compiler setups or strong skipping disabled, the legacy rule still matters: a restartable composable with unstable parameters may be restartable but not skippable. + +## 2. Generate compiler reports + +With Kotlin 2.0+ the Compose Compiler is configured through the Kotlin Gradle plugin: + +```kotlin +plugins { + alias(libs.plugins.android.application) // or android.library / jvm + alias(libs.plugins.kotlin.android) // or kotlin.multiplatform / kotlin.jvm + alias(libs.plugins.compose.compiler) +} + +if (providers.gradleProperty("composeReports").orNull == "true") { + composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") + } +} +``` + +Then build the variant whose compiler configuration you care about, for example: + +```bash +./gradlew :app:assembleRelease -PcomposeReports=true +``` + +Use release/non-debuggable builds for runtime profiling. Compiler reports are build-time outputs, so the important thing is matching the variant and compiler flags you ship. + +Key files: + +| File | What it tells you | +|---|---| +| `-classes.txt` | Stability of classes and properties | +| `-composables.txt` | Restartable/skippable status and parameter stability | +| `-composables.csv` | Same data in sortable form | +| `-module.json` | Aggregate metrics | + +## 3. Fix stability where semantics need it + +Pick the lightest fix that makes the type's immutability or equality semantics true. + +### Immutable collections + +`kotlin.collections.List` is an interface; Compose cannot know the runtime implementation is immutable. Prefer `kotlinx.collections.immutable` at UI-state boundaries: + +```kotlin +// Before: unstable collection interfaces +data class UiState(val items: List, val tags: Set) + +// After: immutable collection contracts +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class UiState(val items: ImmutableList, val tags: ImmutableSet) +``` + +Producers convert once at the boundary with `.toImmutableList()` / `.toImmutableSet()`. + +### `@Immutable` / `@Stable` + +- Use `@Immutable` when every property is effectively immutable and equality describes all observable state. +- Use `@Stable` for types whose mutable state is observable by Compose, typically via `MutableState`. + +Do not annotate to silence a report. A false stability promise can produce stale UI. + +### Third-party immutable types + +For types you cannot annotate, use `stabilityConfigurationFiles`: + +```kotlin +composeCompiler { + stabilityConfigurationFiles.add( + rootProject.layout.projectDirectory.file("compose_stability.conf"), + ) +} +``` + +```text +java.math.BigDecimal +java.math.BigInteger +java.time.* +kotlinx.datetime.* +``` + +Only list types you are willing to promise are immutable. Do not list mutable types such as `java.util.Date`. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| Kotlin 2.0.20+ but old docs say unstable means non-skippable | Strong skipping changed the default | Check comparison semantics and instance churn instead | +| `unstable val items: List` | Interface collection | Use `ImmutableList` or another true immutable wrapper | +| `unstable val price: BigDecimal` | External immutable type | Add to stability config | +| `@Immutable` on a type with mutable internals | False promise | Fix the model or remove the annotation | +| Composable skips poorly despite strong skipping | New unstable instance each recomposition | Remember, hoist, or make the type stable/equality-based | +| Reports not generated | Compose compiler plugin missing or flag not set | Apply `org.jetbrains.kotlin.plugin.compose` and enable destinations | + +## When NOT to apply + +- The issue is a fast-changing `State` read in composition, such as scroll or animation. Use `compose-state-deferred-reads`. +- The recomposition count matches real data changes. +- The bug is wrong data or stale state, not excess work. +- The code is test-only and readability is more important than report cleanliness. + +## Related + +- [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) - frame-rate state should often be read in layout/draw rather than composition. +- [`compose-recomposition-performance`](../compose-recomposition-performance/SKILL.md) - entry point when you are not sure which recomposition axis is involved. diff --git a/.claude/skills/compose-state-deferred-reads/SKILL.md b/.claude/skills/compose-state-deferred-reads/SKILL.md new file mode 100644 index 0000000000..e385e1f99e --- /dev/null +++ b/.claude/skills/compose-state-deferred-reads/SKILL.md @@ -0,0 +1,141 @@ +--- +name: compose-state-deferred-reads +description: Use when Jetpack Compose code reads scroll, animation, gesture, or other frame-rate State in composition, passes changing values across composable boundaries, or uses value-form layout/draw modifiers. Technique-layer skill — complements the codebase-specific compose-expert. +--- + +# Compose state deferred reads + +## Core principle + +State reads invalidate the phase that reads them. If a `State` is read in a composable body, changes invalidate composition. If it is read in layout or draw, changes can invalidate only layout or draw. Frame-rate state such as scroll offsets, animations, and drag positions usually belongs in layout/draw, not composition. + +The fix is structural: keep the `State` or a provider lambda, and read the value inside a layout/draw callback. + +## When to use this skill + +- `val x by animate*AsState(...)` is passed to `Modifier.offset(x = ...)`, `Modifier.size(...)`, `Modifier.graphicsLayer(...)`, or another value-form modifier. +- `LazyListState.firstVisibleItemScrollOffset`, `ScrollState.value`, `Animatable.value`, or gesture state is read in a composable body. +- A composable takes `scrollOffset: Int`, `progress: Float`, `dragOffset: Offset`, or similar frame-rate values. +- Recomposition counters climb during scroll, animation, or gestures even when data is stable. + +## 1. Prefer block-form modifiers + +Several modifiers have value forms and block forms. The value form receives values already read in composition; the block form can read during layout or draw. + +```kotlin +// Before: animated value read in composition by the `by` delegate +@Composable +fun SelectionPill(selectedIndex: Int) { + val offsetX by animateDpAsState(120.dp * selectedIndex) + Box(Modifier.offset(x = offsetX)) +} + +// After: State is kept, value is read in the layout-phase offset block +@Composable +fun SelectionPill(selectedIndex: Int) { + val offsetX = animateDpAsState(120.dp * selectedIndex) + Box( + Modifier.offset { + IntOffset(offsetX.value.roundToPx(), 0) + }, + ) +} +``` + +Common replacements: + +| Composition read | Deferred read | +|---|---| +| `Modifier.offset(x = animatedX)` | `Modifier.offset { IntOffset(animatedX.value.roundToPx(), 0) }` | +| `Modifier.graphicsLayer(translationY = y)` | `Modifier.graphicsLayer { translationY = yProvider() }` | +| `val radius by animateFloatAsState(...); drawBehind { drawCircle(radius = radius) }` | `val radius = animateFloatAsState(...); drawBehind { drawCircle(radius = radius.value) }` | + +The `drawBehind` block is already draw-phase; the important part is that the `State.value` read also happens inside that block. + +## 2. Pass providers across composable boundaries + +If the fast-changing value would cross a composable boundary, pass a provider lambda instead of a snapshot value: + +```kotlin +// Before: HomeScreen reads scroll offset in composition and passes the value down +@Composable +fun HomeScreen() { + val listState = rememberLazyListState() + LazyColumn(state = listState) { + item { HeroImage(scrollOffset = listState.firstVisibleItemScrollOffset) } + } +} + +@Composable +fun HeroImage(scrollOffset: Int, modifier: Modifier = Modifier) { + AsyncImage( + model = "...", + modifier = modifier.graphicsLayer(translationY = -scrollOffset / 2f), + ) +} + +// After: the only read happens inside graphicsLayer +@Composable +fun HomeScreen() { + val listState = rememberLazyListState() + LazyColumn(state = listState) { + item { + HeroImage( + scrollOffsetProvider = { + if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset + } else { + 0 + } + }, + ) + } + } +} + +@Composable +fun HeroImage(scrollOffsetProvider: () -> Int, modifier: Modifier = Modifier) { + AsyncImage( + model = "...", + modifier = modifier.graphicsLayer { + translationY = -scrollOffsetProvider() / 2f + }, + ) +} +``` + +Suffix provider parameters with `Provider` when that clarifies the deferred-read contract. + +## 3. Other layout/draw read sites + +State reads can also be deferred inside: + +- `Modifier.layout { measurable, constraints -> ... }` +- Custom `Alignment.align(...)` +- `drawWithContent`, `drawBehind`, and other draw modifiers +- Block-form layer/layout modifiers such as `graphicsLayer { ... }` and `offset { ... }` + +Use these when the state changes where something is placed or painted. If the state decides *which composables exist*, it belongs in composition. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `val x by animateFloatAsState(...)` then `Modifier.offset(...)` | `by` reads in composition | Keep `State` and read `.value` in `offset {}` | +| `Modifier.graphicsLayer(translationY = animatedY)` | Property-argument form uses composition values | Use `graphicsLayer { translationY = ... }` | +| `Child(scrollOffset = listState.firstVisibleItemScrollOffset)` | Fast-changing value crosses boundary | `Child(scrollOffsetProvider = { ... })` | +| Draw block still recomposes every frame | Value was read before draw block | Move the `State.value` read inside the draw block | +| State chooses between different UI branches | Composition decision | Keep the read in composition | + +## When NOT to apply + +- The state controls which composables are emitted. +- The animation is one-shot, cheap, and clarity wins. +- You are writing tests where direct value assertions are simpler. +- Runtime evidence shows recomposition is not the bottleneck. + +## Related + +- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) - where state-holder vs plain UI split applies when passing providers/lambdas across boundaries. +- [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) - parameter stability and compiler reports. +- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) - child composables need a normal `modifier` parameter before callers can move visual reads into modifiers. diff --git a/.claude/skills/compose-state-holder-ui-split/SKILL.md b/.claude/skills/compose-state-holder-ui-split/SKILL.md new file mode 100644 index 0000000000..aceca49701 --- /dev/null +++ b/.claude/skills/compose-state-holder-ui-split/SKILL.md @@ -0,0 +1,157 @@ +--- +name: compose-state-holder-ui-split +description: Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. Technique-layer skill — complements the codebase-specific compose-expert and feed-patterns. +--- + +# Compose: state holder/UI split + +## Core principle + +Separate state-holder wiring from UI rendering. The state-holder composable talks to ViewModels, components, flows, navigation, and side effects. The UI composable takes plain immutable UI state plus callbacks and describes layout. + +This keeps screens previewable, testable, and easier to reuse across Android, Desktop, TV, and KMP/CMP targets. + +## When to use this skill + +Use this when a Compose screen: + +- Takes a ViewModel, component, controller, navigator, repository, or service directly. +- Collects app/business state or side effects in the same function that lays out most UI. +- Passes a whole state holder into child composables instead of explicit state and callbacks. +- Is hard to preview because it needs dependency injection, navigation, lifecycle, or fake services. +- Has UI tests that must construct a full app stack to verify a simple layout branch. + +## The pattern + +Use a small public state-holder composable: + +```kotlin +@Composable +fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) { + val state by component.state.collectAsStateWithLifecycle() + + ProfileScreen( + state = state, + onNameChange = component::onNameChange, + onSaveClick = component::save, + onBackClick = component::back, + modifier = modifier, + ) +} +``` + +Then put UI in a plain composable that knows nothing about the state holder: + +```kotlin +@Composable +fun ProfileScreen( + state: ProfileUiState, + onNameChange: (String) -> Unit, + onSaveClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ProfileContent( + name = state.name, + isSaving = state.isSaving, + canSave = state.canSave, + onNameChange = onNameChange, + onSaveClick = onSaveClick, + onBackClick = onBackClick, + modifier = modifier, + ) +} +``` + +Private content functions can break up layout: + +```kotlin +@Composable +private fun ProfileContent( + name: String, + isSaving: Boolean, + canSave: Boolean, + onNameChange: (String) -> Unit, + onSaveClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // Layout only. +} +``` + +## Rules of thumb + +| Concern | State-holder composable | UI composable | +|---|---|---| +| Collect ViewModel/component state | Yes | No | +| Collect one-shot effects | Yes, or a tiny sibling effect handler | Usually no | +| Hold dependency-injected objects | Yes | No | +| Accept immutable UI state | Usually passes it through | Yes | +| Accept lambdas for user events | Wires them | Calls them | +| Own layout, modifiers, semantics, test tags | No/minimal | Yes | +| Own UI-local state like scroll, focus, text input, animation, interaction | Sometimes seeds it | Yes | +| Preview/screenshot friendly | Not necessarily | Yes | + +The "no collection in UI composables" rule is about app/business state and side-effect streams. Plain UI composables can still own UI-local framework state: `rememberScrollState`, `rememberLazyListState`, `FocusRequester`, focus state, animation state, `TextFieldState`, `MutableInteractionSource.collectIsPressedAsState()`, and similar behavior that belongs to the rendered widget. + +If that UI-local state grows into coordinated behavior with multiple related fields and operations, consult `compose-expert` (state hoisting section) to decide whether it should become a plain state holder class remembered in composition. + +## What to pass + +Pass the smallest useful UI contract: + +- Prefer a dedicated `UiState`/`State` object over many unrelated primitives when the screen has real state. +- Prefer explicit lambdas (`onRetryClick`, `onItemSelected`) over passing a whole component. +- Keep domain models out of the UI composable if they force business rules into UI. Map to UI models when the UI needs a different shape. +- Keep navigation as callbacks. The UI composable says "user clicked back", not "navigate to route X". +- Frame-rate or UI-local values that should not force whole-tree recomposition when they change: prefer provider lambdas and deferred reads per [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md). + +## Side effects + +[`compose-side-effects`](../compose-side-effects/SKILL.md) covers effect APIs (`LaunchedEffect`, `DisposableEffect`, `SideEffect`), keys, cleanup, and `rememberUpdatedState`. + +Handle effects near the state holder, where the effect source and imperative target are both available: + +```kotlin +@Composable +fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) { + val state by component.state.collectAsStateWithLifecycle() + + LaunchedEffect(component) { + component.effects.collect { effect -> + when (effect) { + ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved") + } + } + } + + ProfileScreen(state = state, onSaveClick = component::save) +} +``` + +If effect handling grows, extract `ProfileEffects(component, snackbarHostState)` rather than pushing the component into the UI composable. + +## Common mistakes + +| Mistake | Why it hurts | Fix | +|---|---|---| +| `fun Screen(viewModel: MyViewModel)` contains all layout | Hard to preview/test without Android lifecycle and DI | Add a plain UI overload that takes `state` and callbacks | +| Child composables take `component` | Dependencies leak through the tree | Pass only the state/callbacks that child needs | +| UI composable launches navigation | UI becomes coupled to app routing | Expose `onBackClick`, `onItemClick`, etc. | +| UI composable collects app/business flows | Collection lifecycle is hidden in layout | Collect near the state holder and pass values down | +| UI-local state is hoisted into the state holder for no reason | State holder starts owning layout mechanics | Keep scroll/focus/animation/text-field interaction state in the UI composable when it is only UI behavior | +| Every tiny composable gets a state-holder overload | Too much ceremony | Split at screen/section boundaries, not every `Row` | + +## When NOT to apply + +- Tiny one-off composables that already take plain values and callbacks. +- Design-system primitives such as `Button`, `Card`, or `ListItem`; those should expose slots and modifiers, not state holders. +- Cases where the state-holder composable would only forward one primitive and add no isolation. + +## Related + +- [`compose-expert`](../compose-expert/SKILL.md) — Amethyst's shared-UI patterns, including state hoisting for UI element state and plain state holder classes. +- [`compose-side-effects`](../compose-side-effects/SKILL.md) — effect keys and cleanup in Compose. +- [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) — deferred reads for frame-rate / UI-local values passed across boundaries. +- [`kotlin-multiplatform`](../kotlin-multiplatform/SKILL.md) — platform services, native views, and expect/interface boundaries when shared UI meets platform-specific leaves. diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 9417851c41..411f4d0233 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -8,13 +8,13 @@ │ (Amethyst) │ └─────────────────────────────────────────────────────────┘ │ - ┌────────────────┼────────────────┬────────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ -│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │ -│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│ -└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘ + ┌────────────────┼────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ :amethyst │ │ :desktopApp │ │ :benchmark │ +│ (Android) │ │ (JVM) │ │ (Android) │ +└─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ │ └────────────────┼────────────────┘ @@ -77,7 +77,7 @@ **Type:** Android Application **Targets:** Android **Dependencies:** -- Modules: `:commons`, `:quartz`, `:ammolite` +- Modules: `:commons`, `:quartz`, `:nestsClient` - External: Android SDK, AndroidX, Firebase, Tor **Role:** Android-specific navigation, layouts, and entry point @@ -91,13 +91,6 @@ **Role:** Performance benchmarking for Android builds -### :ammolite (Support Module) -**Type:** Android Library -**Targets:** Android -**Dependencies:** Android-specific utilities - -**Role:** Android support utilities for amethyst - ## Dependency Flow Patterns ### Desktop Build Chain @@ -112,10 +105,10 @@ ### Android Build Chain ``` :amethyst → :commons (androidMain) → :quartz (androidMain) - ↓ ↓ -:ammolite jvmAndroid - ↓ - commonMain + ↓ + jvmAndroid + ↓ + commonMain ``` ## Source Set Dependencies diff --git a/.claude/skills/kotlin-coroutines-structured-concurrency/SKILL.md b/.claude/skills/kotlin-coroutines-structured-concurrency/SKILL.md new file mode 100644 index 0000000000..410c7d9fc4 --- /dev/null +++ b/.claude/skills/kotlin-coroutines-structured-concurrency/SKILL.md @@ -0,0 +1,441 @@ +--- +name: kotlin-coroutines-structured-concurrency +description: Use when writing or reviewing Kotlin code that stores CoroutineScope, launches from init/non-suspending APIs, calls runBlocking, or catches broad exceptions around suspend calls. Technique-layer skill — complements the codebase-specific kotlin-coroutines. +--- + +# Kotlin coroutines: structured concurrency + +## Core principle + +A well-structured coroutine is a self-contained unit of asynchronous work — single entry, single exit, scoped to a lifecycle known at the call site. + +**Scopes should usually be tied to the caller's lifecycle, not stored as a property on the callee.** A stored `CoroutineScope` is a strong review signal: the class must prove it owns cancellation, error reporting, restart behavior, and lifecycle. Most repositories, managers, use cases, and data sources cannot prove that, so they should expose `suspend` APIs instead. + +The fix is almost always the same: **make the API `suspend` and let the caller own the scope.** + +## When to use this skill + +You're writing or reviewing Kotlin code and you see any of these: + +- A class with `private val scope: CoroutineScope` (constructor param stored as a property) +- An `init { scope.launch { ... } }` block +- A non-suspending public function whose body is `scope.launch { ... }` +- `runBlocking { ... }` in suspend-capable application code, or in tests where `runTest` should apply +- `runCatching { suspendCall() }` or a `catch` on `Exception` / `Throwable` around a `suspend` call without rethrowing `CancellationException` +- A `catch (e: CancellationException)` (or equivalent) around suspension that does not rethrow + +## The silent-cancellation bug + +The reason an unowned `CoroutineScope` property is so dangerous: "once a scope is cancelled, every future `launch` on it silently completes as cancelled — no exception, no log, nothing." The work just doesn't happen. This is one of the hardest coroutine bugs to diagnose, and it appears when a class holds a long-lived reference to a lifecycle it does not own. + +If APIs are `suspend`, this can't happen: the caller's scope is either alive (work runs) or the call site cancels (the caller knows). + +## Anti-patterns and fixes + +### 1. CoroutineScope stored as a property + +```kotlin +// ❌ BAD +@Inject +class UserRepository( + private val scope: CoroutineScope, + private val api: UserApi, +) { + fun refresh() { + scope.launch { _state.value = api.fetchUser() } + } +} + +// ✅ GOOD +@Inject +class UserRepository( + private val api: UserApi, +) { + suspend fun refresh(): User = api.fetchUser() +} +``` + +The repository no longer needs to know about coroutines at all. The caller (a ViewModel, a use case) decides on what scope, with what error handling, with what cancellation semantics. + +### 2. init-block launches + +```kotlin +// ❌ BAD: construction-time side effect, unbounded work +class UserSession(private val scope: CoroutineScope, private val api: Api) { + init { scope.launch { _user.value = api.load() } } +} +``` + +The constructor returns immediately. The caller can't `await` the load, can't see errors, can't cancel. The class is "alive" but its state is undefined. + +```kotlin +// ✅ GOOD: explicit bootstrap, caller owns the suspension +class UserSession(private val api: Api) { + private var _user: User? = null + val user: User get() = checkNotNull(_user) { "Call init() first" } + + suspend fun init() { _user = api.load() } +} +``` + +### 3. Fire-and-forget from non-UI classes + +A non-suspending public function on a **non-UI class** (repository, manager, use case, data source) that launches into a class-owned scope. The caller gets no result, no error, no cancellation, and no guarantee the work ever ran. + +```kotlin +// ❌ BAD — repository with stored scope and fire-and-forget public API +class AnalyticsClient(private val scope: CoroutineScope, private val api: Api) { + fun track(event: Event) { + scope.launch { api.send(event) } // caller has no idea what happens + } + fun signOut() { + scope.launch { api.signOut() } // silent failure if scope cancelled + } +} +``` + +```kotlin +// ✅ GOOD +class AnalyticsClient(private val api: Api) { + suspend fun track(event: Event) = api.send(event) + suspend fun signOut() = api.signOut() +} +``` + +#### Carve-out: the UI ↔ state-holder boundary + +UI frameworks are non-suspending. A Composable's `onClick`, a Fragment's `onKeyEvent`, an Activity's `onNewIntent` — none can `suspend`. The state holder (ViewModel, Decompose Component, feature model, etc. — anything whose role is to absorb UI events and hold UI state) **is** the boundary that translates one-shot UI events into asynchronous work bound to the UI lifecycle. That's its job. + +```kotlin +// ✅ GOOD — state holder absorbs a non-suspending UI event onto its scope +class FavouritesViewModel(private val repo: FavouritesRepository) : ViewModel() { + fun onToggleFavourite(item: Item) { + viewModelScope.launch { repo.toggleFavourite(item) } + } +} + +// in Compose: +ListItem(onClick = { viewModel.onToggleFavourite(item) }) +``` + +This is **not** the fire-and-forget anti-pattern. All three conditions must hold: + +1. **State holder for a UI surface** — a ViewModel, Decompose Component, feature model, or equivalent UI state holder. Not a repository, manager, use case, or data source. +2. **Lifecycle-bound scope** — `viewModelScope`, a Component's `coroutineScope` that's cancelled on destroy, a Composable's `rememberCoroutineScope()`. Not `AppScope`, not an injected long-lived scope, not an ad-hoc `CoroutineScope(...)`. +3. **Caller really is a UI event** — Composable callback, key handler, lifecycle hook. Not another business-logic class calling through the state holder. + +The repository / use case / data source layers underneath still expose `suspend` APIs. The state holder is the *only* layer where the non-suspending → suspending translation belongs. + +"It feels like a state holder" isn't enough. The question is "does the UI directly bind to this?" If no, the carve-out doesn't apply. + +### 4. Stored scopes that aren't injected + +The same anti-pattern, without an injected scope: + +```kotlin +// ❌ BAD — same problem, scope is constructed in-class instead of injected +class FooManager { + private val scope = MainScope() + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) +} +``` + +Lifecycle is now owned by nothing and lives forever. Replace with `suspend` APIs. + +The same is true if the instantiation is nested inside a function body — `fun foo() { CoroutineScope(...).launch { … } }` is just a stored scope with extra steps. Each call leaks a new uncancellable scope; bundling it into a `by lazy` property doesn't fix the underlying issue (the scope shouldn't exist at all). + +### 5. DI-bound singletons / initializers that launch + +A specific pattern that is hard to spot: a DI-bound class (`@SingleIn(AppScope)`, `@Singleton`, an `Initializer.initialize()`) launches a coroutine from its constructor / `init` block / `initialize()`. The launched work then has: + +- **A non-deterministic start time** — whenever the graph realizes the binding. Cold-start ordering is invisible. +- **No observable lifecycle.** Nothing else in the codebase can see whether it's running or has crashed. +- **No `stop()` / restart path.** If upstream enters a bad state, the loop is uncancellable. +- **No calling code to grep for.** Readers can't find "who starts this and when". + +§1 says scopes should be tied to the caller's lifecycle. The DI-bound variant violates this indirectly: the *scope* may be injected, but the *launch* is hidden inside construction — same effect, harder to see. + +```kotlin +// ❌ BAD — singleton boots work as a side effect of being constructed +@SingleIn(AppScope::class) +@Inject +class TokenRefresher( + @ForScope(AppScope::class) private val scope: CoroutineScope, + private val auth: AuthService, +) { + init { + scope.launch { + while (isActive) { + delay(5.minutes) + auth.refreshIfNeeded() + } + } + } +} + +// ❌ ALSO BAD — Initializer.initialize() that *launches*, not just registers +class TokenInvalidatorInitializer @Inject constructor( + @ForScope(AppScope::class) private val scope: CoroutineScope, + private val store: AuthStore, + private val invalidator: TokenInvalidator, +) : Initializer { + override fun initialize() { + scope.launch { store.tokenChanges.collect { invalidator.invalidate() } } + } +} +``` + +Both look like "application-scoped singletons", but the **When NOT to apply** carve-out is *not* permission to launch from `init` / `initialize()`. It's permission for a singleton to own a scope when its API is suspending. + +#### First ask: does this background-loop class need to exist at all? + +Most background-loop classes exist only because no one inverted the observation. Three answers, in order of preference: + +**Pattern 1 — invert into the consumer.** The class observes state forever to react when it changes. But *someone* mutates the state — sign-out flow, profile switch, flag-update handler. That mutation site is already in a coroutine context and is the natural place to do the work directly. + +```kotlin +// ✅ GOOD — no background loop, no scope, no class. The mutation site does the work. +class Authenticator( + private val authStore: AuthStore, + private val tokenInvalidator: TokenInvalidator, +) { + suspend fun signOut() { + authStore.clearTokens() + tokenInvalidator.invalidate() // direct call at the mutation site + } +} +``` + +The background-loop class is **deleted**. The work happens where the state changes. + +When this applies: the consumer of the state has a clear lifecycle (a use case, an Authenticator, a service handler) and can perform the reaction inline. + +**Pattern 2 — scheduled work.** Genuinely periodic or deferred. Use WorkManager / BGTaskScheduler. The enqueue is one-shot; make it suspending and call it once from an orchestrator that already runs at startup. + +**Pattern 3 — explicit named launch site.** Sometimes the consumer is a synchronous API with no observable lifecycle (e.g., OpenTelemetry's `Sampler.shouldSample(...)`, an AIDL stub fanout, a broadcast receiver bridge). The observation has to live somewhere coroutine-aware, but it must live at an *explicit named call site* — not in the class's own `init`. + +```kotlin +// ✅ GOOD — work is named; an explicit call site owns the launch +@SingleIn(AppScope::class) +class OtelConfigurableSampler(...) : Sampler { + @Volatile private var delegate: Sampler = ... + + suspend fun observeRate(featureFlags: FeatureFlags) { + featureFlags.observe(OTEL_SAMPLING_RATE).collect { rate -> + delegate = Sampler.traceIdRatioBased(rate.coerceIn(0.0, 1.0)) + } + } + + override fun shouldSample(...) = delegate.shouldSample(...) +} + +// wired explicitly at the OTel SDK init module: +applicationScope.launch { otelSampler.observeRate(featureFlags) } +``` + +When this applies: the consumer is a synchronous API that calls *into* you with no observable lifecycle. The launch can't be invertible, but it must still be visible at a named call site. + +#### Test for which pattern fits + +"Is the consumer's lifecycle observable to me?" + +- **Yes, and they're already in a coroutine context** → Pattern 1. Push the subscription into them; delete the background-loop class. +- **The work is periodic / deferred** → Pattern 2. Suspend enqueue called once. +- **No, they're a synchronous API with no observable lifecycle** → Pattern 3. Explicit launch site, not `init`. + +If a fourth answer seems to fit — e.g., "I want a `Bootable` interface that launches everything for me" — that's the same anti-pattern with an extra layer of abstraction. The whole point is that launches be *visible*; auto-discovery by interface defeats it. + +#### Initializers are still fine — *if they only register* + +The `Initializer` pattern is correct when `initialize()` *registers* a listener or hook. The bug is when `initialize()` *launches* a coroutine. + +```kotlin +// ✅ GOOD Initializer — registers a contributor, doesn't launch +class FavouritesContributorInitializer @Inject constructor( + private val registry: ContributorRegistry, + private val favouritesContributor: FavouritesContributor, +) : Initializer { + override fun initialize() { + registry.register(favouritesContributor) + } +} +``` + +**`Initializer.initialize()` must not `launch` a coroutine.** If yours does, it's a Pattern 1/2/3 candidate. + +#### Diagnostic for review + +- Where is the start moment defined? If "wherever DI realizes me", bad. +- Who can observe whether the work is running? If "no one", bad. +- Who can stop or restart it? If "no one", bad. +- Can a reader grep for the launch site? If no, bad. + +If the answers are "the consumer / the orchestrator / the named call site" — you're good. + +### 6. Swallowing `CancellationException` + +A `catch` clause around a `suspend` call that matches `CancellationException` — directly, or through `Exception` / `Throwable` — and doesn't rethrow usually turns cancellation into silent success. The parent coroutine thinks the child finished; the child keeps running (or its side effects do); the cancellation contract is broken. + +Same failure shape as §1's stored-scope bug, viewed from the other end: §1 hides the work *from* the caller's lifecycle; this hides cancellation *from* the work. + +```kotlin +// ❌ BAD — catches CancellationException, never rethrows +suspend fun fetch() { + try { + api.load() + } catch (e: Exception) { // matches CancellationException too + logger.warn("load failed", e) + } +} + +// ❌ ALSO BAD — runCatching has the same problem +suspend fun fetch() { + runCatching { api.load() } + .onFailure { logger.warn("load failed", it) } +} +``` + +The acceptable shapes: + +```kotlin +// ✅ Separate catch first +try { api.load() } +catch (e: CancellationException) { throw e } +catch (e: Exception) { logger.warn("load failed", e) } + +// ✅ Conditional rethrow inside the broad catch +try { api.load() } +catch (e: Exception) { + if (e is CancellationException) throw e + logger.warn("load failed", e) +} + +// ✅ ensureActive() — good when the catch handles ordinary failures and you only need +// to rethrow if the current coroutine is cancelled +try { api.load() } +catch (e: Exception) { + currentCoroutineContext().ensureActive() + logger.warn("load failed", e) +} + +// ✅ runCatching with explicit guard +runCatching { api.load() } + .onFailure { + if (it is CancellationException) throw it + logger.warn("load failed", it) + } + +// ✅ runCatching terminated with getOrThrow (cancellation flows back out) +runCatching { api.load() }.getOrThrow() +``` + +The trigger is "a suspend call inside the `try`", not "the enclosing function is declared `suspend`". This applies inside any suspending body — `suspend fun`, a `launch { … }` lambda, a Flow `collect { … }`, etc. + +The common carve-out is an intentionally local timeout: catching `TimeoutCancellationException` from your own `withTimeout` and converting it to a domain result can be correct. Keep that catch narrow and close to the timeout. Do not use it as permission to swallow arbitrary cancellation. + +Catching a non-cancellation subtype (`IOException`, your own exception types) is fine — they don't extend `CancellationException`. + +### 7. `runBlocking` + +`runBlocking` parks the current thread until the lambda finishes. Inside suspend-capable or lifecycle-scoped application paths it is wrong: a thread that meant to be async is now blocked, structured concurrency is broken, and any cancellation upstream has no effect. It is the "callee makes a structural decision for the caller" anti-pattern at its most direct. + +```kotlin +// ❌ BAD — bridging to suspend by blocking the calling thread +fun saveUser(user: User) { + runBlocking { repository.save(user) } +} +``` + +Three fixes, by context: + +**Suspend-capable application code** — make the function `suspend`: + +```kotlin +// ✅ GOOD +suspend fun saveUser(user: User) = repository.save(user) +``` + +If the immediate caller can't suspend either (a non-suspending UI callback, a `BroadcastReceiver` hook), use the existing lifecycle-bound scope at the boundary — see §3's UI ↔ state-holder carve-out. The fix is at the boundary, not inside `saveUser`. + +Legitimate blocking boundaries exist: `main` in a CLI tool, Java interop APIs that must return synchronously, framework callbacks with no suspending alternative, and migration shims. Keep `runBlocking` at that outer boundary, keep the body small, and call suspending code immediately. + +**Tests** — use `runTest`: + +```kotlin +// ❌ BAD — real time, slow tests, no virtual delay +@Test fun loadsUser() = runBlocking { + assertThat(repository.load().name).isEqualTo("Alice") +} + +// ✅ GOOD +@Test fun loadsUser() = runTest { + assertThat(repository.load().name).isEqualTo("Alice") +} +``` + +`runTest` gives you virtual time (`delay()` returns immediately), `TestDispatcher` integration, and proper coroutine cleanup. Real-time `runBlocking` in tests makes them slow and flaky. + +**`ContentProvider` carve-out** — Android's `ContentProvider` methods (`query`, `insert`, `update`, `delete`, `onCreate`, `call`) are synchronous from outside the process. There is no way to suspend them. Inside *member functions* of a `ContentProvider` subclass (direct or indirect — not companion objects), `runBlocking` is the unavoidable bridge. Keep the body as short as possible and call into suspending code immediately: + +```kotlin +// ✅ Acceptable in ContentProvider members only +class MyProvider : ContentProvider() { + override fun query(...): Cursor? = runBlocking { dao.query(...) } +} +``` + +This carve-out is for `android.content.ContentProvider` subclasses *only*. "It's like a `ContentProvider`" doesn't apply, and a `runBlocking` in a `ContentProvider`'s companion object is still a regular violation — the helper isn't part of the framework's synchronous surface. + +## Quick reference + +| Symptom | Anti-pattern | Fix | +|---|---|---| +| Class has `private val scope: CoroutineScope` | Stored scope on the callee | Remove. Make public APIs `suspend`. | +| `init { scope.launch { ... } }` | Construction-time launch | Move to `suspend fun init()` / `login()` | +| `fun foo() { scope.launch { ... } }` on a repository/manager/use case | Fire-and-forget from non-UI class | `suspend fun foo()`, let UI state holder pick the scope | +| `fun onClick() { viewModelScope.launch { ... } }` on a state holder, called from UI | UI ↔ state-holder boundary — fine | Keep as-is (see §3 carve-out) | +| `private val scope = MainScope()` | Internally-constructed stored scope | Same — remove, make APIs `suspend` | +| `@SingleIn(AppScope) class X(scope) { init { scope.launch { … } } }` | DI-bound opaque launch (§5) | Expose `suspend fun run()`, launch from startup orchestrator | +| `class Y : Initializer { override fun initialize() { scope.launch { … } } }` | Initializer that launches, not registers (§5) | Same — `suspend fun run()`, orchestrator owns lifecycle | +| `try { suspendCall() } catch (e: Exception\|Throwable\|CancellationException) { … }` with no rethrow | Swallowed cancellation (§6) | Prefer `catch (e: CancellationException) { throw e }`; use `ensureActive()` only when that matches the intent | +| `runCatching { suspendCall() }.onFailure { … }` with no cancellation guard | Same shape as above (§6) | Add `if (it is CancellationException) throw it`, or terminate with `.getOrThrow()` | +| `runBlocking { … }` inside suspend-capable app code | Thread-blocking bridge (§7) | Make caller `suspend`; or use a lifecycle scope at the boundary | +| `runBlocking { … }` in a test | Same — real-time bridging (§7) | Use `runTest { … }` | +| `runBlocking { … }` inside a `ContentProvider.query`/`insert`/… member | Carve-out (§7) | Acceptable; keep the body minimal | + +## Refactoring guidance + +Removing an existing offender: + +1. **Start at the leaf.** Pick the class farthest from any UI — usually a repository or data source. Its public surface should be the easiest to convert. +2. **Convert public functions to `suspend`** one at a time. The compiler will surface every caller. +3. **At each caller, choose the scope deliberately:** `viewModelScope`, `lifecycleScope`, `coroutineScope { }`, or an explicit job. This is the choice that was missing before. +4. **Delete the `CoroutineScope` constructor parameter** once nothing uses it. Remove the injection binding. + +Don't try to fix every class in one MR. Removing an anti-pattern is incremental work. + +## When NOT to apply + +- **UI state holders absorbing UI events.** A ViewModel/Component/feature model with `fun onClick(...) { viewModelScope.launch { ... } }` is correct — that's the boundary the framework needs. See §3 carve-out. +- **Lifecycle owners with explicit cancellation and error policy.** Actors/services, app infrastructure, or application-scoped singletons may own a scope when they expose clear `close`/`cancel`/restart behavior or otherwise map directly to an application lifecycle. Inject `Application.applicationScope` explicitly rather than creating one ad-hoc. **This is not permission to launch from `init` / `initialize()`** — see §5. +- **Already-suspending APIs** don't need any of this work. +- **Tests** sometimes use `TestScope` as a deliberate ambient scope — that's a different pattern with explicit virtual-time control. + +## Red flags during review + +These thoughts mean the anti-pattern is back: + +| Thought | Reality | +|---|---| +| "I'll just add a `CoroutineExceptionHandler` to the scope" | The problem isn't error handling. The problem is the scope shouldn't exist. | +| "I need to launch from `init` so the data's ready when consumers arrive" | Consumers reading state that isn't ready is the bug. Use phasing. | +| "The caller doesn't want to deal with `suspend`" | Then the caller chooses fire-and-forget at their scope. Don't decide for them. | +| "It's just a small fire-and-forget call" | Silent cancellation makes every fire-and-forget a potential silent failure. | +| "We caught and logged the exception, so we're fine" | Did the catch rethrow `CancellationException`? If no, the coroutine is silently un-cancelled. (§6) | +| "It's just one `runBlocking`, in a non-critical path" | Every `runBlocking` asserts the caller has no async option. If they do, it's the wrong primitive. (§7) | +| "Tests are simpler with `runBlocking`" | They run in real time, can't fast-forward `delay`, and lose `TestDispatcher` semantics. Use `runTest`. (§7) | + +## Related + +- [`kotlin-flow-state-event-modeling`](../kotlin-flow-state-event-modeling/SKILL.md) — `StateFlow`, `SharedFlow`, `Channel`, `stateIn`, one-shot events, and related modeling. +- [`kotlin-coroutines`](../kotlin-coroutines/SKILL.md) — Amethyst's relay-pool / callbackFlow / testing async patterns. diff --git a/.claude/skills/kotlin-flow-state-event-modeling/SKILL.md b/.claude/skills/kotlin-flow-state-event-modeling/SKILL.md new file mode 100644 index 0000000000..a84c10bee3 --- /dev/null +++ b/.claude/skills/kotlin-flow-state-event-modeling/SKILL.md @@ -0,0 +1,183 @@ +--- +name: kotlin-flow-state-event-modeling +description: Use when writing or reviewing Kotlin StateFlow/SharedFlow/Channel choices, sentinel default values, stateIn placement, WhileSubscribed staleness, or MutableStateFlow update patterns. Technique-layer skill — complements the codebase-specific kotlin-expert. +--- + +# Kotlin Flow: state and event modeling + +## Core principle + +**Pick the primitive that matches replay, fan-out, and synchronous-read requirements.** `StateFlow`, `SharedFlow`, `Channel`-backed flows, and cold `Flow` differ in buffering, who sees each emission, and whether `.value` exists. Wrong choices drop events, leak sharing coroutines, or force fake domain sentinels into state. + +## When to use this skill + +You're writing or reviewing Kotlin code involving: + +- `MutableStateFlow(SomeSentinel)` — `NoUser`, `Empty`, `Loading`, etc. — because the real value is async +- `.stateIn(...)` called inside a function rather than assigned to a property +- `SharingStarted.WhileSubscribed(...)` on a flow whose `.value` is read synchronously and must stay fresh +- `MutableSharedFlow` for navigation events, snackbars, or other one-shot emissions where loss would be a bug +- `.map { }` on a `StateFlow` when consumers still need synchronous `.value` +- `MutableStateFlow.value = _state.value.copy(...)` or update code that builds expensive objects inside `update { ... }` + +## SharedFlow for single-consumer fire-once events + +`SharedFlow` defaults have no replay buffer. If nothing is collecting at the exact instant of emission, the event is gone. For a **single UI consumer** handling exactly-once events such as navigation or snackbars, a buffered `Channel` exposed as a `Flow` often matches the semantics better: + +```kotlin +// ❌ BAD +private val _navEvents = MutableSharedFlow() +val navEvents: SharedFlow = _navEvents.asSharedFlow() + +// ✅ GOOD +private val _navEvents = Channel(Channel.BUFFERED) +val navEvents: Flow = _navEvents.receiveAsFlow() +``` + +`Channel.receiveAsFlow()` is **fan-out, not broadcast**: with multiple collectors, each event is delivered to **one** collector. `Channel.BUFFERED` is bounded, so sends can suspend and `trySend` can fail. If multiple observers must all see the same event, use explicit state, durable storage, or a deliberately configured `SharedFlow` instead. + +## StateFlow polluted with invalid sentinel defaults + +`StateFlow` forces an initial value. When the real value is async, developers sometimes invent fake domain values — `NoUser`, `EmptyUser`, placeholder IDs — and every consumer is forced to treat that sentinel as real data. + +```kotlin +// ❌ BAD — sentinel leaks into the type +class UserSession(private val db: Db) { + private val _user = MutableStateFlow(NoUser) + val user: StateFlow = _user.asStateFlow() + init { scope.launch { _user.value = db.load() } } +} +``` + +One fix is **phasing**: don't expose the `StateFlow` until the real value exists. + +```kotlin +// ✅ GOOD — bootstrap suspends; observers only see real users +class UserSession(private val db: Db) { + private var _user: MutableStateFlow? = null + val user: StateFlow + get() = checkNotNull(_user) { "Call login() first" } + + suspend fun login() { + _user = MutableStateFlow(db.load()) + } +} +``` + +If absence, loading, or error is a real state, model it explicitly (`User?`, `sealed interface UserUiState`, `Result`, etc.). The bug is a fake domain value masquerading as real data, not every initial value. + +## Mutate MutableStateFlow with `update { ... }` + +Prefer `MutableStateFlow.update { current -> ... }` over reading `.value` and writing it back. `update` applies the transform atomically against the latest state, which avoids lost updates when multiple coroutines mutate the same state. + +```kotlin +// BAD — read/modify/write can lose concurrent updates. +_state.value = _state.value.copy( + selectedId = id, + details = details, +) + +// GOOD — transform starts from the latest state. +_state.update { current -> + current.copy( + selectedId = id, + details = details, + ) +} +``` + +Keep object creation outside the `update` block unless it needs the current state. The update lambda can be retried, so expensive work or side effects inside it may run more than once: + +```kotlin +// GOOD — details does not depend on current state, so build it once. +val details = Details.from(response) +_state.update { current -> + current.copy(details = details) +} + +// GOOD — derived value depends on current state, so compute it inside. +_state.update { current -> + val nextItems = current.items.replaceById(updatedItem) + current.copy(items = nextItems) +} +``` + +The block should be a pure, fast state transformation: no network calls, database writes, logging side effects, random IDs, or time reads unless those values were captured before the block. + +## `stateIn()` inside a function + +```kotlin +// ❌ BAD — new sharing coroutine every call +fun getPreferences(): StateFlow = + repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default) +``` + +Every call to `getPreferences()` launches a fresh coroutine on `scope` that never completes. Performance dies fast under repeated reads. + +```kotlin +// ✅ GOOD — one shared instance, computed once +val preferences: StateFlow = + repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default) +``` + +## `WhileSubscribed` with synchronous `.value` + +`SharingStarted.WhileSubscribed(timeout)` disconnects the upstream when there are no active collectors. While disconnected, `.value` returns the last cached value, which may be stale or still the initial value. + +**Rule:** if `.value` must be fresh or initialized without an active collector, use `SharingStarted.Eagerly` or explicit initialization. `WhileSubscribed` is fine when stale/cached values are acceptable and consumers primarily collect asynchronously. + +## `.map` on `StateFlow` loses `.value` + +```kotlin +// ❌ BAD — `name.value` won't compile; it's now a plain Flow +val name: Flow = userState.map { it.name } +``` + +If you need synchronous `.value`, terminate the chain with `.stateIn(...)`: + +```kotlin +// ✅ GOOD +val name: StateFlow = userState + .map { it.name } + .stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name) +``` + +Community "derived state flow" utilities run the transform on every `.value` read — only acceptable for fast, idempotent transforms. Default to `.stateIn(...)`. + +## Decision: which Flow type? + +| Need | Primitive | +|------|-----------| +| State that always has a value, read by both async collectors **and** synchronous code | `StateFlow`, often with `SharingStarted.Eagerly` when `.value` matters | +| Hot stream, multiple subscribers, **no** requirement for synchronous `.value` | `SharedFlow` | +| Discrete events for **one** consumer, exactly-once handoff | Consider `Channel(BUFFERED).receiveAsFlow()` | +| Cold stream, one consumer per collection | Plain `Flow` | + +If you're tempted to reach for `SharedFlow`, ask: would dropping an emission be a bug, and how many consumers must see it? If one consumer must handle it exactly once, a `Channel` may fit. If every observer must see it, model durable state or configure a broadcast stream deliberately. + +## Quick reference + +| Symptom | Problem | Fix | +|---------|---------|-----| +| `MutableStateFlow(FakeDomainValue)` | Invalid placeholder default | Model absence explicitly or use phase initialization | +| `MutableSharedFlow` for single-consumer nav/snackbar | Lossy default event stream | Consider `Channel(BUFFERED).receiveAsFlow()` | +| `fun foo() = flow.stateIn(...)` | Per-call sharing coroutine | Make it a `val` / shared instance | +| `WhileSubscribed` + `.value` must be fresh/initialized | Stale or initial data | `SharingStarted.Eagerly` or explicit initialization | +| `stateFlow.map { ... }` consumed as state | Lost `.value` | Terminate with `.stateIn(...)` | +| `_state.value = _state.value.copy(...)` | Non-atomic read/modify/write | `_state.update { it.copy(...) }` | +| Expensive object creation inside `update { ... }` that doesn't use current state | Work can repeat if update retries | Build before `update`; keep only current-state transforms inside | + +## Red flags during review + +| Thought | Reality | +|---------|---------| +| "We need `SharedFlow` because there are multiple subscribers" | Multiple subscribers change the semantics. `Channel.receiveAsFlow()` is not broadcast; choose the event model deliberately. | +| "We'll use `WhileSubscribed` to save resources" | Only if stale/initial `.value` reads are acceptable. Verify before applying. | +| "I'll use a sentinel until real data loads" | Consumers treat it as real domain; prefer explicit UI/state modeling or phasing. | +| "I'll construct the new object inside `update` because it's convenient" | The lambda may retry. Construct outside unless it depends on the current state. | + +## Related + +- [`kotlin-coroutines-structured-concurrency`](../kotlin-coroutines-structured-concurrency/SKILL.md) — scope ownership, init launches, fire-and-forget boundaries, cancellation, `runBlocking` +- [`compose-side-effects`](../compose-side-effects/SKILL.md) — collecting event flows and wiring side effects in Compose +- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) — where state holders expose flows to UI diff --git a/.claude/skills/kotlin-types-value-class/SKILL.md b/.claude/skills/kotlin-types-value-class/SKILL.md new file mode 100644 index 0000000000..052eddd7e2 --- /dev/null +++ b/.claude/skills/kotlin-types-value-class/SKILL.md @@ -0,0 +1,119 @@ +--- +name: kotlin-types-value-class +description: Use when writing or reviewing Kotlin type declarations to choose @JvmInline value class over data class where appropriate, including Compose stability implications. Technique-layer skill — complements the codebase-specific kotlin-expert. +--- + +# Kotlin value class vs data class + +## Core principle + +Prefer `@JvmInline value class` for single-field types that carry domain meaning. Data classes are for aggregating multiple fields. A value class gives you type safety (you can't mix up `UserId` and `String`) without the allocation overhead of a data class. + +## When to use this skill + +- Writing a new Kotlin type that wraps a single value +- Reviewing a data class that has only one property +- Seeing primitive types (`String`, `Long`, `Int`, etc.) used where a domain type would prevent misuse +- Compose compiler reports showing unstable parameters that could be value classes + +## Decision flow + +| Situation | Prefer | +|---|---| +| Single field + domain-meaningful (`UserId`, `EmailAddress`, `Percentage`) | `@JvmInline value class` | +| Single field + no domain meaning (just grouping) | Type alias or keep the primitive | +| Multiple fields | Data class | +| Needs custom `equals`/`hashCode`/`toString` beyond the wrapped value | Data class (value classes delegate to the underlying type) | +| Used as a generic type argument or nullable in hot paths | Data class or primitive (autoboxing cost) | + +```kotlin +// GOOD: domain-meaningful single field +@JvmInline value class UserId(val value: String) +@JvmInline value class EmailAddress(val value: String) +@JvmInline value class Percentage(val value: Float) + +// BAD: data class wrapping a single field +data class UserId(val value: String) // unnecessary allocation +data class EmailAddress(val value: String) // type safety without the overhead is available + +// BAD: value class with no domain meaning +@JvmInline value class Wrapper(val value: String) // just use the String, or a type alias + +// BAD: value class needing custom equality +@JvmInline value class CaseInsensitiveString(val value: String) +// value class equals delegates to String equals, which IS case-sensitive +// Use a data class if you need different equality semantics +``` + +## Compose stability + +`@JvmInline value class` is treated as `Stable` by the Compose compiler when its underlying type is stable (primitives, `String`, and other stable types). This means: + +- Value classes passed as composable parameters avoid "unstable parameter" warnings +- No need for `@Immutable` annotations at Compose boundaries when wrapping primitives or strings +- Replacing single-field data classes with value classes at UI boundaries improves skippability + +```kotlin +// Before: data class wrapping a single field +data class UiState(val userId: String) // works, but allocates a wrapper object + +// After: value class is stable and zero-allocation at runtime +@JvmInline value class UserId(val value: String) +data class UiState(val userId: UserId) +``` + +## Gotchas + +- **Autoboxing**: Value classes are unboxed at compile time but boxed (allocated) when used as nullable (`UserId?`), generic type arguments (`List`), or vararg parameters. In hot paths these allocations matter; in most code they don't. +- **No backing fields**: You cannot use `init` blocks, `lateinit`, or delegated properties like `by lazy`. The class body is extremely constrained — only the single constructor parameter exists. +- **No data-class conveniences**: No `copy()`, no `component1()` for destructuring, and no way to customize `toString()`. If you need any of these, use a data class. +- **No custom equals/hashCode/toString**: These always delegate to the underlying type. Need custom equality → use a data class. +- **when exhaustiveness**: Sealed hierarchies of value classes work differently than data class hierarchies. Test `when` branches carefully. +- **Serialization semantics**: With kotlinx.serialization, a `@Serializable data class A(val value: String)` serializes as `{"value":"..."}`, but a `@Serializable value class A(val value: String)` serializes as the underlying value (`"..."`). Replacing a single-field data class with a value class is a breaking change for your API/JSON contract. +- **Serialization**: Some serialization frameworks need explicit support for value classes (e.g., kotlinx.serialization's `@Serializable` works, but Jackson may need configuration). +- **Interoperability**: From Java, value classes appear as their underlying type. Java callers bypass the type-safety wrapper. +- **Reflection and runtime erasure**: When passed as `Any` or used in generic contexts, value classes box into a synthetic wrapper class. Java reflection sees mangled method signatures, and frameworks that rely on raw runtime types (some ORMs, DI containers, or serializers) may see the underlying type rather than the value class. + +## Packing multiple values + +A value class can only declare one field, but Compose provides `packFloats`, `packInts`, and matching `unpack*` functions in `androidx.compose.ui.util` to store multiple primitives in a single `Long`. This lets you represent composite values (e.g., a 2D point, size, or padding) as a zero-allocation value class instead of a multi-field data class. + +```kotlin +@JvmInline value class Offset(val packedValue: Long) + +fun Offset(x: Float, y: Float): Offset = Offset(packFloats(x, y)) +val Offset.x: Float get() = unpackFloat1(packedValue) +val Offset.y: Float get() = unpackFloat2(packedValue) +``` + +- **Only use this in performance-critical paths** — manual bit-packing is error-prone. A data class is simpler and safer for most UI types. +- **Available in `androidx.compose.ui.util`** — `packFloats`, `packInts`, `unpackFloat1`, `unpackFloat2`, `unpackInt1`, `unpackInt2`. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Data class wrapping a single domain field | Replace with `@JvmInline value class` | +| Value class with no domain meaning (just a wrapper) | Use a type alias or the primitive directly | +| Value class needing custom equality | Use a data class instead | +| Value class as generic type argument in hot path | Accept autoboxing cost or use the primitive | +| `@Immutable` annotation on a type that could be a value class | Replace with value class — it's Stable by default | +| Forgetting `@JvmInline` annotation | Always pair `value class` with `@JvmInline` for single-field classes | + +## Red flags during review + +- A data class with exactly one property +- A `String`, `Long`, or `Int` used where different values should not be interchangeable (e.g., `fun transfer(from: String, to: String, amount: Long)`) +- An `@Immutable` annotation on a single-field wrapper +- A type alias used for domain distinction where value-class semantics are needed (type aliases are type-erased, no runtime protection) + +## When NOT to apply + +- The type needs multiple fields → data class +- The type needs custom `equals`/`hashCode`/`toString` → data class +- The type is used heavily as a nullable or generic in performance-critical code → measure autoboxing cost first +- The project does not need the type-safety distinction → a type alias or primitive is sufficient + +## Related + +- [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) — diagnose unstable Compose parameters; value classes are one fix diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 90f533702f..c80d5bd8b5 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.08.0` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.09.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.08.0" +quartz = "1.09.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.08.0") + implementation("com.vitorpamplona.quartz:quartz:1.09.2") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 59293256c7..3b80e96904 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.08.0 +com.vitorpamplona.quartz:quartz:1.09.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.08.0" +quartz = "1.09.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.08.0") + implementation("com.vitorpamplona.quartz:quartz:1.09.2") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.08.0") + implementation("com.vitorpamplona.quartz:quartz:1.09.2") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index c8e10823f8..be864ab050 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.08.0 +com.vitorpamplona.quartz:quartz:1.09.2 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index b64f0b6244..5821e1d1d0 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -21,7 +21,6 @@ TASKS=( :commons:jvmTest :nestsClient:jvmTest :quic:jvmTest - :ammolite:testDebugUnitTest :amethyst:testPlayDebugUnitTest :cli:test ) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c3e0553cf..df4e4baa68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -218,7 +218,6 @@ jobs: :quartz:jvmTest \ :commons:jvmTest \ :nestsClient:jvmTest \ - :ammolite:testDebugUnitTest \ :amethyst:testFdroidDebugUnitTest \ :amethyst:testPlayDebugUnitTest \ :amethyst:assembleBenchmark \ diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index e5ce7edcdf..17b7b64c13 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -21,9 +21,13 @@ permissions: env: # Asset naming contract: amethyst-desktop---. # Single source of truth in scripts/asset-name.sh. - # linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below. - LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage - LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0 + # appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below. + # We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to + # bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH + # mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the + # AppDir as-is, which is what we actually want. + APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage + APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 jobs: # --------------------------------------------------------------------------- @@ -35,7 +39,6 @@ jobs: fail-fast: false matrix: include: - - { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" } - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } @@ -84,17 +87,19 @@ jobs: if: matrix.family == 'linux' run: sudo apt-get update && sudo apt-get install -y rpm fakeroot - - name: Fetch linuxdeploy (linux-portable only, SHA-verified) + - name: Fetch appimagetool (linux-portable only, SHA-verified) if: matrix.family == 'linux-portable' run: | set -euo pipefail - curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o packaging/appimage/linuxdeploy-x86_64.AppImage - actual=$(sha256sum packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}') - if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then - echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual" + # appimagetool 1.9.0 validates the .desktop file via desktop-file-validate. + sudo apt-get update && sudo apt-get install -y desktop-file-utils + curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}') + if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then + echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual" exit 1 fi - chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage + chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage - name: Build desktop artifacts uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -204,7 +209,6 @@ jobs: fail-fast: false matrix: include: - - { os: macos-13, arch: x64, family: macos, tasks: "amyImage" } - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 5e5bcfd89f..459dc6effa 100644 --- a/.gitignore +++ b/.gitignore @@ -167,9 +167,8 @@ desktopApp/src/jvmMain/appResources/macos/ desktopApp/src/jvmMain/appResources/windows/ # CI-fetched AppImage tooling (downloaded by create-release workflow; not committed) -packaging/appimage/linuxdeploy-x86_64.AppImage -packaging/appimage/linuxdeploy-extracted/ -packaging/appimage/squashfs-root/ +desktopApp/packaging/appimage/appimagetool-x86_64.AppImage +desktopApp/packaging/appimage/squashfs-root/ # Git worktrees .worktrees/ diff --git a/BUILDING.md b/BUILDING.md index 611db4f12c..05c238e422 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -27,7 +27,8 @@ Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` -- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage +- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; + `appimagetool` + `desktop-file-utils` for AppImage Install Linux RPM tooling: @@ -39,12 +40,15 @@ sudo apt-get install -y rpm fakeroot sudo dnf install -y rpm-build ``` -Install linuxdeploy locally (CI fetches its own — SHA-verified): +Install appimagetool locally (CI fetches its own — SHA-verified): ```bash -curl -fsSL -o packaging/appimage/linuxdeploy-x86_64.AppImage \ - https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage -chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage +# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry +sudo apt-get install -y desktop-file-utils + +curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \ + https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage +chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage ``` --- @@ -107,11 +111,11 @@ amethyst-desktop---. Where: -| Field | Values | -|---|---| -| `` | Tag stripped of leading `v` (e.g. `1.08.0`) | -| `` | `macos`, `windows`, `linux` | -| `` | `x64`, `arm64` | +| Field | Values | +|---|---------------------------------------------------------| +| `` | Tag stripped of leading `v` (e.g. `1.09.2`) | +| `` | `macos`, `windows`, `linux` | +| `` | `x64`, `arm64` | | `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` | Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh). @@ -120,10 +124,10 @@ any change is a breaking contract. Examples: -- `amethyst-desktop-1.08.0-macos-x64.dmg` -- `amethyst-desktop-1.08.0-macos-arm64.dmg` -- `amethyst-desktop-1.08.0-windows-x64.msi` -- `amethyst-desktop-1.08.0-linux-x64.AppImage` +- `amethyst-desktop-1.09.2-macos-x64.dmg` +- `amethyst-desktop-1.09.2-macos-arm64.dmg` +- `amethyst-desktop-1.09.2-windows-x64.msi` +- `amethyst-desktop-1.09.2-linux-x64.AppImage` --- @@ -219,8 +223,8 @@ or equivalent issue tracker. On rotation, paste new token and run ```bash brew bump-cask-pr amethyst-nostr \ - --version 1.08.0 \ - --url "https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-macos-arm64.dmg" + --version 1.09.2 \ + --url "https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-macos-arm64.dmg" ``` The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a @@ -231,7 +235,7 @@ auto-submits new version bumps on each stable release. ```bash wingetcreate new \ - https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-windows-x64.msi + https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-windows-x64.msi ``` Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is diff --git a/CHANGELOG.md b/CHANGELOG.md index 86437fff68..fad3dd0fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,511 @@ + Fixes + +- Fix Blossom blob detection to reject non-compliant filenames by @greenart7c3 in #2919 +- fix(desktop): add ProGuard keep rules so desktop builds actually launch by @mstrofnone in #2921 +- fix(desktop): mirror Android ProGuard strategy for release builds by @mstrofnone in #2922 +- fix(video): pause playback when app goes to background by @vitorpamplona in #2925 +- feat(desktop): wire NamecoinSettingsSection into Settings screen by @mstrofnone in #2923 +- feat(desktop): wire Import Follow List dialog into UI by @mstrofnone in #2924 +- Fix crash when toggling home tabs with persisted pager state by @vitorpamplona in #2930 +- Dedupe public channels in known chat list by channel id by @vitorpamplona in #2932 +- Route NIP-43 relay join/leave through launchSigner by @vitorpamplona in #2933 +- Migrate DNS cache from SharedPreferences to cacheDir by @vitorpamplona in #2935 +- Filter DNS poison (loopback / any-local) from non-loopback hosts by @vitorpamplona in #2936 +- perf(dns-cache): hand-rolled binary persistence format for SurgeDnsStore by @vitorpamplona in #2937 +- Ignore duplicated hashtags in different char cases when processing hashtag spam by @vitorpamplona +- New Crowdin translations + + Activates Nest, Git Screens and Fixes + +- Activates Nest option in the Left Drawer +- Rename onRefresh callback to avoid shadowing parameter by @vitorpamplona in #2912 +- Fix desktop app ProGuard build with Compose 1.11.0 by @vitorpamplona in #2914 +- Fix pull-to-refresh indicator positioning in DisappearingScaffold by @vitorpamplona in #2916 +- Remove collectAsStateWithLifecycle from BottomBarSettingsContent by @vitorpamplona in #2918 +- Add Git repository detail screen with issues/patches tabs by @vitorpamplona in #2917 + + +# [Release v1.09.0: We are going crazy](https://github.com/vitorpamplona/amethyst/releases/tag/v1.09.0) - 2026-05-14 + +What's New? +- Go live on audio spaces (Nests) +- Marmot Group chats (WhiteNoise) +- Voice and Video calls (Noscall) +- PDF reader +- Favorite algo feeds +- HLS Video Uploads +- Schedule posts for later +- Cast videos to your TV (Chromecast) +- Multiple accounts on Desktop +- Mute a whole conversation thread +- Tap any timestamp to see the exact date and time +- Pull Notification (internal Pokey) +- Local LLM helpers (Pixel 9+, Samsung 25+, Xiaomi 15+) +- `amy`, a command-line Nostr client + +## Features + +- Voice and Video Calls (NIP-AC) — one-on-one and group WebRTC calls. + - Full-screen incoming-call UI over the lock screen. + - PiP, ringtone and vibration. + - Proximity sensor support. + - Bluetooth headset routing. + - Camera switch. + - Network resilience and default TURN servers. + - Mid-call peer invites with a 30s timeout and per-peer status. + - Settings toggle to disable calls. +- Audio Rooms / Nests (NIP-53) — a full revamp. + - Live chat panel. + - Reactions overlay and picker. + - Listener counter. + - Presence with publishing/onstage tags. + - Hand-raise queue. + - Host actions: kick, promote, demote, edit, close room. + - Per-participant context sheet. + - Scheduled rooms with a TimePicker and SCHEDULED badge. + - "Listen to recording" CTA for closed rooms. + - Share a room as `naddr1`. + - Custom room themes and fonts (kind 30312). + - Home live-bubble row showing follows broadcasting. + - Host-leave confirmation and default-server prompt. + - In-app lobby with a chat composer, gating room re-entry. + - PiP that focuses active speakers. + - Feed bucketed into Live / Scheduled / Recently ended, with live status verified by current presence. + - Live audio-level speaker ring. + - Keeps the screen on while connected. + - Audio plays through the media volume stream. +- Marmot Encrypted Group Chats (MLS over Nostr / NIP-EE) + - Create, join and leave groups. + - Inline group rendering in Messages. + - Member management with user search. + - Admin grant/revoke. + - Group info screen with picture, member list and per-relay freshness. + - Auto-publish KeyPackage. + - Reset Marmot State safety valve in Settings. + - Full RFC 9420 compliance pass (P0/P1/P2). + - External Commit flow. + - Retained-epoch decryption for offline catch-up. + - Required-capabilities advertised on groups; interop fixes for other Marmot clients. + - Popup notifications for group messages (kind:445). +- Multi-account on Desktop + - Account switcher dropdown in the sidebar and single-pane layout. + - Add Account dialog and per-account logout. + - View-only (npub-only) accounts. + - Account removal switches to another account or logs out cleanly. + - Encrypted local account storage (AES-256-GCM). + - Display names and middle-truncated npubs. +- Schedule posts for later + - Date/time picker and toolbar toggle in the post composer. + - Dedicated screen and drawer entry to view, push or delete scheduled posts. + - Background worker that publishes at the scheduled time. + - Warning when scheduling without always-on notifications. +- Cast videos to your TV + - Chromecast casting (Google Play build only). + - Stop-from-phone button; the local player pauses while casting. + - Cast button backfilled for accounts that already had video settings. +- Mute a whole thread + - Mute thread entry in the long-press dropdown and quick-action sheet. + - Muted threads listed in Security Filters with an unmute action. + - Muted threads dropped from feeds, notifications and push delivery. +- Configurable home tabs + - Choose between New Threads, Conversations and Everything. + - Visibility toggles persist across restarts. +- Configurable bottom navigation bar + - Pick which screens appear in the bottom bar. + - Restore-default button in settings. +- Reply and Mention notifications (NIP-10 / NIP-22) + - Dedicated Mentions channel. + - Per-thread grouping. + - Inline reply. + - All content-event citations routed to Mentions. + - Opt-in Following / Everyone tab split. +- Filter the home feed in place by hashtag, community, geohash and relay (no navigation away) +- Hashtag and geohash top-nav filters on Pictures, Shorts, Articles, Polls and Products +- NIP-22 comments on external content (hashtags, geohashes, URLs) render a typed reply-context chip and land in the conversations feed +- Interest Sets (NIP-51, kind 30015) + - List, create, rename, delete and clone interest sets. + - Public/private hashtag toggle. + - TopNav filter chips. +- NIP-9A Community Rules + - Structured rules editor in the new-community flow. + - Post validation against community rules in the composer. + - Opt-in moderation feed filter. +- PDF reader + - Inline PDF previews in feeds. + - Double-tap to toggle zoom. + - Zoom-aware hi-res re-render for crisp pinch-zoom. + - Download and save PDFs to Downloads/Amethyst. +- Multi-wallet NWC + - Multiple wallets with a balance view. + - Default picker, rename and reorder. + - Dedicated Add Wallet screen with Connect Wallet / paste / QR scan. +- Favorite Algo Feeds filter in the Top Nav Bar +- Custom Post creation on Polls / Pictures / Shorts / Longs +- Custom Emoji Packs (NIP-30) + - Browse Emoji Sets screen for discovering kind 30030 packs. + - My Emoji List screen for managing your kind 10030 selection. + - Modernized pack metadata screen with hero image and inline emoji/cover upload. + - Public/private toggle when adding emoji. + - Decrypted private emojis surfaced end-to-end. +- Dedicated drawer screens for more content types. + - Standalone Articles, Products, Public Chats, Communities (NIP-72), Live Streams and Follow Packs screens. + - Products screen defaults to "Around Me". +- Richer live stream chat. + - Inline clips (kind 1313) and raids (kind 1312). + - Inline zap receipts. + - NIP-75 zap goal pinned at the top. + - Top zappers leaderboard. + - Stream clips surfaced in the profile gallery tab. +- Content warnings on media. + - Grid-level content warnings with distinct reasons. + - Warning overlaid on the blurhash at media size. +- YouTube-style video quality picker. + - Feed and PiP default to the lowest HLS resolution. + - Fullscreen defaults to auto. +- HLS Video uploads (NIP-71) + - Pick which renditions to upload. + - See which file is currently uploading. + - Optional cross-post as a kind-1 note. + - Generated poster JPEG. + - Blurhash and thumbhash on every video imeta. +- ThumbHash support alongside BlurHash + - Used across events, uploads and the UI. + - Forwarded when adding media to the gallery. + - Upload failures to generate a blurhash/thumbhash are now surfaced. +- NIP-A3 Payment Targets (kind 10133) + - Pay action on the note reactions row. + - Payment-targets button on the profile. + - Lightning address moved to the wallet setup screen. + - Alt-text on payment-target events. +- Search power tools + - Scope, source, follows and sort toggles. + - Paste an `npub1…`, `nprofile1…`, `nevent1…`, `naddr1…` or `note1…` to jump straight to it. +- Markdown renderer — improved typography, blockquote gutter, table styling +- Polls + - Single-screen creation with a poll-type selector. + - Open/Closed tabs. + - "View results" option (prevents voting after viewing). + - Dismiss button on active-poll cards. +- Badge support Redesigned — You can now create, grant, manage and add/remove badges from your profile. +- Settings revamp + - Modernized Settings screen. + - Dedicated Profile UI settings page. + - Compose Settings screen (auto-draft toggle). + - Security Filters split into a hub with per-category screens. +- Tap a timestamp to toggle between relative ("2h ago") and absolute date/time, driven by a single shared ticker +- Copy raw JSON of a note from the dropdown menu +- Stale-relay hint on replaceable events, using the NIP-66 relay cache +- Two-stage zap progress on the zap action +- Bulk-remove for blocked users and hidden words +- Jump-to-parent icon on replies in Full UI mode +- Configurable report-warning threshold +- `.f4a` audio playback + +## In AI-Ready phones (Pixel 9+, Samsung 25+, Xiaomi 15+): +- AI Writing Help — assistant in the new-post screen. + - Tone suggestions, precomputed in parallel. + - Auto language detection. + - On-device option in Application Preferences. +- AI Alt-Text for images — on-device image description / labeling. + - Suggestions appear in the upload sheet (Google Play build). + +## Desktop + +- Tor Support — full Tor support on the desktop app. + - kmp-tor daemon and settings UI. + - Per-relay routing. + - `.onion` badge. + - Restart-on-toggle. + - Image loading via Tor. +- Multi-account + - Sidebar account switcher. + - Add Account dialog and per-account logout. + - Encrypted account storage. +- Embedded local relay — an in-process relay with SQLite event persistence +- Custom feeds system + - Create, pin and inline-switch between custom feeds. + - Author search in the feed builder (relay NIP-50 + avatars). +- App Drawer with a categorized screen launcher +- Workspace management + - Save, switch and restore workspaces. + - Tabs, an editor and unified search. + - Pin/unpin syncs to the active workspace. +- Namecoin name resolution + - Namecoin lookups now resolve and surface in search. + - Follows the `import` field of name objects (ifa-0001). + - Added `relay.testls.bit` ElectrumX endpoints (clearnet TLS, Tor, bare IP). +- Native theming for macOS, GNOME, KDE and Windows (matches platform look and accent colors) +- Relay power tools + - Dashboard and config editors. + - Per-screen relay picker. + - Persistent configuration. + - Correct counts. +- Messages + - Draggable divider. + - Alignment polish and centered empty states. + - Typography hierarchy and refined dividers. +- macOS polish + - Dock / Cmd+Tab icon via the Taskbar API. + - Apple-HIG squircle margins. + - Transparent window icon. + - Light-mode primary contrast. + - Content extends correctly under the title bar. +- Reading layout — width-capped reading column with comfortable side margins for wide windows +- Compact UI + - Search/Chat/Profile inputs. + - Settings hierarchy normalized. + - Tabs-first headers across Home / Reads / Notifications. + - Whole-card hover on notes. +- Per-OS theming preview flag for testing macOS/GNOME/KDE/Windows looks locally +- Selectable error messages. +- Scrollable single-pane navigation rail. +- Fixes feed loading, repost rendering and Profile back-navigation visibility. + +## Amy (CLI) + +- New `amy`, a non-interactive CLI Nostr client. + - Drives the same Quartz + Commons engine as the apps. + - Available on macOS and Linux from the GitHub Release. +- Subcommands: + - `account` / `use`, `profile`, `post`, `feed`, `notes`. + - `dm send | list | await | send-file` (NIP-17, kind:14 + kind:15). + - `marmot …`. + - `store stat | sweep-expired | scrub | compact`. +- Cache-first reads from a local file-backed event store. + - `relays.json` is gone — `kind:10002 / 10050 / 10051` events in the store *are* the config. +- Secure key storage. + - Private keys move out of `identity.json` into the OS keychain or a NIP-49 encrypted file. + - On-disk data restricted to owner-only. +- Color, human-readable output by default; `--json` opts in. + +## Quartz + +- Adds NIP-AC — WebRTC call signaling events (offer / answer / ICE / hangup / reject / renegotiate) over EphemeralGiftWrap, multi-device, group calls +- Adds EphemeralGiftWrapEvent (kind 21059) — replaces 20s expiration GiftWraps for call signaling +- Adds NIP-A3 Payment Targets (kind 10133) +- Adds NIP-82 Software Applications (experimental) +- Adds the AdminCommandEvent for audio-room kick (kind 4312) +- Adds the NIP-9A community rules parser + validator (kind:34551) +- Expands NIP-34 git collaboration coverage. + - Repository State (kind 30618). + - Pull Requests and PR updates (kinds 1618 / 1619). + - Git Status events (open / closed / draft / applied). +- Adds the rest of NIP-51 list event kinds and full NIP-53 live-activity rendering +- Adds MLS / Marmot event types and a pure-Kotlin MLS engine with IETF RFC 9420 interop test vectors (no native deps) +- Adds an async SQLite event persistence layer. + - NIP-09 / NIP-50 / NIP-62 compliance. + - Room-style connection pool. +- Adds a file-backed event store. + - flock + transactions. + - scrub/compact. + - NIP-50 full-text search. + - NIP-62 Right-to-Vanish. + - NIP-01 tiebreaker. + - NIP-09 created_at window. + - Deletion-author check. +- Adds a reactive `ObservableEventStore` layer. + - A façade that wraps any event store — SQLite-backed, file-backed, or in-memory. + - Publishes a `StoreChange` on every accepted insert, delete and expiration sweep. + - Projections stay in sync without re-querying the store. + - Ephemeral events (kinds 20000-29999) emit without being persisted. + - `EventStoreProjection` turns the change stream into a cold `Flow` of sealed `ProjectionState`. + - Per-filter limits and per-projection NIP-62 vanish scoping. +- Promotes the relay toolkit into the new `geode` module — a real Nostr relay. + - Implements NIP-01 and NIP-45. + - NIP-77 negentropy reconciliation (strfry parity). + - NIP-86 management API. + - TOML config and graceful drain. + - Adaptive connection pooling for 10k+ connections. +- Adds an EventInterner so deserialized events share canonical instances, with an interning event store that interns on insert +- Adds Ktor KMP HTTP implementations alongside OkHttp +- Adds macOS (Apple Silicon), iOS and Linux native targets. + - Pure-Kotlin Ed25519 and X25519 for the MLS crypto on those platforms. + - `commonMain` now compiles for Kotlin/Native. + +## Crypto and Performance + +- Custom secp256k1 implementation, starting to replace `fr.acinq.secp256k1` + - Pure-Kotlin core for KMP native targets (macOS / iOS / Linux). + - C + inline-ASM accelerated path on Android via a JNI bridge. + - Hardware SHA-256 (SHA-NI on x86_64, ARMv8 CE on ARM64). + - Comb method for G multiplication → 3× faster sign/keygen. + - Same-pubkey batch Schnorr verify (5–6× throughput). + - `verifySchnorrFast` for Nostr (skips y-parity inversion). + - 4×64-bit limb representation with lazy field ops and ARM64 assembly. + - Standalone `libsecp256k1-nostr` / `libschnorr256k1` C project, with Android benchmarks. +- Concurrent caching DNS resolver (SurgeDns) + - Lock-free DNS cache. + - 24h positive TTL. + - Stale-while-revalidate. + - Persisted across process restarts. +- Smoother video playback + - Warm ExoPlayer pool and retained warm players. + - Tuned LoadControl. + - VideoCache warmup 10s → 1.5s. + - Stable controller-overlay tree. +- Faster icons — shared FontFamily and TextMeasurer across all Material Symbols +- Faster chat lists — stable list keys and reduced recomposition +- Faster note rendering — cached event-derived values, fewer per-item allocations during feed scroll +- Faster Quartz queries + - Direct-slot driver for replaceable + addressable lookups. + - Streaming k-way merge. + - Smallest-first FTS intersect. + - Parallel Schnorr verify in the ingest queue. + - Index-driven fan-out for cache observers. +- Faster rich-text translation +- Thumbnail disk cache for profile pictures; Coil disk-cache eviction moved off the write path to prevent scroll stalls +- Paginated GiftWrap loading for the DM chat list +- Bookmark events preloaded for faster access +- Lifecycle-aware screen subscriptions + - Feed/screen relay subscriptions pause on background and resume on foreground. + - 30s grace delay so brief app switches don't churn subscriptions. +- Adaptive video disk cache — sized to 20% of free disk (256 MB–4 GB) instead of a fixed 1 GB, with on-demand HLS videos cached in SimpleCache +- Tuned image/video OkHttp dispatcher and connection pool (16 in-flight per host) to de-serialize feed loading +- Streaming image hashing — computes image hashes without loading the whole file into memory; SHA-256 hasher moved off the thread pool +- GeoHash library rewritten from scratch for performance, dropping an external dependency + +## QUIC + nestsClient (foundation) + +- New pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI, no native deps). + - Powers the NIP-53 audio-rooms over MoQ-transport path. +- Full RFC coverage and stabilization: + - RFC 9002 loss recovery and retransmission. + - 0-RTT early data. + - 1-RTT key update. + - TLS 1.3 session resumption (PSK). + - ECN. + - Connection migration with path validation. + - Retry and Version Negotiation packet handling. + - Stateless-reset detection. + - Broad DoS-hardening / RFC-compliance stabilization sweep. +- Passes the quic-interop-runner test matrix against picoquic and quic-go. + - Covers handshake, transfer, multiplexing, retry, 0-RTT, key-update, ECN, http3. + - Includes qlog diagnostics. +- Multiple security and correctness audits. + - RFC 9001 test vectors. + - Live interop against aioquic and picoquic. +- `nestsClient` module + - MoQ-transport (IETF) reference implementation. + - Production moq-lite Lite-03/04 codec with version-aware ALPN negotiation. + - `catalog.json` publishing aligned with kixelated/hang. + - Opus + AudioRecord/AudioTrack. + - Reconnection policy with proactive JWT refresh. + - Cross-stack (Amethyst ↔ Rust ↔ browser) interop harness in CI. + +## Improvements and Fixes +- WakeUp Push Notification events — Starting to migrate to a better Push/Loading system +- Pinned notes moved to their own screen +- Left drawer reorganized into collapsible You / Feeds / Create / System sections, with clearer names +- Article writing redesign — banner, tags, slug +- Redesigned long-form article cards +- GIF support + - Playback controls and autoplay. + - GIF→MP4 upload conversion option in the upload screen. + - GIF / image keyboard support in the short post screen and in Marmot, DM and public-channel chat fields. +- Configurable video player buttons in Account Settings +- Autoplay Videos setting (Always / Never), separate from the video-loading toggle +- Drag-and-drop reordering for some relay list settings +- 3-dot options menu on video / picture / file feed cards +- Zoomable media grows from its source bounds, and loads the full-resolution source in the image dialog +- Favorite relays can now be added to the Global Feed +- Configurable max-hashtag spam filter +- Account setting to forward kind 0 events to a local relay +- Relay Sync UI replaced with visual indicators +- Account Settings + - Split broadcast tracker visibility from Complete UI mode. + - Hide payment-targets icon by default and place it after Zap. + - Float the broadcast banner as a rounded card. +- Danger Zone section in settings +- NIP-89 client tag + - Per-account toggle to disable it, synced via NIP-78 security settings. + - On by default and moved into Compose settings. +- Local Blossom cache — image and profile-picture fetches route through a local Blossom cache +- Mention preservation in compose: + - Survives keyboard auto-correction. + - Partial-overlap edits delete the whole mention. + - Cursor snaps to mention boundaries. +- Chat cursor jumping fixed +- Avatar zoom-in keeps aspect ratio during the animation +- Profile pictures center-cropped to prevent squashing +- HLS video fixes: + - Playback routed to the right MediaSource. + - Multi-rendition videos collapse to a single gallery tile. + - Render with artwork and a graceful fallback. +- Broken "Pause" action removed from the always-on background notification +- Hand-raise button in audio rooms now has a visible toggled state +- GiftWrap unwrapping for all writable accounts when always-on is enabled +- Search bar bech32 paste navigates instead of running a search +- Top and bottom bars stay visible on non-scrollable lists +- Rich-text translation: + - Bug, performance and jitter overhaul. + - `{N}` placeholders so URLs survive CJK translation. +- Swipe-to-dismiss containers fixed on newer Compose +- Right-to-Vanish settings observe toggles reactively, preserve prior behavior on upgrade +- Relay reconnection: + - Auto-reconnect after a server-initiated disconnect. + - Periodic keep-alive to revive relays stuck in long backoff. +- Account settings (profile, follow list, mutes, relay lists, KeyPackages) are republished to newly-selected relays so accounts aren't lost on fresh relays +- Broadcasting relays: + - Kept out of personal & channel sends. + - Always included in non-private sends. + - Fixed an infinite loop in the broadcast-relay computation. +- Tor now falls back to clearnet when bootstrap is stuck +- Android Arti reliability: stale Arti cache cleared on init with retry, SOCKS proxy default port moved with busy-port retry, relay-over-Tor connectivity fixes +- Chess game challenges filtered out of the home feed (ended games only); chess cards show user picture and name instead of hex pubkeys +- Expired polls re-evaluated and removed from notification cards +- NIP-39 external identity claims without a platform separator are rejected +- Dismissible cleanup banner across Pinned Notes, Bookmarks and Bookmark Sets, flagging author-deleted items with a "Remove from list" action +- Bogus Content-Type rejected when saving downloaded media, with URL-extension fallback validation +- NIP-46 bunker decrypt/encrypt response parsing fixed, with a longer timeout +- Hidden DMs no longer counted toward the unread message badge +- Profile header hides the `_@` prefix on NIP-05 names +- Foreground-service-not-allowed exception from the background handled gracefully +- Fixes Samsung crash on outgoing call +- Foreground service starts earlier to prevent call death on Android 14+ +- Stop ringtone and call notification when rejecting consecutive calls + +## UI Refresh + +- Migrates the icon set from Material Icons to Material Symbols (thin weight) for a more consistent, modern look across the app +- Drops unused legacy drawables +- Bottom-bar icon size bumped to compensate for Material Symbols padding + +## Build & Documentation + +- CI restructure: + - Splits Android into its own CI job. + - Adds Android Lint as the first step. + - Merges test+build to eliminate duplicate compilation. + - Drops `assembleDebug` APK uploads. +- Adds a `:nestsClient:test` step to the desktop CI leg +- Adds a quic-interop-runner CI workflow and a browser-side cross-stack interop workflow +- Broadens `libicu` Depends so the `.deb` installs across Debian and Ubuntu +- Adds `SECURITY.md` with private vulnerability reporting policy +- Moves desktop packaging / AppImage tooling into the `desktopApp` module +- AGP and dependencies bumped + +## Contributors + +- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z +- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c +- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 +- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k +- @npub1dn0tej4a5806qk9ts56j572sndvjk27l5qmsxf0z3mquknccve7s4k8tfp + +## Translations + +- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp +- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz +- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm +- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 +- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw +- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t +- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h +- Russian by Anton Zhao + # [Release v1.08.0: Arti](https://github.com/vitorpamplona/amethyst/releases/tag/v1.08.0) - 2026-04-01 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdc9d9945b..56a3348ba6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -164,7 +164,6 @@ Modules: - `amethyst/` — Android app: Activity, layouts, navigation. - `desktopApp/` — Desktop JVM app: Window, sidebar, keyboard shortcuts. - `cli/` — `amy`, a non-interactive JVM CLI over `quartz` + `commons`. -- `ammolite/` — Legacy support module. Per-module design docs live in `/plans/YYYY-MM-DD-.md`. The global `docs/plans/` folder is frozen. diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 57c2f5f539..007d3d387a 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,9 +54,9 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 442 + versionCode = 445 versionName = generateVersionName(libs.versions.app.get()) - buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\"" + buildConfigField "String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -270,7 +270,6 @@ dependencies { implementation project(path: ':quartz') implementation project(path: ':commons') - implementation project(path: ':ammolite') implementation project(path: ':nestsClient') implementation libs.androidx.core.ktx implementation libs.androidx.activity.compose diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index 6e21a524cd..ccd571f29e 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -62,7 +62,6 @@ # JSON parsing -keep class com.vitorpamplona.quartz.** { *; } -keep class com.vitorpamplona.amethyst.** { *; } --keep class com.vitorpamplona.ammolite.** { *; } # Room generates *_Impl subclasses instantiated reflectively via no-arg constructor. # -keepnames preserves the name but R8 still strips the unused (). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cf65c19049..d05a2935f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -219,8 +219,8 @@ class AppModules( // Persists [surgeDns]'s positive cache across process restarts so cold starts don't pay // ~700 sync getaddrinfo calls. Restored entries fall through to the stale-while-revalidate - // path on first lookup. - val dnsStore = SurgeDnsStore(appContext, surgeDns) + // path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it. + val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns) // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = @@ -542,7 +542,7 @@ class AppModules( // image cache in disk for coil val diskCache: DiskCache by lazy { Log.d("AppModules", "ImageCacheFactory Init") - ImageCacheFactory.newDisk(appContext) + ImageCacheFactory.newDisk(appContext, applicationIOScope) } // image cache in memory for coil diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2d1721287d..a631b77d21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -161,7 +161,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds @@ -548,8 +548,8 @@ class Account( return false } - suspend fun updateDisableClientTag(disable: Boolean): Boolean { - if (settings.updateDisableClientTag(disable)) { + suspend fun updateAddClientTag(add: Boolean): Boolean { + if (settings.updateAddClientTag(add)) { sendNewAppSpecificData() return true } @@ -999,7 +999,15 @@ class Account( return true } - fun computeRelayListToBroadcast(event: Event): Set { + fun computeRelayListToBroadcast(event: Event): Set = computeRelayListToBroadcast(event, mutableSetOf()) + + private fun computeRelayListToBroadcast( + event: Event, + visited: MutableSet, + ): Set { + // a-tagged events can form cycles; without this the two recursive descents stack-overflow. + if (!visited.add(event.id)) return emptySet() + if (event is GiftWrapEvent) { val receiver = event.recipientPubKey() return if (receiver != null) { @@ -1078,7 +1086,7 @@ class Account( } linkedNote.event?.let { linkedEvent -> - relayList.addAll(computeRelayListToBroadcast(linkedEvent)) + relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited)) } } } @@ -1099,7 +1107,7 @@ class Account( } linkedNote.event?.let { linkedEvent -> - relayList.addAll(computeRelayListToBroadcast(linkedEvent)) + relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited)) } } } @@ -3061,7 +3069,7 @@ class Account( private fun hasExcessiveHashtags(note: Note): Boolean { val limit = settings.syncedSettings.security.maxHashtagLimit.value - return limit > 0 && (note.event?.countHashtags() ?: 0) > limit + return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true } override fun isAcceptable(note: Note): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 4a5511df5a..94dc103b93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -440,8 +440,8 @@ class AccountSettings( } } - fun updateDisableClientTag(disable: Boolean): Boolean = - if (syncedSettings.security.updateDisableClientTag(disable)) { + fun updateAddClientTag(add: Boolean): Boolean = + if (syncedSettings.security.updateAddClientTag(add)) { saveAccountSettings() true } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index ed4d71e424..6d582ceaac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -56,7 +56,7 @@ class AccountSyncedSettings( MutableStateFlow(internalSettings.security.filterSpamFromStrangers), MutableStateFlow(internalSettings.security.maxHashtagLimit), MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay), - MutableStateFlow(internalSettings.security.disableClientTag), + MutableStateFlow(internalSettings.security.addClientTag), ) val videoPlayer = AccountVideoPlayerPreferences( @@ -85,7 +85,7 @@ class AccountSyncedSettings( security.filterSpamFromStrangers.value, security.maxHashtagLimit.value, security.sendKind0EventsToLocalRelay.value, - security.disableClientTag.value, + security.addClientTag.value, ), videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), ) @@ -146,8 +146,8 @@ class AccountSyncedSettings( security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay) } - if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) { - security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag) + if (security.addClientTag.value != syncedSettingsInternal.security.addClientTag) { + security.addClientTag.tryEmit(syncedSettingsInternal.security.addClientTag) } val newVideoPlayerButtonItems = @@ -247,7 +247,7 @@ class AccountSecurityPreferences( var filterSpamFromStrangers: MutableStateFlow = MutableStateFlow(true), val maxHashtagLimit: MutableStateFlow = MutableStateFlow(5), var sendKind0EventsToLocalRelay: MutableStateFlow = MutableStateFlow(false), - val disableClientTag: MutableStateFlow = MutableStateFlow(false), + val addClientTag: MutableStateFlow = MutableStateFlow(true), ) { fun updateShowSensitiveContent(show: Boolean?): Boolean { if (showSensitiveContent.value != show) { @@ -297,9 +297,9 @@ class AccountSecurityPreferences( false } - fun updateDisableClientTag(disable: Boolean): Boolean = - if (disable != disableClientTag.value) { - disableClientTag.tryEmit(disable) + fun updateAddClientTag(add: Boolean): Boolean = + if (add != addClientTag.value) { + addClientTag.tryEmit(add) true } else { false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 37c277a11c..6273c2a426 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -161,5 +161,5 @@ class AccountSecurityPreferencesInternal( var filterSpamFromStrangers: Boolean = true, val maxHashtagLimit: Int = 5, var sendKind0EventsToLocalRelay: Boolean = false, - var disableClientTag: Boolean = false, + var addClientTag: Boolean = true, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt new file mode 100644 index 0000000000..bcc7b0fa09 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -0,0 +1,85 @@ +/* + * 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.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Cross-screen index of the most recent NIP-34 status event (kinds + * 1630-1633) per target id, kept up to date from + * [LocalCache.live.newEventBundles]. Status events are not tracked in + * `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to + * find them otherwise would be a full cache scan per row. + */ +object GitStatusIndex { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val started = AtomicBoolean(false) + + private val mutableLatestByTarget = MutableStateFlow?>(null) + val latestByTarget: StateFlow?> = mutableLatestByTarget.asStateFlow() + + fun startIfNeeded() { + if (!started.compareAndSet(false, true)) return + scope.launch { + // Subscribe to bundle updates BEFORE the initial scan via onStart, so any + // events that arrive between scan start and collector attach are picked up. + LocalCache.live.newEventBundles + .onStart { + val initial = HashMap() + LocalCache.notes.forEach { _, note -> + val event = note.event as? GitStatusEvent ?: return@forEach + val target = event.rootEventId() ?: return@forEach + val current = initial[target] + if (current == null || event.createdAt > current.createdAt) { + initial[target] = event + } + } + mutableLatestByTarget.value = initial + }.collect { bundle -> processBundle(bundle) } + } + } + + private fun processBundle(bundle: Set) { + val snapshot = mutableLatestByTarget.value ?: emptyMap() + var modified: HashMap? = null + for (note in bundle) { + val event = note.event as? GitStatusEvent ?: continue + val target = event.rootEventId() ?: continue + val map = modified ?: snapshot + val current = map[target] + if (current == null || event.createdAt > current.createdAt) { + if (modified == null) modified = HashMap(snapshot) + modified[target] = event + } + } + modified?.let { mutableLatestByTarget.value = it } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 246b7a9efa..d60787d017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -135,10 +135,15 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -2676,7 +2681,10 @@ object LocalCache : ILocalCache, ICacheProvider { nip19.relay.forEach { relayHint -> relayHints.addEvent(nip19.hex, relayHint) } - getOrCreateNote(nip19.hex) + val note = getOrCreateNote(nip19.hex) + if (note.author == null) { + nip19.author?.let { note.author = checkGetOrCreateUser(it) } + } } is NEmbed -> { @@ -3097,10 +3105,30 @@ object LocalCache : ILocalCache, ICacheProvider { consumeRegularEvent(event, relay, wasVerified) } + is GitPullRequestEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is GitPullRequestUpdateEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is GitStatusEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is GitRepositoryEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } + is GitRepositoryStateEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is UserGraspListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is RootSiteEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 655af6970e..3233e9377a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -139,7 +139,7 @@ class AccountCacheState( NostrSignerWithClientTag( inner = signer, clientName = CLIENT_TAG_NAME, - disabled = { accountSettings.syncedSettings.security.disableClientTag.value }, + disabled = { !accountSettings.syncedSettings.security.addClientTag.value }, ) val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index 142a3dee6c..7a9c63b2e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -306,7 +306,7 @@ class CallAudioManager( .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || - it.type == AudioDeviceInfo.TYPE_BLE_HEADSET + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && it.type == AudioDeviceInfo.TYPE_BLE_HEADSET) } } catch (_: Exception) { false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt index 7406c8e136..38b7b45770 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt @@ -27,8 +27,8 @@ import coil3.annotation.ExperimentalCoilApi import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.network.CacheStrategy -import coil3.network.ConcurrentRequestStrategy import coil3.network.ConnectivityChecker +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.NetworkFetcher import coil3.network.okhttp.asNetworkClient import coil3.request.Options @@ -74,7 +74,7 @@ class BlossomFetcher( diskCache = lazy { imageLoader.diskCache }, cacheStrategy = lazy { CacheStrategy.DEFAULT }, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, - concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, + concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystem.kt new file mode 100644 index 0000000000..d6fe3f61da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystem.kt @@ -0,0 +1,179 @@ +/* + * 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.images + +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import okio.FileHandle +import okio.FileSystem +import okio.ForwardingFileSystem +import okio.Path +import okio.Sink +import java.io.IOException + +/** + * okio [FileSystem] wrapper that moves Coil's eviction `unlink()` out from + * under the `DiskLruCache` lock. + * + * Verified against Coil 3.4.0's `coil3.disk.DiskLruCache`: eviction is not + * inline on the commit thread — `completeEdit()` calls `launchCleanup()`, + * which `launch`es on a limited-parallelism IO scope. But the cleanup + * coroutine runs `trimToSize()` *while holding the global `DiskLruCache` + * lock*, and `trimToSize()` → `removeEntry()` → `fileSystem.delete()` does + * the unlink syscalls under that lock. Every `openSnapshot()` (read) and + * `openEditor()` (write) contends on the same lock. So on a saturated + * cache, each cleanup pass blocks all feed-scroll reads and writes for the + * duration of a burst of `delete()` syscalls — that is the scroll stall + * users see go away after a storage wipe. + * + * This wrapper intercepts `delete()` — exactly the call `removeEntry()` + * makes — and, instead of unlinking inline, enqueues the path and unlinks + * it later on a background coroutine, one path at a time with a `yield()` + * between each so the IO dispatcher stays responsive. `delete()` returns in + * microseconds, so `trimToSize()` releases the lock almost immediately; + * Coil keeps full ownership of LRU order, keys, and `size` accounting, and + * only the syscall is rescheduled off-lock. + * + * Re-create race: if Coil re-fetches a URL whose entry was just evicted, it + * writes a new file at the same path. [atomicMove], [sink], [appendingSink], + * [openReadWrite] and [createDirectory] therefore cancel any pending delete + * of the path they are about to (re)create, so the drainer can never unlink + * a freshly written file. + * + * The drainer unlinks each path while holding [lock], so a concurrent + * write-path call that needs to cancel a delete waits at most one in-flight + * `unlink()` — O(1), never the O(N) burst this class exists to remove. + * + * This instance is dedicated to Coil's image disk cache, so every path it + * sees is a cache file and deferring all `delete()` calls is safe. + */ +class DeferredDeleteFileSystem( + delegate: FileSystem, + scope: CoroutineScope, +) : ForwardingFileSystem(delegate) { + companion object { + private const val TAG = "DeferredDeleteFileSystem" + } + + private val lock = Any() + private val pendingDeletes = LinkedHashSet() + private val wakeUp = Channel(Channel.CONFLATED) + + init { + scope.launch { + for (signal in wakeUp) { + drainPending() + } + } + } + + override fun delete( + path: Path, + mustExist: Boolean, + ) { + synchronized(lock) { pendingDeletes.add(path) } + wakeUp.trySend(Unit) + } + + override fun atomicMove( + source: Path, + target: Path, + ) { + synchronized(lock) { + pendingDeletes.remove(source) + pendingDeletes.remove(target) + } + super.atomicMove(source, target) + } + + override fun sink( + file: Path, + mustCreate: Boolean, + ): Sink { + synchronized(lock) { pendingDeletes.remove(file) } + return super.sink(file, mustCreate) + } + + override fun appendingSink( + file: Path, + mustExist: Boolean, + ): Sink { + synchronized(lock) { pendingDeletes.remove(file) } + return super.appendingSink(file, mustExist) + } + + override fun openReadWrite( + file: Path, + mustCreate: Boolean, + mustExist: Boolean, + ): FileHandle { + synchronized(lock) { pendingDeletes.remove(file) } + return super.openReadWrite(file, mustCreate, mustExist) + } + + override fun createDirectory( + dir: Path, + mustCreate: Boolean, + ) { + synchronized(lock) { pendingDeletes.remove(dir) } + super.createDirectory(dir, mustCreate) + } + + /** Visible for tests: number of paths waiting to be unlinked. */ + fun pendingCount(): Int = synchronized(lock) { pendingDeletes.size } + + /** Visible for tests: unlink everything currently queued, synchronously. */ + fun drainNow() { + while (true) { + synchronized(lock) { + val iterator = pendingDeletes.iterator() + if (!iterator.hasNext()) return + val path = iterator.next() + iterator.remove() + deleteUnderLock(path) + } + } + } + + private suspend fun drainPending() { + while (true) { + synchronized(lock) { + val iterator = pendingDeletes.iterator() + if (!iterator.hasNext()) return + val path = iterator.next() + iterator.remove() + deleteUnderLock(path) + } + yield() + } + } + + private fun deleteUnderLock(path: Path) { + try { + super.delete(path, mustExist = false) + } catch (e: IOException) { + Log.w(TAG, "deferred delete failed: $path", e) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt index ecd00e5626..798a910d05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt @@ -24,14 +24,22 @@ import android.content.Context import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.service.safeCacheDir +import kotlinx.coroutines.CoroutineScope +import okio.FileSystem import okio.Path.Companion.toOkioPath class ImageCacheFactory { companion object { - fun newDisk(app: Context): DiskCache = + fun newDisk( + app: Context, + scope: CoroutineScope, + ): DiskCache = DiskCache .Builder() .directory(app.safeCacheDir().resolve("image_cache").toOkioPath()) + // Defers Coil's inline eviction unlink() onto [scope] so a + // saturated cache doesn't stall feed-scroll IO on the write path. + .fileSystem(DeferredDeleteFileSystem(FileSystem.SYSTEM, scope)) .maxSizePercent(0.2) .maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index f7b25bee4f..da4d6829c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -33,8 +33,8 @@ import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.memory.MemoryCache import coil3.network.CacheStrategy -import coil3.network.ConcurrentRequestStrategy import coil3.network.ConnectivityChecker +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.NetworkFetcher import coil3.network.okhttp.asNetworkClient import coil3.request.Options @@ -149,7 +149,7 @@ class OkHttpFactory( diskCache = lazy { imageLoader.diskCache }, cacheStrategy = cacheStrategyLazy, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, - concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, + concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index 0f85b5926f..c0ff868583 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -30,8 +30,8 @@ import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult import coil3.key.Keyer import coil3.network.CacheStrategy -import coil3.network.ConcurrentRequestStrategy import coil3.network.ConnectivityChecker +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.NetworkFetcher import coil3.network.okhttp.asNetworkClient import coil3.request.Options @@ -113,7 +113,7 @@ class ProfilePictureFetcher( diskCache = diskCacheLazy, cacheStrategy = lazy { CacheStrategy.DEFAULT }, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, - concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, + concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() }, ) return ProfilePictureFetcher( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt index 220c2eec5f..e3599ca3f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import android.content.Intent -import android.net.Uri import android.os.PowerManager import android.provider.Settings +import androidx.core.net.toUri import com.vitorpamplona.quartz.utils.Log /** @@ -54,7 +54,7 @@ object BatteryOptimizationHelper { try { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") + data = "package:${context.packageName}".toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt index 6da1cdff5a..c8f6be168e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt @@ -72,16 +72,17 @@ class LocalBlossomCacheRedirectInterceptor( } private fun findSha256AndExtensionInPath(url: HttpUrl): Triple? { - // Per Blossom (BUD-01) the blob is always the last path segment. If the - // last segment isn't a sha256, this isn't a Blossom URL and the bridge - // must leave it alone — even if an earlier path segment happens to be - // a 64-char hex (e.g. a per-user cache prefix). The prefix segments - // are preserved verbatim via `buildServerBase`. + // Per Blossom (BUD-01) the last path segment must be exactly + // `` or `.`. URLs whose filename merely embeds a + // 64-char hex (e.g. "nostr.build_.jpg") aren't Blossom blobs and + // the bridge must leave them alone — rewriting them would point the + // local cache at a fallback `xs=` server that doesn't host the blob. + // Prefix segments are preserved verbatim via `buildServerBase`. val lastIndex = url.pathSegments.lastIndex if (lastIndex < 0) return null val segment = url.pathSegments[lastIndex] - val match = SHA256_SEGMENT_REGEX.find(segment) ?: return null - val sha = match.value.lowercase() + val match = BLOSSOM_LAST_SEGMENT_REGEX.matchEntire(segment) ?: return null + val sha = match.groupValues[1].lowercase() val ext = guessExtensionFrom(segment, sha) ?: "bin" return Triple(lastIndex, sha, ext) } @@ -119,6 +120,6 @@ class LocalBlossomCacheRedirectInterceptor( const val LOCAL_CACHE_HOST = "127.0.0.1" const val LOCAL_CACHE_PORT = 24242 const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT" - private val SHA256_SEGMENT_REGEX = Regex("(? = try { - val addresses = delegate.lookup(host).ifEmpty { throw UnknownHostException(host) } + val raw = delegate.lookup(host).ifEmpty { throw UnknownHostException(host) } + val addresses = + if (isLoopbackHostname(host)) { + raw + } else { + raw + .filterNot { it.isLoopbackAddress || it.isAnyLocalAddress } + .ifEmpty { throw UnknownHostException(host) } + } putPositive(host, addresses) addresses } catch (e: UnknownHostException) { @@ -220,12 +228,10 @@ class SurgeDns( for ((host, entry) in cache) { if (entry.addresses.isEmpty()) continue if (entry.expiresAtMillis <= now) continue - // hostAddress is platform-typed (String!); mapNotNull narrows to String and stays - // defensive against the rare case where it might be null. - val ips = entry.addresses.mapNotNull { it.hostAddress } - if (ips.isNotEmpty()) { - out += DnsCacheRecord(host, ips, entry.expiresAtMillis) - } + // Raw bytes from getAddress() — no string formatting / re-parsing on either side + // of the persistence boundary. + val ips = entry.addresses.map { it.address } + out += DnsCacheRecord(host, ips, entry.expiresAtMillis) } return out } @@ -233,22 +239,35 @@ class SurgeDns( /** * Restore from a previously taken snapshot. Entries already expired on disk are dropped. * Existing in-memory entries are NOT overwritten (so a fresh lookup that completes before - * restore lands keeps its newer answer). + * restore lands keeps its newer answer). Loopback / any-local addresses (127/8, ::1, 0.0.0.0) + * are filtered out for non-loopback hostnames — they're poison left over from a captive + * portal, ad-blocker DNS, or VPN hiccup at snapshot time. When such addresses are dropped + * the cache is marked dirty so the next persist re-writes the snapshot without them. */ fun restore(records: List) { val now = System.currentTimeMillis() + var droppedPoisoned = false for (record in records) { if (record.expiresAtMillis <= now) continue - val addresses = - record.addresses.mapNotNull { literal -> - // getByName on a numeric literal parses without doing DNS. - runCatching { InetAddress.getByName(literal) }.getOrNull() + val key = record.hostname.lowercase(Locale.ROOT) + val allowLoopback = isLoopbackHostname(key) + val addresses = ArrayList(record.addresses.size) + for (bytes in record.addresses) { + // getByAddress wraps the raw bytes without re-parsing a string literal. + val addr = runCatching { InetAddress.getByAddress(bytes) }.getOrNull() ?: continue + if (!allowLoopback && (addr.isLoopbackAddress || addr.isAnyLocalAddress)) { + droppedPoisoned = true + continue } + addresses += addr + } if (addresses.isNotEmpty()) { - val key = record.hostname.lowercase(Locale.ROOT) cache.putIfAbsent(key, Entry(addresses, record.expiresAtMillis)) } } + // Dirty the cache so the next save rewrites the snapshot without the poisoned entries + // — otherwise they'd be re-restored on every cold start. + if (droppedPoisoned) dirty.set(true) } /** True if the cache has changed since the last [tryClearDirty] / [markDirty] / construction. */ @@ -267,6 +286,39 @@ class SurgeDns( dirty.set(true) } + /** + * True if [host] is itself a loopback identifier — the user-configured `localhost`-style relay + * case where 127.0.0.1 / ::1 in the answer is legitimate and must not be filtered as poison. + * Accepts the DNS name `localhost` and any `.localhost` subdomain (RFC 6761), and IP literals + * that parse to a loopback or any-local address (127.0.0.0/8, ::1, 0.0.0.0). [host] is assumed + * already lowercased. + */ + private fun isLoopbackHostname(host: String): Boolean { + // RFC 1034: a trailing dot is the FQDN form (e.g. `localhost.`), still the same name. + val name = host.trimEnd('.') + if (name == "localhost" || name.endsWith(".localhost")) return true + val literal = parseIpLiteral(name) ?: return false + return literal.isLoopbackAddress || literal.isAnyLocalAddress + } + + /** + * Parses [host] as a numeric IP literal without ever triggering a DNS lookup. Returns null for + * anything that doesn't structurally look like an IPv4 or IPv6 literal — we must not hand a + * plain hostname to [InetAddress.getByName] from inside the resolver. + */ + private fun parseIpLiteral(host: String): InetAddress? { + val candidate = + if (host.startsWith("[") && host.endsWith("]")) { + host.substring(1, host.length - 1) + } else { + host + } + val looksLikeIpv4 = candidate.isNotEmpty() && candidate.all { it.isDigit() || it == '.' } && candidate.contains('.') + val looksLikeIpv6 = candidate.contains(':') + if (!looksLikeIpv4 && !looksLikeIpv6) return null + return runCatching { InetAddress.getByName(candidate) }.getOrNull() + } + private class Entry( val addresses: List, val expiresAtMillis: Long, @@ -286,9 +338,17 @@ class SurgeDns( } } -/** Persistable record. Public so [SurgeDnsStore] can serialize it via Jackson. */ -data class DnsCacheRecord( +/** + * Persistable record. Addresses are stored as raw bytes (4 or 16) so [SurgeDnsStore] can write + * them straight into the binary blob and round-trip through [InetAddress.getByAddress] without + * formatting/parsing a string literal on either side. + * + * Not a `data class`: the auto-generated `equals`/`hashCode` would compare `addresses` + * (List) by reference identity, which is a footgun. Nothing in this codebase needs + * structural equality on records, so the methods are simply not provided. + */ +class DnsCacheRecord( val hostname: String, - val addresses: List, + val addresses: List, val expiresAtMillis: Long, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt index c460f12107..bfbaa0ed54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt @@ -20,43 +20,64 @@ */ package com.vitorpamplona.amethyst.service.okhttp -import android.content.Context -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.utils.Log +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream /** - * Persists [SurgeDns]'s positive cache to a small `SharedPreferences` blob so the resolver - * starts hot after a process restart. + * Persists [SurgeDns]'s positive cache to a small binary blob under the app's cache directory + * so the resolver starts hot after a process restart. * * Cold starts are the resolver's worst case — every host is a sync `getaddrinfo`. With a * persisted snapshot, every previously-seen host falls into the stale-while-revalidate path on * first lookup: the cached IP is served immediately, and a background refresh updates it. That * turns ~700 blocking system calls at app start into zero. * - * The blob is plain (not encrypted) — hostnames are already exposed in the user's signed relay - * list, Coil's image cache, and the system resolver's own state. ~700 entries × ~80 bytes ≈ - * ~55 KB of JSON. + * Stored under `cacheDir` because the snapshot is pure perf — if the OS evicts it under storage + * pressure, the resolver just falls back to sync `getaddrinfo` and rebuilds the cache as + * lookups happen. The blob is plain (not encrypted) — hostnames are already exposed in the + * user's signed relay list, Coil's image cache, and the system resolver's own state. + * + * Format (big-endian, no padding): + * ``` + * header: + * [u32 magic = 0x534E5343] // 'SNSC' + * [u16 version = 1] + * [u32 record_count] + * record (repeated record_count times): + * [u8 host_len] // hostname ASCII, < 256 bytes + * [host_len bytes] // UTF-8 hostname + * [u8 ip_count] + * per ip: + * [u8 ip_byte_len] // 4 or 16 + * [ip_byte_len bytes] // raw, from InetAddress.address + * [i64 expiresAtMillis] + * ``` + * + * ~700 hosts × ~36 bytes ≈ ~25 KB. */ class SurgeDnsStore( - private val context: Context, + private val file: File, private val dns: SurgeDns, ) { - private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } - /** * Read the persisted snapshot and merge it into the resolver. Existing in-memory entries * are preserved (see [SurgeDns.restore]). Safe to call once at app start. Blocking I/O — * call from a background thread. */ fun load() { - val json = prefs.getString(KEY_SNAPSHOT, null) ?: return + if (!file.exists()) return val records = try { - MAPPER.readValue>(json) + readRecords(file) } catch (t: Throwable) { Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" } - prefs.edit().remove(KEY_SNAPSHOT).apply() + file.delete() return } // restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the @@ -75,27 +96,104 @@ class SurgeDnsStore( // captured by the next save instead of being silently lost. compareAndSet ensures two // concurrent saves don't both proceed. if (!dns.tryClearDirty()) return + // Write atomically: write to a sibling tmp file then rename so a crash mid-write + // can't leave a half-written blob that load() would have to throw away. + val tmp = File(file.parentFile, "${file.name}.tmp") try { val records = dns.snapshot() - val json = MAPPER.writeValueAsString(records) - prefs.edit().putString(KEY_SNAPSHOT, json).apply() + file.parentFile?.mkdirs() + writeRecords(tmp, records) + if (!tmp.renameTo(file)) { + file.delete() + if (!tmp.renameTo(file)) { + tmp.copyTo(file, overwrite = true) + } + } Log.d(TAG) { "Persisted ${records.size} DNS cache entries" } } catch (t: Throwable) { Log.w(TAG) { "Failed to persist DNS cache: ${t.message}" } // Restore the dirty signal so the next save retries. dns.markDirty() + } finally { + // Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords + // crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate. + if (tmp.exists()) tmp.delete() } } /** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */ fun clear() { - prefs.edit().remove(KEY_SNAPSHOT).apply() + file.delete() } + private fun writeRecords( + target: File, + records: List, + ) { + DataOutputStream(BufferedOutputStream(FileOutputStream(target))).use { out -> + out.writeInt(MAGIC) + out.writeShort(VERSION) + out.writeInt(records.size) + for (record in records) { + val hostBytes = record.hostname.toByteArray(Charsets.UTF_8) + require(hostBytes.size <= MAX_HOST_LEN) { "hostname too long: ${hostBytes.size}" } + require(record.addresses.size <= MAX_IPS_PER_HOST) { "too many IPs: ${record.addresses.size}" } + out.writeByte(hostBytes.size) + out.write(hostBytes) + out.writeByte(record.addresses.size) + for (ip in record.addresses) { + require(ip.size == 4 || ip.size == 16) { "bad ip byte length: ${ip.size}" } + out.writeByte(ip.size) + out.write(ip) + } + out.writeLong(record.expiresAtMillis) + } + } + } + + private fun readRecords(source: File): List = + DataInputStream(BufferedInputStream(FileInputStream(source))).use { input -> + val magic = input.readInt() + if (magic != MAGIC) throw IllegalStateException("bad magic: 0x${Integer.toHexString(magic)}") + val version = input.readUnsignedShort() + if (version != VERSION) throw IllegalStateException("unsupported version: $version") + val count = input.readInt() + if (count < 0 || count > MAX_RECORDS) throw IllegalStateException("bad record count: $count") + val out = ArrayList(count) + repeat(count) { + val hostLen = input.readUnsignedByte() + // 0-length hostnames are nonsense; treat as corruption rather than restoring them. + if (hostLen == 0) throw IllegalStateException("empty hostname") + val hostBytes = ByteArray(hostLen) + input.readFully(hostBytes) + val hostname = String(hostBytes, Charsets.UTF_8) + val ipCount = input.readUnsignedByte() + if (ipCount == 0 || ipCount > MAX_IPS_PER_HOST) throw IllegalStateException("bad ip count: $ipCount") + val ips = ArrayList(ipCount) + repeat(ipCount) { + val ipLen = input.readUnsignedByte() + if (ipLen != 4 && ipLen != 16) throw IllegalStateException("bad ip byte length: $ipLen") + val ipBytes = ByteArray(ipLen) + input.readFully(ipBytes) + ips += ipBytes + } + val expiresAt = input.readLong() + out += DnsCacheRecord(hostname, ips, expiresAt) + } + out + } + companion object { private const val TAG = "SurgeDnsStore" - private const val PREFS_NAME = "amethyst_dns_cache" - private const val KEY_SNAPSHOT = "dns_cache_v1" - private val MAPPER = jacksonObjectMapper() + const val FILE_NAME = "dns_cache_v1.bin" + + // 'SNSC' — Surge dNS Cache. + private const val MAGIC = 0x534E5343 + private const val VERSION = 1 + private const val MAX_HOST_LEN = 255 + private const val MAX_IPS_PER_HOST = 255 + + // Sane cap so a corrupt header can't trick load() into allocating gigabytes. + private const val MAX_RECORDS = 100_000 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index 8d2be3a9cd..a26dfbdb63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -26,6 +26,9 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.ui.platform.LocalView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.Player import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia @@ -75,6 +78,44 @@ fun ControlWhenPlayerIsActive( listener.destroy() } } + + // Pause when the host activity leaves the foreground; resume the visible + // video when it comes back. The scroll-based mutex above only reacts to + // on-screen position, which doesn't change on background — so without + // this, videos play forever behind another app. + // Skip the explicit BackgroundMedia (PiP) instance: the user opted that + // one into keep-playing. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, mediaControllerState) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + if (controller.isPlaying && !BackgroundMedia.isMutex(mediaControllerState)) { + controller.pause() + } + } + + Lifecycle.Event.ON_RESUME -> { + if (automaticallyStartPlayback && + isClosestToTheCenterOfTheScreen.value && + !controller.isPlaying + ) { + if (BackgroundMedia.hasBackgroundButNot(mediaControllerState)) { + controller.volume = 0f + } + controller.play() + } + } + + else -> { + Unit + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } } class PlayerEventListener( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt index ac8ab8d12c..231745dc8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import java.lang.ref.WeakReference import kotlin.math.abs /** @@ -58,8 +59,10 @@ private var winner: TrackedVideo? = null // Cached result of view.getGlobalVisibleRect. The compose root rarely changes // across a scroll burst; caching avoids N native calls per frame. +// The view is held weakly: it is only used for an identity check, never +// dereferenced, and a strong static reference would leak the Activity. private val cachedRootRect = Rect() -private var cachedRootRectView: View? = null +private var cachedRootRectView: WeakReference? = null private var cachedRootRectVisible: Boolean = false private var cachedRootRectTimeNs: Long = 0L private const val ROOT_RECT_CACHE_TTL_NS = 8_000_000L // ~half a frame at 60fps @@ -191,11 +194,11 @@ private fun electNewWinner() { */ private fun cachedGlobalVisibleRect(view: View): Rect? { val now = System.nanoTime() - if (cachedRootRectView === view && (now - cachedRootRectTimeNs) < ROOT_RECT_CACHE_TTL_NS) { + if (cachedRootRectView?.get() === view && (now - cachedRootRectTimeNs) < ROOT_RECT_CACHE_TTL_NS) { return if (cachedRootRectVisible) cachedRootRect else null } cachedRootRectVisible = view.getGlobalVisibleRect(cachedRootRect) - cachedRootRectView = view + cachedRootRectView = WeakReference(view) cachedRootRectTimeNs = now return if (cachedRootRectVisible) cachedRootRect else null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 5c21ecaf3b..6f5ed7dd56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.pip import android.app.ActivityOptions import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.graphics.Rect import android.os.Build import android.os.Bundle @@ -68,8 +69,11 @@ class PipVideoActivity : ComponentActivity() { } } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) if (!isInPictureInPictureMode) { // User dismissed PiP (swiped away or expanded). finishAndRemoveTask() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 1694849b08..f1ef8d84d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasourc import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler @@ -96,6 +97,7 @@ class RelaySubscriptionsCoordinator( val channel = ChannelFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) + val gitRepository = RepositoryFilterAssembler(client) val thread = ThreadFilterAssembler(client) val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) @@ -156,6 +158,7 @@ class RelaySubscriptionsCoordinator( channel, chatroom, community, + gitRepository, thread, profile, hashtags, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index 1506da4828..30818e74d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -34,6 +34,8 @@ fun potentialRelaysToFindEvent(note: Note): Set { set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex)) + note.author?.outboxRelays()?.let { set.addAll(it) } + LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 165d634c17..cd1c87828d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -27,6 +27,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.Configuration import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle @@ -36,6 +37,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.mutableStateOf +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallState @@ -217,8 +219,11 @@ class CallActivity : AppCompatActivity() { enterPipIfActive() } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) isInPipMode.value = isInPictureInPictureMode } @@ -285,7 +290,6 @@ class CallActivity : AppCompatActivity() { } private fun enterPipIfActive() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val callManager = CallSessionBridge.callManager ?: return val state = callManager.state.value val isActive = @@ -388,7 +392,7 @@ class CallActivity : AppCompatActivity() { addAction(ACTION_PIP_HANGUP) addAction(ACTION_PIP_TOGGLE_MUTE) } - registerReceiver(pipActionReceiver, filter, RECEIVER_NOT_EXPORTED) + ContextCompat.registerReceiver(this, pipActionReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) } private fun unregisterPipReceiver() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index f54f53494c..a9f9c6c16a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -661,6 +662,11 @@ fun BechLink( val baseNote = loadedLink?.baseNote + // Preload the placeholder's author NIP-65 outbox so a missing quoted event can be fetched from there. + baseNote?.author?.let { author -> + UserFinderFilterAssemblerSubscription(author, accountViewModel) + } + if (canPreview && quotesLeft > 0 && baseNote != null) { Row { DisplayFullNote( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 027222f3c6..c352077e42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -45,10 +45,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf @@ -107,17 +107,10 @@ private fun LoadedPdfPreviewCard( ) { val sharePopupExpanded = remember { mutableStateOf(false) } - val density = LocalDensity.current - val configuration = LocalConfiguration.current + val containerWidthPx = LocalWindowInfo.current.containerSize.width val targetWidthPx = - remember(density, configuration) { - val screenPx = - with(density) { - configuration.screenWidthDp.dp - .toPx() - .toInt() - } - screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1) + remember(containerWidthPx) { + containerWidthPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1) } @Suppress("ProduceStateDoesNotAssignValue") @@ -256,7 +249,7 @@ private fun renderFirstPage( renderer.openPage(0).use { page -> val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) // PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output. - val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(renderW, renderH) bitmap.eraseColor(android.graphics.Color.WHITE) page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) PdfLoadState.Ready( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index e9f71e8b74..75fe452184 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.graphics.createBitmap import coil3.disk.DiskCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -398,7 +399,7 @@ private suspend fun renderPageCatching( val (width, height) = cappedRenderSize(page.width, page.height, maxDim) // PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently // rejected and produces blank output. - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bmp = createBitmap(width, height) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) bmp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index c1ef9f4c42..1d7e025cd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent @@ -51,7 +51,7 @@ class FilterByListParams( fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now - fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit + fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.hasMoreHashtagsThan(hiddenLists.maxHashtagLimit) fun isAuthorInFollows(author: HexKey): Boolean { if (followLists == null) return false 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 b18dc7d936..bd38e9d4b4 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 @@ -23,43 +23,31 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState 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 com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding import kotlinx.coroutines.delay import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RefresheableBox( invalidateableContent: InvalidatableContent, enablePullRefresh: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { - var isRefreshing by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val onRefresh: () -> Unit = { - isRefreshing = true - scope.launch { - invalidateableContent.invalidateData() - delay(500) - isRefreshing = false - } - } - if (enablePullRefresh) { - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = onRefresh, - modifier = Modifier.fillMaxSize(), - content = content, - ) + RefresheableBox(onRefresh = invalidateableContent::invalidateData, content = content) } else { Box(Modifier.fillMaxSize(), content = content) } @@ -73,19 +61,35 @@ fun RefresheableBox( ) { var isRefreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val onRefresh: () -> Unit = { - isRefreshing = true - scope.launch { - onRefresh() - delay(500) - isRefreshing = false - } - } + val state = rememberPullToRefreshState() + val topPadding = LocalDisappearingScaffoldPadding.current.calculateTopPadding() PullToRefreshBox( isRefreshing = isRefreshing, - onRefresh = onRefresh, + onRefresh = { + isRefreshing = true + scope.launch { + onRefresh() + delay(500) + isRefreshing = false + } + }, + state = state, modifier = Modifier.fillMaxSize(), + // Anchor the indicator below the scaffold's top bar. Inside a DisappearingScaffold + // the content fills the full screen height (feed items scroll behind the bar), + // so the default top-aligned indicator would sit behind the top bar. Outside a + // scaffold the local default is 0.dp and behaviour is unchanged. + indicator = { + PullToRefreshDefaults.Indicator( + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = topPadding), + isRefreshing = isRefreshing, + state = state, + ) + }, content = content, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 083c9f10ac..04055ec777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen @@ -356,6 +357,7 @@ fun BuildNavigation( composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 9f809abd79..14b5ddcd52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomAppBarDefaults.windowInsets import androidx.compose.material3.HorizontalDivider @@ -49,10 +48,12 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HorzPadding import com.vitorpamplona.amethyst.ui.theme.Size0dp import com.vitorpamplona.amethyst.ui.theme.Size10Modifier -import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size27dp +import com.vitorpamplona.amethyst.ui.theme.Size25Modifier +import com.vitorpamplona.amethyst.ui.theme.Size27Modifier +import com.vitorpamplona.amethyst.ui.theme.onSurface65 /** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]), * exclusive of the system navigation-bar inset. Used by FAB callers that want to @@ -110,6 +111,7 @@ private fun RenderBottomMenu( thickness = DividerThickness, ) NavigationBar( + modifier = HorzPadding, containerColor = MaterialTheme.colorScheme.background, tonalElevation = Size0dp, ) { @@ -151,13 +153,12 @@ private fun NotifiableIcon( destination: Route, accountViewModel: AccountViewModel, ) { - Box(Modifier.size(Size27dp)) { - val iconSizeModifier = Modifier.size(Size24dp) - val description = stringRes(def.labelRes) + Box(Size27Modifier, contentAlignment = Alignment.Center) { Icon( symbol = def.icon, - contentDescription = description, - modifier = iconSizeModifier, + contentDescription = stringRes(def.labelRes), + modifier = Size25Modifier, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, ) AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 65a98d3912..aa46500d90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.bottombars import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.serialization.Serializable @@ -323,7 +322,7 @@ val DrawerFeedsItems: List = NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, - if (isDebug) NavBarItem.NESTS else null, + NavBarItem.NESTS, NavBarItem.LONGS, NavBarItem.POLLS, NavBarItem.BADGES, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt index d1937f06dd..33df344796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.ui.navigation.navs import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.core.net.toUri import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -119,7 +119,7 @@ class BouncingIntentNav( private fun bounce(uri: String) { val intent = - Intent(Intent.ACTION_VIEW, Uri.parse(uri)) + Intent(Intent.ACTION_VIEW, uri.toUri()) .setClass(context, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) runCatching { context.startActivity(intent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index c68fb7a63f..d38d7726b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -144,6 +145,10 @@ fun routeForInner( Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) } + is GitRepositoryEvent -> { + Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } + is GiftWrapEvent -> { noteEvent.innerEventId?.let { routeFor(LocalCache.getOrCreateNote(it), loggedIn) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 11ba830907..3d5ece6229 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -329,6 +329,18 @@ sealed class Route { ) } + @Serializable data class GitRepository( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable data class FollowPack( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt index 9944d64cdb..f3e7a65ee6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.produceState import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay @@ -35,7 +35,7 @@ private const val TICK_INTERVAL_MS = 30_000L // every TimeAgo on screen reads from it. Because TimeAgo wraps the formatted string in // `derivedStateOf`, the Text only recomposes when the displayed string actually changes // (e.g. crossing 1m → 2m) — not on every tick. -val LocalNowSeconds = compositionLocalOf> { mutableStateOf(TimeUtils.now()) } +val LocalNowSeconds = compositionLocalOf> { mutableLongStateOf(TimeUtils.now()) } @Composable fun NowProvider(content: @Composable () -> Unit) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt index 8e72db0c13..86565a8f56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -20,12 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +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.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId @Composable fun DisplayExternalId( @@ -42,6 +67,73 @@ fun DisplayExternalId( DisplayHashtagExternalId(externalId, accountViewModel, nav) } - else -> {} + is UrlId -> { + DisplayUrlExternalId(externalId) + } + + else -> { + DisplayGenericExternalId(externalId) + } + } +} + +@Composable +fun DisplayUrlExternalId(externalId: UrlId) { + val uriHandler = LocalUriHandler.current + DisplayExternalIdChip( + symbol = MaterialSymbols.Link, + contentDescription = stringRes(id = R.string.external_url_scope), + label = externalId.url, + linkInteractionListener = { runCatching { uriHandler.openUri(externalId.url) } }, + ) +} + +@Composable +fun DisplayGenericExternalId(externalId: ExternalId) { + DisplayExternalIdChip( + symbol = MaterialSymbols.Public, + contentDescription = stringRes(id = R.string.external_id_scope), + label = externalId.toScope(), + linkInteractionListener = null, + ) +} + +@Composable +private fun DisplayExternalIdChip( + symbol: MaterialSymbol, + contentDescription: String, + label: String, + linkInteractionListener: LinkInteractionListener?, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + if (linkInteractionListener != null) { + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("externalId", null, linkInteractionListener), + ) { + append(label) + } + } + } else { + buildAnnotatedString { append(label) } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index d8ece23ae8..43b310e905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -20,13 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,12 +40,18 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +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.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.AddressableNote @@ -49,19 +61,22 @@ import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size8dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -70,6 +85,101 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +private val CardShape = QuoteBorder +private val ChipShape = RoundedCornerShape(8.dp) +private val CardPadding = PaddingValues(Size10dp) +private val HeaderSpacing = Arrangement.spacedBy(Size8dp) +private val LinkRowSpacing = Arrangement.spacedBy(Size8dp) + +@Composable +private fun GitCardContainer( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val border = MaterialTheme.colorScheme.subtleBorder + Column( + modifier = + modifier + .fillMaxWidth() + .clip(CardShape) + .border(1.dp, border, CardShape) + .padding(CardPadding), + ) { + content() + } +} + +@Composable +private fun IconBadge( + symbol: MaterialSymbol, + contentDescription: String?, + background: Color, + tint: Color, + size: Dp = 28.dp, + iconSize: Dp = Size16dp, +) { + Row( + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(background), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + modifier = Modifier.size(iconSize), + tint = tint, + ) + } +} + +@Composable +private fun TypeChip( + text: String, + background: Color, + contentColor: Color, +) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = contentColor, + maxLines = 1, + modifier = + Modifier + .clip(ChipShape) + .background(background) + .padding(horizontal = Size8dp, vertical = 2.dp), + ) +} + +@Composable +private fun LinkRow( + symbol: MaterialSymbol, + contentDescription: String?, + url: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = LinkRowSpacing, + ) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + modifier = Modifier.size(Size16dp), + tint = MaterialTheme.colorScheme.grayText, + ) + ClickableUrl( + url = url, + urlText = url.removePrefix("https://").removePrefix("http://"), + ) + } +} + @Composable fun RenderGitPatchEvent( baseNote: Note, @@ -101,26 +211,46 @@ private fun RenderShortRepositoryHeader( nav: INav, ) { val noteEvent by observeNoteEvent(baseNote, accountViewModel) + val title = noteEvent?.name() ?: baseNote.dTag() + val summary = noteEvent?.description() - Column( - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(ChipShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f)) + .clickable { nav.nav(Route.GitRepository(baseNote.address)) } + .padding(horizontal = Size10dp, vertical = Size8dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, ) { - val title = noteEvent?.name() ?: baseNote.dTag() - Text( - text = stringRes(id = R.string.git_repository, title), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), + IconBadge( + symbol = MaterialSymbols.Code, + contentDescription = null, + background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + tint = MaterialTheme.colorScheme.primary, + size = 24.dp, + iconSize = 14.dp, ) - noteEvent?.description()?.let { - Spacer(modifier = DoubleVertSpacer) + Column(modifier = Modifier.weight(1f, fill = true)) { Text( - text = it, + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -136,72 +266,98 @@ private fun RenderGitPatchEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val repository = remember(noteEvent) { noteEvent.repositoryAddress() } + GitCardContainer { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, + ) { + TypeChip( + text = stringRes(id = R.string.kind_git_patch), + background = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f), + contentColor = MaterialTheme.colorScheme.tertiary, + ) - if (repository != null) { - LoadAddressableNote(repository, accountViewModel) { - if (it != null) { - RenderShortRepositoryHeader(it, accountViewModel, nav) - Spacer(modifier = DoubleVertSpacer) + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + + val commit = remember(noteEvent) { noteEvent.commit() } + commit?.takeIf { it.isNotBlank() }?.let { hash -> + Text( + text = hash.take(7), + style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP), + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) } } - } - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } + if (repository != null) { + Spacer(modifier = StdVertSpacer) + LoadAddressableNote(repository, accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) } } + } - val isAuthorTheLoggedUser = - remember(note.event) { accountViewModel.isLoggedUser(note.author) } + Spacer(modifier = HalfDoubleVertSpacer) - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - val callbackUri = remember(note) { note.toNostrUri() } + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = callbackUri, - accountViewModel = accountViewModel, - nav = nav, + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - } + } else { + val callbackUri = remember(note) { note.toNostrUri() } - if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags( - event = noteEvent, - content = eventContent, - callbackUri = callbackUri, + SensitivityWarning( + note = note, accountViewModel = accountViewModel, - nav = nav, - ) + ) { + val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + DisplayUncitedHashtags( + event = noteEvent, + content = eventContent, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } @@ -242,67 +398,83 @@ private fun RenderGitIssueEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val repository = remember(noteEvent) { noteEvent.repositoryAddress() } + GitCardContainer { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, + ) { + TypeChip( + text = stringRes(id = R.string.kind_git_issue), + background = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + contentColor = MaterialTheme.colorScheme.primary, + ) - if (repository != null) { - LoadAddressableNote(repository, accountViewModel) { - if (it != null) { - RenderShortRepositoryHeader(it, accountViewModel, nav) - Spacer(modifier = DoubleVertSpacer) - } + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) } - } - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } + if (repository != null) { + Spacer(modifier = StdVertSpacer) + LoadAddressableNote(repository, accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) } } + } - val isAuthorTheLoggedUser = - remember(note.event) { accountViewModel.isLoggedUser(note.author) } + Spacer(modifier = HalfDoubleVertSpacer) - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - val callbackUri = remember(note) { note.toNostrUri() } + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val tags = - remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = callbackUri, - accountViewModel = accountViewModel, - nav = nav, + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - } + } else { + val callbackUri = remember(note) { note.toNostrUri() } - if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val tags = + remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + } } } } @@ -331,62 +503,62 @@ private fun RenderGitRepositoryEvent( val web = noteEvent.web() val clone = noteEvent.clone() - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ).padding(Size10dp), - ) { - Column { - Text( - text = stringRes(id = R.string.git_repository, title), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), + GitCardContainer { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, + modifier = Modifier.fillMaxWidth(), + ) { + IconBadge( + symbol = MaterialSymbols.Code, + contentDescription = null, + background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + tint = MaterialTheme.colorScheme.primary, ) - summary?.let { + Column(modifier = Modifier.weight(1f, fill = true)) { Text( - text = it, - modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), - maxLines = 3, + text = stringRes(id = R.string.kind_git_repo), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) } + } - HorizontalDivider(thickness = DividerThickness) + summary?.let { + Spacer(modifier = HalfDoubleVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } - web?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.git_web_address), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( + if (web != null || clone != null) { + Spacer(modifier = HalfDoubleVertSpacer) + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + web?.let { + LinkRow( + symbol = MaterialSymbols.Public, + contentDescription = stringRes(id = R.string.git_web_address), url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), ) } - } - - clone?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.git_clone_address), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( + clone?.let { + LinkRow( + symbol = MaterialSymbols.AutoMirrored.OpenInNew, + contentDescription = stringRes(id = R.string.git_clone_address), url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt new file mode 100644 index 0000000000..2425dfd6fc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt @@ -0,0 +1,158 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +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.model.GitStatusIndex +import com.vitorpamplona.amethyst.ui.stringRes +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.GitStatusEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent + +private val PillShape = RoundedCornerShape(8.dp) + +enum class StatusKind { + OPEN, + APPLIED, + CLOSED, + DRAFT, +} + +private fun GitStatusEvent.statusKind(): StatusKind = + when (this) { + is GitStatusAppliedEvent -> StatusKind.APPLIED + is GitStatusClosedEvent -> StatusKind.CLOSED + is GitStatusDraftEvent -> StatusKind.DRAFT + is GitStatusOpenEvent -> StatusKind.OPEN + else -> StatusKind.OPEN + } + +@Composable +fun GitStatusPill( + targetIdHex: String, + modifier: Modifier = Modifier, + defaultIfMissing: StatusKind? = null, +) { + LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } + val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() + val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker + val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return + + GitStatusPill(kind, modifier) +} + +@Composable +fun GitStatusPill( + kind: StatusKind, + modifier: Modifier = Modifier, +) { + val (label, symbol, container, content) = + when (kind) { + StatusKind.OPEN -> { + StatusStyle( + label = stringRes(id = R.string.git_status_open), + symbol = MaterialSymbols.RadioButtonChecked, + container = Color(0xFF1F883D), + content = Color.White, + ) + } + + StatusKind.APPLIED -> { + StatusStyle( + label = stringRes(id = R.string.git_status_merged), + symbol = MaterialSymbols.Check, + container = Color(0xFF8250DF), + content = Color.White, + ) + } + + StatusKind.CLOSED -> { + StatusStyle( + label = stringRes(id = R.string.git_status_closed), + symbol = MaterialSymbols.Cancel, + container = Color(0xFFCF222E), + content = Color.White, + ) + } + + StatusKind.DRAFT -> { + StatusStyle( + label = stringRes(id = R.string.git_status_draft), + symbol = MaterialSymbols.EditNote, + container = MaterialTheme.colorScheme.surfaceVariant, + content = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + modifier = + modifier + .clip(PillShape) + .background(container) + .padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = content, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = content, + ) + } +} + +private data class StatusStyle( + val label: String, + val symbol: MaterialSymbol, + val container: Color, + val content: Color, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index 5aba46fc80..ad363e38a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -203,7 +204,7 @@ private fun ListenToRecordingButton( runCatching { context.startActivity( android.content - .Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) + .Intent(android.content.Intent.ACTION_VIEW, url.toUri()) .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), ) }.isSuccess 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 9c15c15134..ce5eb75ba0 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 @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -54,7 +55,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip73ExternalIds.scope enum class ReplyRenderType { FULL, @@ -119,6 +122,14 @@ fun RenderTextEvent( Spacer(modifier = HalfVertSpacer) } } + } else if (!makeItShort && noteEvent is CommentEvent) { + // A comment scoped to an external identifier (`I` tag) has no in-cache parent + // note. Show the scope itself as the reply context. + val scope = remember(note) { noteEvent.scope() } + if (scope != null) { + DisplayExternalId(scope, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } } } 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 5ad6de3d07..f650d11a6c 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 @@ -1193,7 +1193,7 @@ class AccountViewModel( fun updateReportWarningThreshold(threshold: Int) = launchSigner { account.updateReportWarningThreshold(threshold) } - fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) } + fun updateAddClientTag(add: Boolean) = launchSigner { account.updateAddClientTag(add) } fun updateFilterSpam(filterSpam: Boolean) = launchSigner { @@ -2091,6 +2091,7 @@ class AccountViewModel( } is NEvent -> { + LocalCache.consume(parsed) LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index b0be747f75..dc91f269fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -76,7 +77,7 @@ fun MarmotGroupListScreen( nav: INav, ) { var groupList by remember { mutableStateOf(listOf>()) } - var selectedTab by remember { mutableStateOf(0) } + var selectedTab by remember { mutableIntStateOf(0) } // Load group list LaunchedEffect(Unit) { 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 20699882cb..cba0f06476 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 @@ -55,10 +55,10 @@ class ChatroomListKnownFeedFilter( val publicChannels = account - .publicChatList.flow.value - .mapNotNull { it -> + .publicChatList.flowSet.value + .mapNotNull { channelId -> LocalCache - .getOrCreatePublicChatChannel(it.eventId) + .getOrCreatePublicChatChannel(channelId) .notes .filter { _, it -> account.isAcceptable(it) && it.event != null } .sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt index 3fdefd677d..fcd4be77b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -37,21 +38,29 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.Button import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.BottomStart import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -75,6 +84,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText import kotlinx.coroutines.launch +private const val STAR_INLINE_ID = "star" + @Composable fun FavoriteAlgoFeedsListScreen( accountViewModel: AccountViewModel, @@ -82,6 +93,8 @@ fun FavoriteAlgoFeedsListScreen( ) { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes + .collectAsStateWithLifecycle() DisappearingScaffold( isInvertedLayout = false, @@ -113,65 +126,67 @@ fun FavoriteAlgoFeedsListScreen( bottom = padding.calculateBottomPadding(), ).consumeWindowInsets(padding), ) { - Text( - text = stringRes(R.string.favorite_dvms_explainer), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - ) - - FavoriteAlgoFeedList(listState, accountViewModel, nav) + if (favorites.isEmpty()) { + Text( + text = stringRes(R.string.favorite_dvms_explainer), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + FavoriteAlgoFeedsEmptyState(nav) + } else { + FavoriteAlgoFeedList(favorites, listState, accountViewModel, nav) + } } } } @Composable -private fun FavoriteAlgoFeedList( +private fun FavoriteAlgoFeedsEmptyState(nav: INav) { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + symbol = MaterialSymbols.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.favorite_dvms_empty_headline), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + FavoriteAlgoFeedsEmptySteps( + ctaLabel = stringRes(R.string.favorite_dvms_empty_cta), + modifier = Modifier.widthIn(max = 320.dp), + ) + } + Spacer(modifier = Modifier.weight(1f)) + BrowseAlgosButton( + label = stringRes(R.string.favorite_dvms_empty_cta), + nav = nav, + ) + } +} + +@Composable +private fun ColumnScope.FavoriteAlgoFeedList( + favorites: List, listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, ) { - val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes - .collectAsStateWithLifecycle() - - if (favorites.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Icon( - symbol = MaterialSymbols.AutoAwesome, - contentDescription = null, - modifier = Modifier.size(72.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(R.string.favorite_dvms_empty_headline), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringRes(R.string.favorite_dvms_empty_subtitle), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - modifier = Modifier.widthIn(max = 280.dp), - ) - Button(onClick = { nav.nav(Route.Discover(initialTab = DiscoverTab.ALGOS)) }) { - Text(text = stringRes(R.string.favorite_dvms_empty_cta)) - } - } - } - return - } - LazyColumn( + modifier = Modifier.weight(1f), state = listState, verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = FeedPadding, @@ -188,6 +203,25 @@ private fun FavoriteAlgoFeedList( ) } } + BrowseAlgosButton( + label = stringRes(R.string.favorite_dvms_add_more), + nav = nav, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 32.dp), + ) +} + +@Composable +private fun BrowseAlgosButton( + label: String, + nav: INav, + modifier: Modifier = Modifier, +) { + Button( + onClick = { nav.nav(Route.Discover(initialTab = DiscoverTab.ALGOS)) }, + modifier = modifier, + ) { + Text(text = label) + } } @OptIn(ExperimentalFoundationApi::class) @@ -267,3 +301,82 @@ private fun FavoriteAlgoFeedRow( } } } + +@Composable +private fun FavoriteAlgoFeedsEmptySteps( + ctaLabel: String, + modifier: Modifier = Modifier, +) { + val step2Template = stringRes(R.string.favorite_dvms_empty_step2) + val primaryColor = MaterialTheme.colorScheme.primary + + val step2Text = + remember(step2Template) { + val parts = step2Template.split("%1\$s", limit = 2) + buildAnnotatedString { + append(parts.first()) + appendInlineContent(STAR_INLINE_ID, "star") + if (parts.size > 1) append(parts[1]) + } + } + + val starInlineContent = + remember(primaryColor) { + mapOf( + STAR_INLINE_ID to + InlineTextContent( + Placeholder( + width = 18.sp, + height = 18.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + symbol = MaterialSymbols.StarBorder, + contentDescription = null, + tint = primaryColor, + ) + }, + ) + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + NumberedStep(number = "1.") { + Text( + text = stringRes(R.string.favorite_dvms_empty_step1, ctaLabel), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + NumberedStep(number = "2.") { + Text( + text = step2Text, + inlineContent = starInlineContent, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } +} + +@Composable +private fun NumberedStep( + number: String, + content: @Composable () -> Unit, +) { + Row( + modifier = Modifier.semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.Top, + ) { + Text( + text = number, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(end = 8.dp), + ) + content() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt new file mode 100644 index 0000000000..e53b7b54b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt @@ -0,0 +1,251 @@ +/* + * 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.gitRepo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +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.model.LocalCache +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +private val SectionCardShape = RoundedCornerShape(15.dp) +private val SectionSpacing = Arrangement.spacedBy(12.dp) +private val LinkSpacing = Arrangement.spacedBy(8.dp) + +@Composable +fun GitRepositoryOverview( + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = SectionSpacing, + ) { + TitleHeader(event) + + val description = event.description() + if (!description.isNullOrBlank()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_about)) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + val webs = event.webs() + val clones = event.clones() + if (webs.isNotEmpty() || clones.isNotEmpty()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_links)) { + Column(verticalArrangement = LinkSpacing) { + webs.forEach { LinkLine(symbol = MaterialSymbols.Public, url = it) } + clones.forEach { LinkLine(symbol = MaterialSymbols.AutoMirrored.OpenInNew, url = it) } + } + } + } + + val topics = event.hashtags() + if (topics.isNotEmpty()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_topics)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + topics.forEach { TopicChip(it) } + } + } + } + + val maintainers = listOfNotNull(event.pubKey).plus(event.maintainers()).distinct() + if (maintainers.isNotEmpty()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_maintainers)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + maintainers.forEach { hex -> + MaintainerRow(pubKeyHex = hex, accountViewModel = accountViewModel, nav = nav) + } + } + } + } + } +} + +@Composable +private fun TitleHeader(event: GitRepositoryEvent) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + symbol = MaterialSymbols.Code, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = event.name() ?: event.dTag(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (event.isPersonalFork()) { + Spacer(Modifier.height(4.dp)) + TopicChip(stringRes(id = R.string.git_repo_personal_fork)) + } + } + } +} + +@Composable +private fun SectionCard( + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(SectionCardShape) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + content() + } +} + +@Composable +private fun LinkLine( + symbol: MaterialSymbol, + url: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(Size16dp), + tint = MaterialTheme.colorScheme.grayText, + ) + ClickableUrl( + url = url, + urlText = url.removePrefix("https://").removePrefix("http://"), + ) + } +} + +@Composable +private fun TopicChip(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = Font12SP), + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) +} + +@Composable +private fun MaintainerRow( + pubKeyHex: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = LocalCache.checkGetOrCreateUser(pubKeyHex) ?: return + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture( + baseUser = user, + size = 32.dp, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, + ) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + ) + } +} 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 new file mode 100644 index 0000000000..ed8aa26439 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -0,0 +1,251 @@ +/* + * 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.gitRepo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import kotlinx.coroutines.launch + +@Composable +fun GitRepositoryScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { note -> + note?.let { + PrepareGitRepositoryScreen( + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun PrepareGitRepositoryScreen( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val issuesViewModel: RepositoryIssuesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoIssues", + factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account), + ) + + val patchesViewModel: RepositoryPatchesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoPatches", + factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account), + ) + + GitRepositoryScreen( + note = note, + issuesViewModel = issuesViewModel, + patchesViewModel = patchesViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GitRepositoryScreen( + note: AddressableNote, + issuesViewModel: RepositoryIssuesFeedViewModel, + patchesViewModel: RepositoryPatchesFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(issuesViewModel) + WatchLifecycleAndUpdateModel(patchesViewModel) + + val event by observeNoteEvent(note, accountViewModel) + + // RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't + // a GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter + // when note.event later mutates, so subscribing before the event has arrived (cold-start + // / deep-link case) leaves an empty filter forever. Gate the subscription composable on + // event presence so it enters composition (and fires DisposableEffect → subscribe → fresh + // updateFilter) only once the repo event is loaded. + if (event != null) { + RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository) + } + + val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + ShorterTopAppBar( + title = { + TopBarTitle(event = event, fallback = note.dTag()) + }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } + }, + ) + + SecondaryTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + text = { Text(stringRes(R.string.git_repo_tab_overview)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + ) + Tab( + selected = pagerState.currentPage == 1, + text = { Text(stringRes(R.string.git_repo_tab_issues)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + ) + Tab( + selected = pagerState.currentPage == 2, + text = { Text(stringRes(R.string.git_repo_tab_patches)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + ) + } + } + }, + accountViewModel = accountViewModel, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (page) { + 0 -> { + val currentEvent = event + if (currentEvent != null) { + GitRepositoryOverview(currentEvent, accountViewModel, nav) + } else { + EmptyMessage(stringRes(R.string.loading_feed)) + } + } + + 1 -> { + RefresheableFeedView( + viewModel = issuesViewModel, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + 2 -> { + RefresheableFeedView( + viewModel = patchesViewModel, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } +} + +@Composable +private fun TopBarTitle( + event: GitRepositoryEvent?, + fallback: String, +) { + Text( + text = event?.name() ?: fallback, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun EmptyMessage(text: String) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(24.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + } +} 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 new file mode 100644 index 0000000000..a9764da996 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt @@ -0,0 +1,55 @@ +/* + * 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.gitRepo.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent + +class RepositoryIssuesFeedFilter( + val repositoryNote: AddressableNote, + val account: Account, +) : AdditiveFeedFilter() { + private val repositoryAddressId = repositoryNote.address.toValue() + + override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + repositoryNote.idHex + + override fun feed(): List { + val result = + LocalCache.notes.mapNotNullIntoSet { _, note -> + if (matches(note)) note else null + } + return sort(result) + } + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { matches(it) } + + private fun matches(note: Note): Boolean { + val event = note.event as? GitIssueEvent ?: return false + return event.repositoryHex() == repositoryAddressId + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt new file mode 100644 index 0000000000..24d8239407 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt @@ -0,0 +1,40 @@ +/* + * 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.gitRepo.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +class RepositoryIssuesFeedViewModel( + val note: AddressableNote, + val account: Account, +) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = RepositoryIssuesFeedViewModel(note, account) as T + } +} 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 new file mode 100644 index 0000000000..243ee020bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt @@ -0,0 +1,60 @@ +/* + * 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.gitRepo.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent + +class RepositoryPatchesFeedFilter( + val repositoryNote: AddressableNote, + val account: Account, +) : AdditiveFeedFilter() { + private val repositoryAddressId = repositoryNote.address.toValue() + + override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + repositoryNote.idHex + + override fun feed(): List { + val result = + LocalCache.notes.mapNotNullIntoSet { _, note -> + if (matches(note)) note else null + } + return sort(result) + } + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { matches(it) } + + private fun matches(note: Note): Boolean { + val event = note.event ?: return false + return when (event) { + is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId + is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId + else -> false + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt new file mode 100644 index 0000000000..629d7bb4d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt @@ -0,0 +1,40 @@ +/* + * 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.gitRepo.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +class RepositoryPatchesFeedViewModel( + val note: AddressableNote, + val account: Account, +) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = RepositoryPatchesFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt new file mode 100644 index 0000000000..f5b05be87d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt @@ -0,0 +1,60 @@ +/* + * 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.gitRepo.datasource + +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +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 + +val RepositoryContentKinds = + listOf( + GitIssueEvent.KIND, + GitPatchEvent.KIND, + GitPullRequestEvent.KIND, + GitStatusOpenEvent.KIND, + GitStatusAppliedEvent.KIND, + GitStatusClosedEvent.KIND, + GitStatusDraftEvent.KIND, + ) + +fun filterRepositoryContent( + relay: NormalizedRelayUrl, + repository: GitRepositoryEvent, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to listOf(repository.addressTag())), + kinds = RepositoryContentKinds, + limit = 500, + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryContentSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryContentSubAssembler.kt new file mode 100644 index 0000000000..4f17b81b46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryContentSubAssembler.kt @@ -0,0 +1,60 @@ +/* + * 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.gitRepo.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +class RepositoryContentSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + if (keys.isEmpty()) return emptyList() + + return keys.flatMap { key -> + val repoEvent = key.repository.event as? GitRepositoryEvent ?: return@flatMap emptyList() + + val announcedRelays = repoEvent.relays().mapNotNull(RelayUrlNormalizer::normalizeOrNull) + val hintRelays = + if (announcedRelays.isEmpty()) { + LocalCache.relayHints.hintsForAddress(repoEvent.addressTag()) + } else { + emptyList() + } + val repoRelays = (announcedRelays + hintRelays + key.repository.relayUrls()).toSet() + + repoRelays.map { relay -> + filterRepositoryContent(relay, repoEvent, since?.get(relay)?.time) + } + } + } + + override fun distinct(key: RepositoryQueryState) = key.repository.idHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssembler.kt new file mode 100644 index 0000000000..68e0c5f77a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssembler.kt @@ -0,0 +1,44 @@ +/* + * 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.gitRepo.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +class RepositoryQueryState( + var repository: AddressableNote, +) + +class RepositoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RepositoryContentSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..bf801b376f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssemblerSubscription.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.ui.screen.loggedIn.gitRepo.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.AddressableNote + +@Composable +fun RepositoryFilterAssemblerSubscription( + repository: AddressableNote, + filterAssembler: RepositoryFilterAssembler, +) { + val state = + remember(repository) { + RepositoryQueryState(repository) + } + + LifecycleAwareKeyDataSourceSubscription(state, filterAssembler) +} 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 cb69b582ce..4023d544f6 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 @@ -227,16 +227,21 @@ private fun HomePages( Column { HomeTopBar(accountViewModel, nav) if (tabs.size > 1) { + // Clamp to a valid index: rememberForeverPagerState persists + // currentPage across tab-count changes, so toggling off a home + // tab can leave currentPage > tabs.lastIndex for a frame, which + // crashes Material3's TabIndicatorOffsetNode. + val selectedTab = pagerState.currentPage.coerceIn(0, tabs.lastIndex) SecondaryTabRow( containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, - selectedTabIndex = pagerState.currentPage, + selectedTabIndex = selectedTab, ) { val coroutineScope = rememberCoroutineScope() tabs.forEachIndexed { index, tab -> Tab( - selected = pagerState.currentPage == index, + selected = selectedTab == index, text = { Text(text = stringRes(tab.resource)) }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, ) @@ -248,7 +253,7 @@ private fun HomePages( bottomBar = { AppBottomBar(Route.Home, nav, accountViewModel) { route -> if (route == Route.Home) { - tabs[pagerState.currentPage].feedState.sendToTop() + tabs.getOrNull(pagerState.currentPage)?.feedState?.sendToTop() } else { nav.navBottomBar(route) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index a189a7ce3f..35df6bc622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -29,7 +29,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -94,7 +96,7 @@ fun CreateNestSheet( sheetState = sheetState, ) { Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 5d2ba8643c..74432b8466 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Per-room state for every wire subscription scoped to a single @@ -56,11 +57,11 @@ class NestRoomQueryState( * Single per-room sub-assembler. Issues two [Filter]s per outbox * relay on every key update: * - * 1. `kinds=[1311, 10312, 7]`, `#a=[roomATag]` — the chat, presence - * and reaction streams the room consumes. Bundling them into - * one Filter is safe: a single Filter requires (kind ∈ kinds) - * AND (every tag dimension matches), and these three streams - * share the same `#a` gate. + * 1. `kinds=[1311, 10312, 7, 9735]`, `#a=[roomATag]` — the chat, + * presence, reaction and zap-receipt streams the room consumes. + * Bundling them into one Filter is safe: a single Filter + * requires (kind ∈ kinds) AND (every tag dimension matches), + * and these streams all share the same `#a` gate. * * 2. `kinds=[4312]`, `#a=[roomATag]`, `#p=[localPubkey]` — admin * commands. Stays separate from (1) because the additional @@ -94,6 +95,7 @@ class NestRoomFilterSubAssembler( LiveActivitiesChatMessageEvent.KIND, MeetingRoomPresenceEvent.KIND, ReactionEvent.KIND, + LnZapEvent.KIND, ), tags = mapOf("a" to listOf(key.note.idHex)), since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt index 1ff53d4819..f40c7277b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt @@ -27,6 +27,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.Configuration import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle @@ -38,6 +39,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.StringResSetup +import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -190,6 +193,24 @@ class NestActivity : AppCompatActivity() { onLeave = { finish() }, onMinimize = { enterPip() }, ) + + // Mirror MainActivity's `AppNavigation` → render toasts + // inside *this* Activity's Compose tree so promote / + // demote / kick / room-edit results actually surface in + // front of the room UI. Without this, every + // `toastManager.toast(...)` from nest code queues into a + // StateFlow whose only collector lives in MainActivity — + // the dialog only becomes visible after the user PIPs out. + // EmptyNav is the documented stub for hosts that have no + // navigation graph; the only consumer that reads `nav` + // is `MultiErrorToastMsg` (zap-error aggregator) — nest + // call sites emit `ResourceToastMsg` / `StringToastMsg` + // which don't touch nav. + DisplayErrorMessages( + toastManager = accountViewModel.toastManager, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) } } } @@ -244,8 +265,11 @@ class NestActivity : AppCompatActivity() { } } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) isInPipMode.value = isInPictureInPictureMode } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index 28a889ff05..e82ce21db3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -251,6 +251,14 @@ private fun NestActivityBody( participants = event.participants(), presences = presences, hostPubkey = event.pubKey, + // Treat presence as authoritative only when fresher + // than the role grant. nostrnests audience members + // emit kind-10312 with `onstage=0` and never flip + // it back on after a promotion — without this gate, + // a freshly-promoted speaker would render in the + // audience tab with role=SPEAKER, looking unchanged + // to the host who just promoted them. + roleGrantSec = event.createdAt, ) } val onStageKeys = @@ -282,6 +290,19 @@ private fun NestActivityBody( } } + // Re-sync the optimistic local intent flag with the role-derived + // on-stage state whenever the user becomes on stage. Symmetric to + // the auto-stop above: when `isOnStageMe` flips to true (initial + // promotion or a re-promotion after a previous voluntary leave), + // also flip `ui.onStageNow` back to true so [StageControlsBar]'s + // `!ui.onStageNow` gate doesn't hide the talk button when their + // role says they CAN talk. Without this, a promoted speaker + // appeared on the stage grid but saw no speaker controls + // — confirmed during testing on 2026-05-13. + LaunchedEffect(isOnStageMe) { + if (isOnStageMe) viewModel.setOnStage(true) + } + // Single REQ per relay covering chat, presence, reactions, admin // commands. See NestRoomFilterAssembler for the filter shape. NestRoomFilterAssemblerSubscription(roomNote, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt index 8227f95199..494b1b9380 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt @@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -108,60 +111,80 @@ fun EditNestSheet( ) } ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + // Two-region layout so the keyboard never squashes the action + // row: the form scrolls inside its own viewport, the bottom + // buttons stay anchored. `imePadding()` on the outer Column + // lifts the whole sheet above the keyboard, so the anchored + // row sits just above the IME instead of behind it. Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().imePadding(), ) { - Text( - text = stringRes(R.string.nest_edit_title), - style = MaterialTheme.typography.titleLarge, - ) - - OutlinedTextField( - value = state.roomName, - onValueChange = viewModel::setRoomName, - label = { Text(stringRes(R.string.nest_create_field_room)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.summary, - onValueChange = viewModel::setSummary, - label = { Text(stringRes(R.string.nest_create_field_summary)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.serviceUrl, - onValueChange = viewModel::setServiceUrl, - label = { Text(stringRes(R.string.nest_create_field_service)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.endpointUrl, - onValueChange = viewModel::setEndpointUrl, - label = { Text(stringRes(R.string.nest_create_field_endpoint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.imageUrl, - onValueChange = viewModel::setImageUrl, - label = { Text(stringRes(R.string.nest_create_field_image)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - state.error?.let { error -> + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + text = stringRes(R.string.nest_edit_title), + style = MaterialTheme.typography.titleLarge, ) - } - Spacer(Modifier.height(4.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedTextField( + value = state.roomName, + onValueChange = viewModel::setRoomName, + label = { Text(stringRes(R.string.nest_create_field_room)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.summary, + onValueChange = viewModel::setSummary, + label = { Text(stringRes(R.string.nest_create_field_summary)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.serviceUrl, + onValueChange = viewModel::setServiceUrl, + label = { Text(stringRes(R.string.nest_create_field_service)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.endpointUrl, + onValueChange = viewModel::setEndpointUrl, + label = { Text(stringRes(R.string.nest_create_field_endpoint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.imageUrl, + onValueChange = viewModel::setImageUrl, + label = { Text(stringRes(R.string.nest_create_field_image)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + state.error?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(4.dp)) + } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { // Destructive — flips status to CLOSED with the same d-tag // so subscribers see the room go dark. Confirm prompt // gates the actual publish so a misclick on the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index 5e67573eac..4ac548b7a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -61,6 +62,8 @@ internal fun NestRoomEventCollectors( ChatCollector(viewModel, roomATag) ReactionsCollector(viewModel, roomATag) ReactionsEvictionTicker(viewModel) + ZapsCollector(viewModel, roomATag) + ZapsEvictionTicker(viewModel) AdminCommandsCollector(viewModel, event, roomATag, localPubkey) } @@ -144,6 +147,57 @@ private fun ReactionsCollector( } } +/** + * Pump kind-9735 zap receipts into the VM's chat ledger AND the + * floating zap-overlay aggregator. The same note flows two places: + * + * 1. [NestViewModel.onChatEvent] — `ChatroomMessageCompose` routes + * `LnZapEvent` notes through `RenderChatZap`, which is the same + * card live streams use. Sharing the chat ledger keeps zap + * cards interleaved with kind-1311 chat messages in time order. + * + * 2. [NestViewModel.onZapEvent] — same sliding-window pattern as + * reactions; drives the floating "⚡ Nsats" chip over the + * targeted participant's avatar (or the room itself). + */ +@Composable +private fun ZapsCollector( + viewModel: NestViewModel, + roomATag: String, +) { + LaunchedEffect(viewModel, roomATag) { + val filter = + Filter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("a" to listOf(roomATag)), + ) + LocalCache.observeNotes(filter).collect { notes -> + val nowSec = System.currentTimeMillis() / 1000 + notes.forEach { note -> + viewModel.onChatEvent(note) + (note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } + } + } + } +} + +@Composable +private fun ZapsEvictionTicker(viewModel: NestViewModel) { + // Mirrors [ReactionsEvictionTicker] — only tick while there are + // zaps to evict. The aggregator's last eviction empties + // [NestViewModel.recentZaps], which flips [hasZaps] to false and + // cancels the loop until the next zap arrives. + val zaps by viewModel.recentZaps.collectAsState() + val hasZaps = zaps.values.any { it.isNotEmpty() } + LaunchedEffect(viewModel, hasZaps) { + if (!hasZaps) return@LaunchedEffect + while (isActive) { + delay(REACTIONS_TICK_MS) + viewModel.evictZaps(System.currentTimeMillis() / 1000 - REACTION_WINDOW_SEC_LOCAL) + } + } +} + @Composable private fun ReactionsEvictionTicker(viewModel: NestViewModel) { // Only tick while there are reactions to evict. The aggregator's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt index 2e44feb771..2942f58db0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt @@ -48,7 +48,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -435,7 +435,7 @@ private fun RoomHeader( */ @Composable private fun CachedListenerCount(roomATag: String) { - var count by remember(roomATag) { mutableStateOf(0) } + var count by remember(roomATag) { mutableIntStateOf(0) } LaunchedEffect(roomATag) { val filter = Filter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index 9d3dfa4fa4..deaceab27d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -50,6 +49,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog @@ -71,6 +72,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Per-participant context sheet (T2 #2). Long-press on any participant @@ -100,12 +104,12 @@ internal fun ParticipantHostActionsSheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val targetUser = remember(target) { LocalCache.getOrCreateUser(target) } - // Reactive display name — UsernameDisplay observes this for the - // header, and we reuse the same flow for toast text so the header - // and toast never disagree (e.g. metadata arriving mid-sheet). + // Reactive display name for confirmation-dialog bodies (kick / + // force-mute) — `observeUserInfo` lets the dialog text track a + // late-arriving metadata event without re-opening the sheet. val userInfo by observeUserInfo(targetUser, accountViewModel) val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex() - val toasts = rememberParticipantToasts(displayName) + val toasts = rememberParticipantToasts() var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) { mutableStateOf(null) @@ -174,7 +178,6 @@ internal fun ParticipantHostActionsSheet( // so future presence events don't re-render them broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target)) broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target)) - accountViewModel.toastManager.toast(toasts.title, toasts.kickSent) onDismiss() }, onDismiss = { openDialog = null }, @@ -190,7 +193,6 @@ internal fun ParticipantHostActionsSheet( openDialog = null val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null) broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target)) - accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent) onDismiss() }, onDismiss = { openDialog = null }, @@ -273,10 +275,8 @@ private fun AudienceActions( ) { if (isFollowing) { accountViewModel.unfollow(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent) } else { accountViewModel.follow(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.followSent) } onDismiss() } @@ -289,10 +289,8 @@ private fun AudienceActions( ) { if (isHidden) { accountViewModel.show(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent) } else { accountViewModel.hide(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.muteSent) } onDismiss() } @@ -337,13 +335,22 @@ private fun HostActions( val nestPresences by nestViewModel.presences.collectAsState() val targetIsBroadcasting = nestPresences[target]?.publishing == true - fun hostAction( - template: EventTemplate?, - toastMsg: String, - ) { - broadcast(accountViewModel, template) - accountViewModel.toastManager.toast(toasts.title, toastMsg) + fun hostAction(template: EventTemplate?) { + // Sign+publish runs inside a coroutine that surfaces ANY + // failure as a toast. Success is confirmed by the UI moving + // the target between stage/audience (or removing them), so + // no success toast is needed — the previous "Promoted X" + // toast was redundant once the kind-30312 + stale-presence + // fixes made the grid update visible. Silent signer failures + // (NIP-46 timeout, manual denial, etc.) still need to + // surface because they're invisible from the host's POV. + if (template == null) { + accountViewModel.toastManager.toast(toasts.failedTitle, toasts.failedTemplateNull) + onDismiss() + return + } onDismiss() + broadcastWithDiagnostics(accountViewModel, template, toasts.failedTitle) } Spacer(Modifier.height(4.dp)) @@ -353,12 +360,12 @@ private fun HostActions( // wasted relay traffic and an obvious UX tell. if (targetRole != ROLE.SPEAKER) { ActionRow(stringRes(R.string.nest_promote_speaker)) { - hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER), toasts.promoteSpeakerSent) + hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER)) } } if (targetRole != ROLE.MODERATOR) { ActionRow(stringRes(R.string.nest_promote_moderator)) { - hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR), toasts.promoteModeratorSent) + hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR)) } } // Demote only when the target actually has a role above listener — @@ -366,7 +373,7 @@ private fun HostActions( // and the visible row sets up a misleading expectation. if (targetRole != null && targetRole != ROLE.PARTICIPANT) { ActionRow(stringRes(R.string.nest_demote_listener)) { - hostAction(RoomParticipantActions.demoteToListener(event, target), toasts.demoteListenerSent) + hostAction(RoomParticipantActions.demoteToListener(event, target)) } } // Force-mute is honor-based: the target's client must honor the @@ -498,6 +505,36 @@ private fun broadcast( accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) } } +/** + * Sign+publish a host-action template and surface only FAILURES as + * a toast. Success is implicit — the UI updates when the kind-30312 + * round-trips, so a success toast would be redundant. Any throw + * (including the silent ones [launchSigner] Log.w-only's — + * `TimedOutException`, `ManuallyUnauthorizedException`, + * `CouldNotPerformException`, + * `RunningOnBackgroundWithoutAutomaticPermissionException`, + * `AutomaticallyUnauthorizedException`) becomes a visible error + * toast with the exception's class name + message so the host knows + * a remote-signer denial happened. + */ +private fun broadcastWithDiagnostics( + accountViewModel: AccountViewModel, + template: EventTemplate, + failedTitle: String, +) { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + try { + accountViewModel.account.signAndComputeBroadcast(template) + } catch (ce: CancellationException) { + throw ce + } catch (e: Throwable) { + val klass = e::class.simpleName ?: "Throwable" + val msg = e.message?.takeIf { it.isNotBlank() } ?: "no message" + accountViewModel.toastManager.toast(failedTitle, "$klass: $msg") + } + } +} + private fun openProfileInMainActivity( context: Context, target: String, @@ -509,7 +546,7 @@ private fun openProfileInMainActivity( runCatching { context.startActivity( Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse("nostr:$npub") + data = "nostr:$npub".toUri() setClass(context, MainActivity::class.java) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) }, @@ -545,33 +582,17 @@ private val SheetDialogSaver: Saver = * (which are not @Composable) read them by reference. */ private data class ParticipantToasts( - val title: String, - val followSent: String, - val unfollowSent: String, - val muteSent: String, - val unmuteSent: String, - val promoteSpeakerSent: String, - val promoteModeratorSent: String, - val demoteListenerSent: String, - val forceMuteSent: String, - val kickSent: String, + val failedTitle: String, + val failedTemplateNull: String, val noAppMessage: String, val zapSplitUnsupported: String, ) @Composable -private fun rememberParticipantToasts(displayName: String): ParticipantToasts = +private fun rememberParticipantToasts(): ParticipantToasts = ParticipantToasts( - title = stringRes(R.string.nest_toast_action_title), - followSent = stringRes(R.string.nest_toast_follow_sent, displayName), - unfollowSent = stringRes(R.string.nest_toast_unfollow_sent, displayName), - muteSent = stringRes(R.string.nest_toast_mute_sent, displayName), - unmuteSent = stringRes(R.string.nest_toast_unmute_sent, displayName), - promoteSpeakerSent = stringRes(R.string.nest_toast_promote_speaker_sent, displayName), - promoteModeratorSent = stringRes(R.string.nest_toast_promote_moderator_sent, displayName), - demoteListenerSent = stringRes(R.string.nest_toast_demote_listener_sent, displayName), - forceMuteSent = stringRes(R.string.nest_toast_force_mute_sent, displayName), - kickSent = stringRes(R.string.nest_toast_kick_sent, displayName), + failedTitle = stringRes(R.string.nest_toast_host_action_failed_title), + failedTemplateNull = stringRes(R.string.nest_toast_host_action_failed_template_null), noAppMessage = stringRes(R.string.nest_no_app_to_open_link), zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt index c7a396081a..b28c392e3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import com.vitorpamplona.quartz.utils.TimeUtils /** * Pure builders for participant-list mutations on a kind-30312 @@ -132,12 +133,25 @@ internal object RoomParticipantActions { ?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null) val others = participants.filterNot { it.pubKey == host.pubKey } + // Strictly monotonic createdAt. NIP-01 replaceable-event + // semantics drop a new version whose createdAt is <= the + // previous one (with a lower-id tie-break that's effectively + // random under signing). Creating a room then immediately + // promoting in the same wall-clock second produced the + // dreaded silent-no-op: the local cache rejected the new + // event in `consumeBaseReplaceable` (strict `>`) and relays + // applied the same rule. `+ 1` is enough to win the tie; + // `coerceAtLeast(now)` keeps the wire timestamp accurate + // when the original is already in the past. + val nextCreatedAt = (original.createdAt + 1L).coerceAtLeast(TimeUtils.now()) + return MeetingSpaceEvent.build( room = original.room().orEmpty(), status = status, service = original.service().orEmpty(), host = host, dTag = original.dTag(), + createdAt = nextCreatedAt, ) { original.endpoint()?.let { endpoint(it) } original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt deleted file mode 100644 index 9c633500e7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt +++ /dev/null @@ -1,104 +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.nests.room.reactions - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * Quick-pick emoji sheet for the room. Six default reactions cover - * the common audio-room interactions (clap, fire, laugh, eyes, - * heart, lightning); the parent invokes [onPick] with the chosen - * emoji which is then sent as a kind-7 reaction tagged for the - * current room (and optional speaker target). - * - * Custom-emoji-pack favourites (NIP-30) are intentionally deferred — - * the v1 sheet is a plain hard-coded set so the dependency surface - * stays tight; later passes can read the user's - * `EmojiPackEvent`s and merge them in. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun RoomReactionPickerSheet( - onPick: (String) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { - Text( - text = stringRes(R.string.nest_reactions_title), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - DEFAULT_REACTIONS.forEach { emoji -> - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = - Modifier - .size(48.dp) - .clickable { - onPick(emoji) - onDismiss() - }, - ) { - Text( - text = emoji, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - } - } - } - } - } -} - -private val DEFAULT_REACTIONS = listOf("👏", "🔥", "😂", "👀", "❤️", "⚡") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt new file mode 100644 index 0000000000..e25e19172f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt @@ -0,0 +1,131 @@ +/* + * 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.nests.room.reactions + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.note.ReactionChoicePopupContent +import com.vitorpamplona.amethyst.ui.note.popupAnimationEnter +import com.vitorpamplona.amethyst.ui.note.popupAnimationExit +import com.vitorpamplona.amethyst.ui.note.rememberVisibilityState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import kotlinx.collections.immutable.persistentSetOf + +/** + * Room-scoped reaction picker. Visually identical to + * [com.vitorpamplona.amethyst.ui.note.ReactionChoicePopup] — same + * pop-up animation, same user-configured reaction set (including + * NIP-30 custom emojis), same "change reactions" affordance — but + * with two semantic deviations specific to live audio rooms: + * + * 1. **Repeatable.** A note's heart `ReactionChoicePopup` calls + * `reactToOrDelete`, which toggles a `(note, emoji)` pair: a + * second tap of the same emoji DELETES the first. For an audio + * room every tap is an ephemeral cheer — the user should be + * able to fire `🔥` ten times during a great moment without the + * second tap nuking the first. We bypass `reactToOrDelete` and + * call `account.reactTo` directly, which always emits a fresh + * kind-7. + * + * 2. **No "already reacted" gate.** The standard popup passes a + * `toRemove` set so already-reacted buttons render with a + * destructive hint (next tap = delete). For us every button is + * always "fresh", so `toRemove` is empty. + * + * Anchoring + animation mirror the original popup so the affordance + * feels the same when the user enters/exits the room. + */ +@Composable +fun RoomReactionPopup( + roomNote: AddressableNote, + iconSize: Dp, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, +) { + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } + val visibilityState = rememberVisibilityState(onDismiss) + val reactions by accountViewModel.reactionChoicesFlow().collectAsState() + + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { visibilityState.targetState = false }, + properties = PopupProperties(focusable = true), + ) { + AnimatedVisibility( + visibleState = visibilityState, + enter = popupAnimationEnter, + exit = popupAnimationExit, + ) { + ReactionChoicePopupContent( + listOfReactions = reactions, + // Empty set — never mark a reaction as "already + // emitted" so the user can fire the same emoji + // repeatedly throughout the room. + toRemove = persistentSetOf(), + onClick = { reactionType -> + accountViewModel.launchSigner { + // Bypass `Account.reactTo` (which delegates to + // `ReactionAction.reactTo(note, …)` and + // short-circuits on `note.hasReacted(by, reaction)` + // — line 181 in ReactionAction.kt). For a live + // audio room we WANT the second tap of the same + // emoji to fire another kind-7. Build the + // template directly and hand it to + // `signAndComputeBroadcast`, which signs + + // publishes + writes to LocalCache without the + // duplicate gate. + val eventHint = roomNote.toEventHint() + if (eventHint != null) { + val template = + if (reactionType.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reactionType) + if (emojiUrl != null) { + ReactionEvent.build(emojiUrl, eventHint) + } else { + ReactionEvent.build(reactionType, eventHint) + } + } else { + ReactionEvent.build(reactionType, eventHint) + } + accountViewModel.account.signAndComputeBroadcast(template) + } + } + visibilityState.targetState = false + }, + onChangeAmount = onChangeAmount, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index ba3e5ff68d..623c717a85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconToggleButton @@ -52,7 +53,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -67,7 +71,21 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup +import com.vitorpamplona.amethyst.ui.note.ZapCustomDialog +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.note.zapClick +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Sticky bottom action bar for the room screen. @@ -96,7 +114,9 @@ internal fun NestActionBar( isOnStage: Boolean, handRaised: Boolean, onHandRaisedChange: (Boolean) -> Unit, - onShowReactionPicker: () -> Unit, + roomNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, onLeave: () -> Unit, ) { Surface( @@ -121,7 +141,9 @@ internal fun NestActionBar( isConnected = ui.connection is ConnectionUiState.Connected, handRaised = handRaised, onHandRaisedChange = onHandRaisedChange, - onShowReactionPicker = onShowReactionPicker, + roomNote = roomNote, + accountViewModel = accountViewModel, + nav = nav, onLeave = onLeave, ) } @@ -352,7 +374,9 @@ private fun EndCluster( isConnected: Boolean, handRaised: Boolean, onHandRaisedChange: (Boolean) -> Unit, - onShowReactionPicker: () -> Unit, + roomNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, onLeave: () -> Unit, ) { Row( @@ -364,19 +388,224 @@ private fun EndCluster( if (!isOnStage && isConnected) { HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange) } + // Zap and React both work in any state — even disconnected + // users can zap / react via the room note. + NestZapButton( + roomNote = roomNote, + accountViewModel = accountViewModel, + nav = nav, + ) // React works in any state — even disconnected users can react - // via the room note. - FilledTonalIconButton(onClick = onShowReactionPicker) { + // via the room note. Reuses the same ReactionChoicePopup + // NoteCompose's heart button drives (NIP-30 custom-emoji + // support, user-configured reaction set). Earlier hand-rolled + // bottom-sheet picker is gone — it only emitted a fixed set + // of six unicode emojis with no path to custom packs. + var wantsToReact by rememberSaveable { mutableStateOf(false) } + FilledTonalIconButton(onClick = { wantsToReact = true }) { Icon( symbol = MaterialSymbols.EmojiEmotions, contentDescription = stringRes(R.string.nest_reactions_button), ) + if (wantsToReact) { + RoomReactionPopup( + roomNote = roomNote, + iconSize = 24.dp, + accountViewModel = accountViewModel, + onDismiss = { wantsToReact = false }, + // No `Route.UpdateReactionType` from inside the + // standalone NestActivity (no NavController). The + // "change reactions" button still dismisses the + // popup; users edit their reaction set from the + // main app's settings. + onChangeAmount = { wantsToReact = false }, + ) + } } Spacer(Modifier.width(4.dp)) LeaveRoomButton(onClick = onLeave) } } +/** + * Round zap button styled to match [HandRaiseToggle] / the React + * button — same `FilledTonalIconButton` shape and tonal palette, but + * tinted with [BitcoinOrange] while a zap is in flight so the user + * sees the progress without needing a separate amount label. + * + * Click behavior is delegated to NoteCompose's [zapClick], which + * applies the user's configured zap amount choices the same way the + * normal note ⚡ button does (single-tap fires the default amount; + * multi-choice opens [ZapAmountChoicePopup]; an unconfigured account + * opens [ZapCustomDialog]). Long-press routes to the + * [Route.UpdateZapAmount] settings screen via the activity's + * [BouncingIntentNav] (no-op when the route can't be expressed as a + * `nostr:` URI — same fallback as the chat panel uses). + */ +@OptIn(ExperimentalUuidApi::class) +@Composable +private fun NestZapButton( + roomNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var wantsToZap by remember { mutableStateOf(false) } + var wantsToSetCustomZap by remember { mutableStateOf(false) } + + // 0f when idle. Driven 0f → 1f by the zap flow's progress + // callbacks; reset to 0f on error, on dismiss, and when the flow + // hands off to an external wallet via `onPayViaIntent` (the + // button can't observe payment completion past that handoff, so + // it must not leave the spinner up — unlike NoteCompose's + // ZapReaction, this button has no ObserveZapIconState fallback). + var zappingProgress by remember { mutableFloatStateOf(0f) } + + val isZapping = zappingProgress > 0.00001f && zappingProgress < 0.99999f + + FilledTonalIconButton( + onClick = { + scope.launch { + zapClick( + baseNote = roomNote, + accountViewModel = accountViewModel, + context = context, + onZapStarts = {}, + onZappingProgress = { progress -> scope.launch { zappingProgress = progress } }, + onMultipleChoices = { scope.launch { wantsToZap = true } }, + onError = { _, message, user -> + scope.launch { + zappingProgress = 0f + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user) + } + }, + onPayViaIntent = { + // Handing off to an external wallet (or the + // manual-split screen) ends the in-app phase — + // clear the spinner since nothing downstream + // tracks payment completion for this button. + zappingProgress = 0f + if (it.size == 1) { + val payable = it.first() + payViaIntent(payable.invoice, context, { }) { error -> + zappingProgress = 0f + accountViewModel.toastManager.toast( + R.string.error_dialog_zap_error, + UserBasedErrorMessage(error, payable.info.user), + ) + } + } else { + val uid = Uuid.random().toString() + accountViewModel.tempManualPaymentCache.put(uid, it) + nav.nav(Route.ManualZapSplitPayment(uid)) + } + }, + onCustomAmount = { wantsToSetCustomZap = true }, + ) + } + }, + ) { + if (isZapping) { + CircularProgressIndicator( + progress = { zappingProgress }, + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = stringRes(R.string.zap_description), + ) + } + } + + if (wantsToZap) { + ZapAmountChoicePopup( + baseNote = roomNote, + popupYOffset = 48.dp, + accountViewModel = accountViewModel, + onZapStarts = {}, + onDismiss = { + wantsToZap = false + zappingProgress = 0f + }, + onChangeAmount = { + scope.launch { + wantsToZap = false + nav.nav(Route.UpdateZapAmount()) + } + }, + onError = { _, message, user -> + scope.launch { + zappingProgress = 0f + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user) + } + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { + // Handing off to an external wallet (or the manual- + // split screen) ends the in-app phase — clear the + // spinner since nothing downstream tracks payment + // completion for this button. + zappingProgress = 0f + if (it.size == 1) { + val payable = it.first() + payViaIntent(payable.invoice, context, { }) { error -> + zappingProgress = 0f + accountViewModel.toastManager.toast( + R.string.error_dialog_zap_error, + UserBasedErrorMessage(error, payable.info.user), + ) + } + } else { + val uid = Uuid.random().toString() + accountViewModel.tempManualPaymentCache.put(uid, it) + nav.nav(Route.ManualZapSplitPayment(uid)) + } + }, + ) + } + + if (wantsToSetCustomZap) { + ZapCustomDialog( + onZapStarts = {}, + onClose = { wantsToSetCustomZap = false }, + onError = { _, message, user -> + scope.launch { + zappingProgress = 0f + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user) + } + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { + // Handing off to an external wallet (or the manual- + // split screen) ends the in-app phase — clear the + // spinner since nothing downstream tracks payment + // completion for this button. + zappingProgress = 0f + if (it.size == 1) { + val payable = it.first() + payViaIntent(payable.invoice, context, { }) { error -> + zappingProgress = 0f + accountViewModel.toastManager.toast( + R.string.error_dialog_zap_error, + UserBasedErrorMessage(error, payable.info.user), + ) + } + } else { + val uid = Uuid.random().toString() + accountViewModel.tempManualPaymentCache.put(uid, it) + nav.nav(Route.ManualZapSplitPayment(uid)) + } + }, + accountViewModel = accountViewModel, + baseNote = roomNote, + ) + } +} + // ── Reusable affordances ──────────────────────────────────────────────── @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 464b5ee926..0c3824e690 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -64,13 +65,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid +import com.vitorpamplona.amethyst.ui.navigation.navs.BouncingIntentNav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPanel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.ParticipantHostActionsSheet -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPickerSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.AudienceGrid import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid @@ -120,7 +121,6 @@ internal fun NestFullScreen( var showEditSheet by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) } - var showReactionPicker by rememberSaveable { mutableStateOf(false) } var hostMenuTarget by rememberSaveable { mutableStateOf(null) } // Summary is collapsed by default; tapping the top-bar title // toggles it so the user can preview the room description without @@ -130,15 +130,21 @@ internal fun NestFullScreen( // Tab selection survives configuration changes and PIP transitions. // Stored as ordinal so rememberSaveable can persist it without a // custom Saver. - var selectedTabIndex by rememberSaveable { mutableStateOf(0) } + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } val isHost = accountViewModel.account.signer.pubKey == event.pubKey val myPubkey = accountViewModel.account.signer.pubKey val leaveScope = rememberCoroutineScope() val topBarContext = LocalContext.current + // BouncingIntentNav handles the few routes the zap button can reach + // (UpdateZapAmount, ManualZapSplitPayment) by bouncing through + // MainActivity — same mechanism the NestChatPanel uses for tap-on- + // mention nav. Routes that don't map to a `nostr:` URI are no-ops. + val actionBarNav = remember(topBarContext, leaveScope) { BouncingIntentNav(topBarContext, leaveScope) } val presences by viewModel.presences.collectAsState() val reactionsByPubkey by viewModel.recentReactions.collectAsState() + val zapsByPubkey by viewModel.recentZaps.collectAsState() val speakerCatalogs by viewModel.speakerCatalogs.collectAsState() val audioLevels by viewModel.audioLevels.collectAsState() @@ -235,6 +241,7 @@ internal fun NestFullScreen( audioLevels = audioLevels, accountViewModel = accountViewModel, reactionsByPubkey = reactionsByPubkey, + zapsByPubkey = zapsByPubkey, connectingSpeakers = ui.connectingSpeakers, onLongPressParticipant = onLongPressParticipant, // Tap on a stage avatar opens the same per-participant @@ -268,7 +275,9 @@ internal fun NestFullScreen( isOnStage = isOnStageMe, handRaised = handRaised, onHandRaisedChange = onHandRaisedChange, - onShowReactionPicker = { showReactionPicker = true }, + roomNote = roomNote, + accountViewModel = accountViewModel, + nav = actionBarNav, onLeave = { if (isHost) { showHostLeaveConfirm = true @@ -310,6 +319,8 @@ internal fun NestFullScreen( onLongPressParticipant = onLongPressParticipant, onTapParticipant = onLongPressParticipant, myPubkey = myPubkey, + reactionsByPubkey = reactionsByPubkey, + zapsByPubkey = zapsByPubkey, modifier = Modifier .weight(1f) @@ -349,13 +360,6 @@ internal fun NestFullScreen( ) } - if (showReactionPicker) { - RoomReactionPickerSheet( - onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) }, - onDismiss = { showReactionPicker = false }, - ) - } - if (showHostLeaveConfirm) { AlertDialog( onDismissRequest = { showHostLeaveConfirm = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index f35bc41f94..0d340f1fc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction +import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -151,6 +153,7 @@ internal fun StageGrid( modifier: Modifier = Modifier, audioLevels: Map = emptyMap(), reactionsByPubkey: Map> = emptyMap(), + zapsByPubkey: Map> = emptyMap(), connectingSpeakers: ImmutableSet = persistentSetOf(), onLongPressParticipant: ((String) -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null, @@ -159,15 +162,12 @@ internal fun StageGrid( listenerCount: Int = 0, bottomBar: (@Composable () -> Unit)? = null, ) { - // Float currently-speaking members to the top so the listener can - // see who they're hearing without scrolling. sortedBy is stable in - // Kotlin — speakers retain relative order among themselves, as do - // non-speakers. Memoized on (members, speakingNow) so a 250ms - // speaking flip doesn't reallocate when neither input changed. - val sortedMembers = - remember(members, speakingNow) { - members.sortedBy { if (it.pubkey in speakingNow) 0 else 1 } - } + // Keep members in their stable arrival order. Reordering on the + // speaking state made a member jump to the front when they started + // talking and slide back ~250ms after they stopped — a constant + // shuffle in any active room. The speaking state is already shown + // in-place via MemberCell's mic badge / audio-level ring, so no + // reordering is needed. // Wrap the strip in a tonal card so the active speakers visually // live in their own zone, separated from the chat / audience tab // below. surfaceContainerLow is a quiet step up from the screen @@ -218,7 +218,7 @@ internal fun StageGrid( horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), verticalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { - items(items = sortedMembers, key = { it.pubkey }) { member -> + items(items = members, key = { it.pubkey }) { member -> val isSelf = myPubkey != null && member.pubkey == myPubkey MemberCell( member = member, @@ -228,6 +228,7 @@ internal fun StageGrid( isConnecting = member.pubkey in connectingSpeakers, showMicBadge = true, reactions = reactionsByPubkey[member.pubkey].orEmpty(), + zaps = zapsByPubkey[member.pubkey].orEmpty(), accountViewModel = accountViewModel, onLongPressParticipant = onLongPressParticipant, onTapParticipant = if (isSelf) null else onTapParticipant, @@ -303,6 +304,8 @@ internal fun AudienceGrid( onLongPressParticipant: ((String) -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null, myPubkey: String? = null, + reactionsByPubkey: Map> = emptyMap(), + zapsByPubkey: Map> = emptyMap(), ) { if (members.isEmpty()) { Box( @@ -341,7 +344,8 @@ internal fun AudienceGrid( audioLevel = 0f, isConnecting = false, showMicBadge = false, - reactions = emptyList(), + reactions = reactionsByPubkey[member.pubkey].orEmpty(), + zaps = zapsByPubkey[member.pubkey].orEmpty(), accountViewModel = accountViewModel, onLongPressParticipant = onLongPressParticipant, onTapParticipant = onTapParticipant, @@ -363,6 +367,7 @@ private fun MemberCell( reactions: List, accountViewModel: AccountViewModel, onLongPressParticipant: ((String) -> Unit)?, + zaps: List = emptyList(), isSelf: Boolean = false, onTapSelf: (() -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null, @@ -524,8 +529,56 @@ private fun MemberCell( isConnecting = isConnecting, showMicBadge = showMicBadge, isSpeaking = isSpeaking, - reactions = reactions, ) + // Reactions overlay sits as a SIBLING of AvatarAndBadges + // inside this fixed-size outer Box, NOT inside + // AvatarAndBadges itself. AvatarAndBadges' inner Box + // wraps content, so any change to the overlay's measured + // size (chip appears / animates / disappears) would shift + // the inner Box's centre and the badges aligned to its + // corners would drift with it. The outer Box has an + // explicit `.size(outerBoxSize)`, so adding the overlay + // here can't change layout dimensions. + // + // Anchor the chip's bottom-RIGHT to the avatar's + // bottom-right corner (small inward nudge from the outer + // Box edge so it clears the ring/glow padding). The chip + // extends LEFTWARD inside the cell as content widens + // (single emoji vs `×N` count). Earlier `BottomStart` + // experiment anchored the chip's left edge at the same + // corner — looked great for a fixed-width chip but bled + // straight into the next column once a real emoji was + // rendered, because the cell was only ~100 dp wide and + // the chip extended out 40+ dp to the right. + if (reactions.isNotEmpty()) { + SpeakerReactionOverlay( + reactions = reactions, + modifier = + Modifier + .align(Alignment.BottomEnd) + .offset(x = -ringPadding + 6.dp, y = -ringPadding + 6.dp), + ) + } + // Zaps animate just above the avatar, TOP-CENTER — the + // only badge-free anchor: the role badge sits TopStart, + // the hand-raise badge TopEnd, the mic badge BottomCenter + // and the reaction overlay BottomEnd. Anchoring TopEnd + // would collide with the hand-raise badge, and now that + // zaps float from the *zapper's* avatar (a likely + // hand-raiser), that overlap would be common. Center- + // anchored so the `⚡ Nsats` chip extends symmetrically + // and stays within the cell rather than bleeding into the + // neighbouring column. Same outer-Box sibling placement + // and layout-stability reasoning as the reaction overlay. + if (zaps.isNotEmpty()) { + SpeakerZapOverlay( + zaps = zaps, + modifier = + Modifier + .align(Alignment.TopCenter) + .offset(y = ringPadding - 6.dp), + ) + } } UsernameDisplay( baseUser = user, @@ -548,7 +601,6 @@ private fun AvatarAndBadges( isConnecting: Boolean, showMicBadge: Boolean, isSpeaking: Boolean, - reactions: List, ) { Box(contentAlignment = Alignment.Center) { ClickableUserPicture( @@ -591,19 +643,6 @@ private fun AvatarAndBadges( modifier = Modifier.align(Alignment.BottomCenter), ) } - // Reactions float over the avatar's bottom-right corner so - // a 👏 burst no longer pushes the username down and reflows - // neighbouring cells. The mic badge sits at BottomCenter, - // so BottomEnd + a small outward offset keeps them clear. - if (reactions.isNotEmpty()) { - SpeakerReactionOverlay( - reactions = reactions, - modifier = - Modifier - .align(Alignment.BottomEnd) - .offset(x = 6.dp, y = 6.dp), - ) - } } } @@ -631,7 +670,7 @@ private fun HandRaiseBadge(modifier: Modifier = Modifier) { Box( modifier = modifier - .offset(x = 2.dp, y = (-2).dp + offsetY.dp) + .offset { IntOffset(2.dp.roundToPx(), ((-2).dp + offsetY.dp).roundToPx()) } .size(MAX_BADGE_SIZE) .background(MaterialTheme.colorScheme.tertiary, CircleShape), contentAlignment = Alignment.Center, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt index 32ac342f84..fb38f49d3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt @@ -21,43 +21,50 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction -import kotlinx.coroutines.delay +import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import kotlinx.collections.immutable.persistentListOf /** - * Floating-emoji overlay drawn under a speaker's avatar. Each chip - * is keyed by emoji content; bursts of the same emoji collapse into - * a single `🔥 ×3` chip whose count tracks live as new reactions land. + * Floating-emoji overlay drawn under a speaker's avatar. Each + * incoming `kind-7` becomes its own independent chip — two + * reactions arriving in quick succession produce two concurrent + * rises, not one shared chip that restarts. The previous + * implementation grouped by content and used `youngestSec` as a + * `remember` key, which meant a second `🔥` from the same speaker + * interrupted the first chip's animation and showed `×2` instead + * of two distinct floating emojis. * - * Reactions are about what the speaker is saying RIGHT NOW, so each - * chip lives for [REACTION_WINDOW_SEC] seconds: the moment its - * youngest reaction arrives the chip fades+scales in, and over that - * window it slowly drifts upward and fades out before the eviction - * tick removes it from the underlying list. + * Reactions live for [REACTION_WINDOW_SEC] seconds in the + * aggregator; each chip animates over [REACTION_RISE_MS] and then + * stays invisible (alpha=0) until the aggregator evicts it. We cap + * the visible-chip count at [MAX_VISIBLE_CHIPS] so a flurry of + * reactions doesn't bleed the Row into the next column. */ @Composable internal fun SpeakerReactionOverlay( @@ -70,24 +77,37 @@ internal fun SpeakerReactionOverlay( exit = fadeOut() + scaleOut(targetScale = 0.6f), modifier = modifier, ) { - // Group by content so duplicate emojis collapse into a single - // "🔥 ×N" chip. Order by most recent so a fresh reaction lands - // at the front of the row. - val byContent = reactions.groupBy { it.content } - val ordered = - byContent.toList().sortedByDescending { (_, list) -> - list.maxOf { it.createdAtSec } + // Most-recent first; capped so the layered Box doesn't grow + // an unbounded set of overlapping chips. Older reactions drop + // off as new ones arrive (the aggregator still tracks them + // for eviction but they're not drawn once past the cap). + val visible = + remember(reactions) { + reactions + .sortedByDescending { it.createdAtSec } + .take(MAX_VISIBLE_CHIPS) } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - ordered.forEach { (content, list) -> - // Newest createdAt drives the chip's lifecycle — - // a fresh reaction restarts the upward-drift+fade. - val youngestSec = list.maxOf { it.createdAtSec } - ReactionChip( - content = content, - count = list.size, - youngestSec = youngestSec, - ) + // Box, not Row — every chip stacks at the same right-anchored + // spot so the X position stays fixed throughout the chip's + // lifecycle. The previous Row layout slid older chips + // leftward each time a new reaction arrived (sortedByDescending + // put the newest at index 0). With overlapping chips, each + // animates independently in place; the newest is drawn on + // top by virtue of being last in the iteration. + Box(contentAlignment = androidx.compose.ui.Alignment.BottomEnd) { + // Iterate in reverse (oldest first) so the newest reaction + // ends up last in the Box and therefore on top of the + // paint stack. + visible.asReversed().forEach { reaction -> + // Per-event-id key — independent animation lifecycle + // per reaction. Same-emoji bursts no longer collide + // on a shared chip. + key(reaction.eventId) { + ReactionChip( + content = reaction.content, + youngestSec = reaction.createdAtSec, + ) + } } } } @@ -96,62 +116,161 @@ internal fun SpeakerReactionOverlay( @Composable private fun ReactionChip( content: String, - count: Int, youngestSec: Long, ) { - // Tick a [progress] state from 0f → 1f over the eviction window - // so the chip can drift + fade in lockstep with how close it is - // to falling out of the aggregator. Reset whenever a fresher - // reaction lands by re-keying on [youngestSec]. - var progress by remember(youngestSec) { mutableStateOf(0f) } + // Manual frame-clock animation. Earlier passes using + // `Animatable.animateTo(...)` and `animateFloatAsState(...)` never + // produced visible motion in this site — five iterations of + // tuning didn't help. Driving the progress State directly from + // `withFrameNanos` is the lowest-level Compose animation pattern + // there is: each frame, we read the elapsed time, compute a 0→1 + // fraction, and write it into a MutableFloatState. The + // `graphicsLayer` lambda below reads that State, so its block + // re-invokes per frame without going through the standard + // animation framework. Keyed on [youngestSec] so a fresh + // reaction in the same emoji burst restarts the animation + // cleanly. + var progress by remember(youngestSec) { mutableFloatStateOf(0f) } LaunchedEffect(youngestSec) { - // Re-sync against wall-clock so a chip whose window started - // before this composable mounted (e.g. user rotated the - // device mid-burst) still ages correctly. - val ageMs = (System.currentTimeMillis() / 1000L - youngestSec).coerceAtLeast(0L) * 1000L - val remaining = (REACTION_WINDOW_MS - ageMs).coerceAtLeast(0L) - progress = (ageMs.toFloat() / REACTION_WINDOW_MS).coerceIn(0f, 1f) - if (remaining <= 0L) return@LaunchedEffect - // 100 ms ticks keep the drift smooth without burning a frame - // budget — the avatar is small and the chip's motion is - // sub-pixel between ticks anyway. - val steps = (remaining / 100L).coerceAtLeast(1L) - repeat(steps.toInt()) { - delay(100L) - progress = - ((System.currentTimeMillis() / 1000L - youngestSec).toFloat() * 1000f / REACTION_WINDOW_MS) - .coerceIn(0f, 1f) + val startNanos = withFrameNanos { it } + while (true) { + val nowNanos = withFrameNanos { it } + val elapsedMs = (nowNanos - startNanos) / 1_000_000f + val raw = (elapsedMs / REACTION_RISE_MS).coerceIn(0f, 1f) + // FastOutSlowInEasing — quick start, slow tail. Pulled + // by hand so we don't import the animation-core easing + // for one call site. + progress = FastOutSlowInEasing.transform(raw) + if (raw >= 1f) break } - progress = 1f } - // Drift up by ~16 dp over the window so the chip reads as - // "rising and dissipating", and fade out over the second half - // so the first half stays solidly readable. - val driftDp = (-16f * progress).dp - val animatedAlpha by animateFloatAsState( - targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f), - animationSpec = tween(durationMillis = 100, easing = LinearEasing), - label = "reaction-chip-alpha", - ) - - Surface( + // The chip drifts up, grows, and fades out — three concurrent + // tracks driven by the same `progress` value, all set inside + // the graphicsLayer lambda so the read tracks at draw time + // (per frame, no full recomposition needed): + // * translationY: 0 → -DRIFT_MAX_DP. Capped within the cell's + // headroom (otherwise the chip clips the avatar above on a + // 75 dp grid cell). + // * scale: 1 → SCALE_MAX. Mirrors a "rising bubble" — the + // emoji looks closer/larger as it lifts. + // * alpha: 1 → 1 → 0. Solid for the first ALPHA_HOLD_FRAC of + // progress; fades over the remainder so the chip dissipates + // well before the 10 s aggregator-eviction tick. + // Fixed-size Box so each chip occupies the same footprint + // regardless of which emoji is rendered — "🔥" and "👍" have + // different intrinsic glyph widths, and without a fixed box the + // Row's right-anchored layout slid the visible content left/right + // by a few dp on every new reaction. Centring the Text inside a + // [CHIP_SIZE]-square Box pins the emoji's centre to a stable + // point. No Surface / background — transparent over the avatar. + Box( modifier = Modifier - .offset(y = driftDp) - .alpha(animatedAlpha), - shape = MaterialTheme.shapes.small, - tonalElevation = 2.dp, - shadowElevation = 1.dp, + .size(CHIP_SIZE) + .graphicsLayer { + val p = progress + translationY = -DRIFT_MAX_DP.dp.toPx() * p + scaleX = 1f + (SCALE_MAX - 1f) * p + scaleY = scaleX + alpha = + ( + 1f - + ( + (p - ALPHA_HOLD_FRAC).coerceAtLeast(0f) / + (1f - ALPHA_HOLD_FRAC) + ) + ).coerceIn(0f, 1f) + }, + contentAlignment = Alignment.Center, ) { - val label = if (count > 1) "$content ×$count" else content - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) + RenderReactionContent(content) } } -private const val REACTION_WINDOW_MS = REACTION_WINDOW_SEC * 1000L +/** + * Render a single reaction's content the same way + * `ReactionsRow.RenderReactionType` does, so a kind-7 with a NIP-30 + * custom emoji shows the image (not the `:shortcode:url` string) + * and special "+" / "-" tokens fall back to the standard heart / 👎 + * conventions. + * + * - `"::"` → image rendered inline via + * [InLineIconRenderer] using [CustomEmoji.ImageUrlType]. + * - `"+"` → ❤️ (the legacy NIP-25 "like" symbol; the heart icon + * vector lives in `ReactionsRow.kt` as a private composable so + * here we render the unicode heart at the same size). + * - `"-"` → 👎. + * - Anything else → rendered verbatim as Text (the common case — + * a single unicode emoji). + */ +@Composable +private fun RenderReactionContent(content: String) { + if (content.isNotEmpty() && content[0] == ':') { + val renderable = + remember(content) { + persistentListOf( + CustomEmoji.ImageUrlType(content.removePrefix(":").substringAfter(":")), + ) + } + InLineIconRenderer( + wordsInOrder = renderable, + style = SpanStyle(color = Color.Unspecified), + fontSize = EMOJI_FONT_SIZE, + maxLines = 1, + ) + return + } + val display = + when (content) { + "+" -> "❤️" + "-" -> "👎" + else -> content + } + Text( + text = display, + style = MaterialTheme.typography.titleLarge.copy(fontSize = EMOJI_FONT_SIZE), + color = MaterialTheme.colorScheme.onSurface, + ) +} + +// 3 s pop-and-drift. Earlier 1.5 s, 4 s, and 6 s passes (when the +// animation was broken upstream) didn't read well; 3 s with the +// working frame-clock animation lands as a brisk rise — fast enough +// to feel like a reaction, slow enough to track visually — and +// still finishes inside the 10 s aggregator-eviction window. +private const val REACTION_RISE_MS = 3000L + +// Final rise distance has to clear the avatar without crashing into +// the cell above. 28 dp lands the chip near the top edge of a 75 dp +// avatar — readable, no clip. +private const val DRIFT_MAX_DP = 28f + +// 1.6× peak scale gives a clearly visible "growing as it rises" +// effect. Applied via graphicsLayer so it's drawing-only and +// doesn't reflow neighbours each frame. +private const val SCALE_MAX = 1.6f + +// Alpha holds at 1 for the first 50 % of progress, then ramps to 0 +// over the remaining 50 %. Keeps the reaction readable in its prime +// without abrupt removal. +private const val ALPHA_HOLD_FRAC = 0.5f + +// Base font for the emoji glyph. labelSmall (default Material caption +// ~11 sp) was too small to read at avatar-grid scale; 22 sp gets the +// emoji to roughly the same visual size as a stage-grid avatar badge. +private val EMOJI_FONT_SIZE = 22.sp + +// Fixed chip footprint. Slightly larger than the emoji glyph so the +// glyph (which varies in intrinsic width across emojis) is centred +// in a consistent box. With this, the Row's right-anchored layout +// produces a perfectly stable left edge per chip count, no matter +// which emoji. +private val CHIP_SIZE = 30.dp + +// Cap on concurrently-rendered chips per sender. The aggregator +// keeps reactions for 10 s; without a cap, a burst of 8+ reactions +// would stretch the Row past the cell and into the next column even +// after the older chips have faded to alpha=0 (still occupy layout). +// 3 reads as "the user is reacting a lot" without overflowing. +private const val MAX_VISIBLE_CHIPS = 3 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt new file mode 100644 index 0000000000..cbfa35db99 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt @@ -0,0 +1,149 @@ +/* + * 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.nests.room.stage + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC +import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.delay + +/** + * Floating zap-chip overlay drawn over a participant's avatar — the + * zap counterpart to [SpeakerReactionOverlay]. The aggregator groups + * zaps by sender, so this renders the zaps a single participant has + * *sent*: consecutive zaps from that sender stack into a row, each + * chip keyed by its event id. The chip life-cycle (`fadeIn + scaleIn` + * on arrival, upward drift + `fadeOut` over [REACTION_WINDOW_SEC]) + * matches reactions so both streams visually feel like the same + * animation. + * + * Renders an "⚡ Nsats" pill in [BitcoinOrange] so zaps are distinct + * from emoji reactions at a glance. + */ +@Composable +internal fun SpeakerZapOverlay( + zaps: List, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = zaps.isNotEmpty(), + enter = fadeIn() + scaleIn(initialScale = 0.6f), + exit = fadeOut() + scaleOut(targetScale = 0.6f), + modifier = modifier, + ) { + // Newest zap leads the row, matching SpeakerReactionOverlay's + // most-recent-first ordering so the two overlays read the same. + val ordered = zaps.sortedByDescending { it.createdAtSec } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + ordered.take(MAX_VISIBLE_ZAPS).forEach { zap -> + ZapChip(zap = zap) + } + } + } +} + +@Composable +private fun ZapChip(zap: RoomZap) { + // Mirror of [SpeakerReactionOverlay.ReactionChip]: tick a [progress] + // state from 0f → 1f over the eviction window so the chip can drift + // + fade in lockstep with how close it is to being evicted. Re-key + // on the event id so the animation restarts whenever a fresh zap + // replaces the value at this slot. + var progress by remember(zap.eventId) { mutableFloatStateOf(0f) } + LaunchedEffect(zap.eventId) { + val ageMs = (System.currentTimeMillis() / 1000L - zap.createdAtSec).coerceAtLeast(0L) * 1000L + val remaining = (ZAP_WINDOW_MS - ageMs).coerceAtLeast(0L) + progress = (ageMs.toFloat() / ZAP_WINDOW_MS).coerceIn(0f, 1f) + if (remaining <= 0L) return@LaunchedEffect + val steps = (remaining / 100L).coerceAtLeast(1L) + repeat(steps.toInt()) { + delay(100L) + progress = + ((System.currentTimeMillis() / 1000L - zap.createdAtSec).toFloat() * 1000f / ZAP_WINDOW_MS) + .coerceIn(0f, 1f) + } + progress = 1f + } + + val driftDp = (-16f * progress).dp + val animatedAlpha by animateFloatAsState( + targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 100, easing = LinearEasing), + label = "zap-chip-alpha", + ) + + Surface( + modifier = + Modifier + .offset(y = driftDp) + .alpha(animatedAlpha), + shape = MaterialTheme.shapes.small, + color = BitcoinOrange.copy(alpha = 0.95f), + // BitcoinOrange is theme-independent, so pin the content color + // to white rather than colorScheme.onPrimary (which tracks the + // theme's primary, not this fixed orange). + contentColor = Color.White, + tonalElevation = 2.dp, + shadowElevation = 1.dp, + ) { + val amountText = + zap.amountSats?.let { showAmountInteger(it.toBigDecimal()) } ?: "" + val label = if (amountText.isNotEmpty()) "⚡ $amountText" else "⚡" + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +private const val ZAP_WINDOW_MS = REACTION_WINDOW_SEC * 1000L + +// Cap the row at a small number so a burst of zaps from the same +// sender doesn't overflow the avatar's top-center anchor — the +// eviction sweep clears them within the window anyway. +private const val MAX_VISIBLE_ZAPS = 3 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt index a484d2d034..d4709a5e0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -154,13 +154,13 @@ fun RelayMembersScreen( joinRequestSent = joinRequestSent, leaveRequestSent = leaveRequestSent, onJoinRequest = { - scope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { sendJoinRequest(normalizedRelayUrl, accountViewModel) joinRequestSent = true } }, onLeaveRequest = { - scope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { sendLeaveRequest(normalizedRelayUrl, accountViewModel) leaveRequestSent = true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt index 26d972ac8f..1a9fe31435 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -98,6 +99,7 @@ import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar @@ -130,6 +132,8 @@ fun ScheduledPostsScreen( val totalActive by viewModel.totalActive.collectAsStateWithLifecycle() val context = LocalContext.current var expandedId by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() // Tick once per minute so relative-time strings ("publishes in 2h 13m") // refresh on a long-open list instead of being frozen at first composition. @@ -183,10 +187,23 @@ fun ScheduledPostsScreen( } }, navigationIcon = { - IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } + // Suppress the back arrow when this is the bottom of the back stack + // (i.e. the user landed here via the bottom nav, which clears the stack). + if (nav.canPop()) { + IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } + } }, ) }, + bottomBar = { + AppBottomBar(Route.ScheduledPosts, nav, accountViewModel) { route -> + if (route == Route.ScheduledPosts) { + scope.launch { listState.animateScrollToItem(0) } + } else { + nav.navBottomBar(route) + } + } + }, ) { padding -> if (groups.isEmpty()) { EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding)) @@ -194,6 +211,7 @@ fun ScheduledPostsScreen( val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) } LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), + state = listState, contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index 351a4d2b48..953296ba00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -58,7 +58,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -104,16 +103,12 @@ fun BottomBarSettingsScreen( @Composable fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { - val pinned by accountViewModel.settings.uiSettingsFlow.bottomBarItems - .collectAsStateWithLifecycle() - - var items by remember(pinned) { - mutableStateOf(initialRows(pinned)) - } + val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems + var items by remember { mutableStateOf(initialRows(bottomBarItemsFlow.value)) } fun save(newItems: List) { items = newItems - accountViewModel.settings.uiSettingsFlow.bottomBarItems.tryEmit( + bottomBarItemsFlow.tryEmit( newItems.filter { it.pinned }.map { it.item }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index 1c5baba82f..2e53282a81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -30,20 +31,21 @@ import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import kotlinx.coroutines.flow.MutableStateFlow @Composable @@ -55,63 +57,112 @@ fun ComposeSettingsScreen( topBar = { TopBarWithBackButton(stringRes(id = R.string.compose_settings), nav) }, + ) { padding -> + ComposeSettingsContent( + sharedPrefs = accountViewModel.settings.uiSettingsFlow, + accountViewModel = accountViewModel, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +fun ComposeSettingsContent( + sharedPrefs: UiSettingsFlow, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Column(Modifier.padding(it)) { - ComposeSettingsContent(accountViewModel.settings.uiSettingsFlow) + SettingsSection(R.string.compose_settings) { + BooleanSwitchTile( + flow = sharedPrefs.automaticallyCreateDrafts, + icon = MaterialSymbols.Drafts, + title = R.string.auto_create_drafts_setting_title, + description = R.string.auto_create_drafts_setting_description, + ) + SettingsDivider() + BooleanSwitchTile( + flow = sharedPrefs.automaticallyProposeAiImprovements, + icon = MaterialSymbols.AutoAwesome, + title = R.string.ai_writing_setting_title, + description = R.string.ai_writing_setting_description, + ) + SettingsDivider() + BooleanSwitchTile( + flow = sharedPrefs.useTrackedBroadcasts, + icon = MaterialSymbols.CellTower, + title = R.string.tracked_broadcasts_setting_title, + description = R.string.tracked_broadcasts_setting_description, + ) + SettingsDivider() + AddClientTagTile(accountViewModel) } } } +@Composable +private fun AddClientTagTile(accountViewModel: AccountViewModel) { + val addClientTag by accountViewModel.account.settings.syncedSettings.security + .addClientTag + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Code, + title = R.string.add_client_tag_title, + description = R.string.add_client_tag_explainer, + checked = addClientTag, + onCheckedChange = accountViewModel::updateAddClientTag, + ) +} + +@Composable +private fun BooleanSwitchTile( + flow: MutableStateFlow, + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, +) { + val value by flow.collectAsState() + + SwitchTile( + icon = icon, + title = title, + description = description, + checked = value == BooleanType.ALWAYS, + onCheckedChange = { isOn -> + flow.tryEmit(if (isOn) BooleanType.ALWAYS else BooleanType.NEVER) + }, + ) +} + +@Composable +private fun SwitchTile( + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable fun ComposeSettingsScreenPreview() { - ThemeComparisonRow { - ComposeSettingsContent(UiSettingsFlow()) - } -} - -@Composable -fun ComposeSettingsContent(sharedPrefs: UiSettingsFlow) { - Column( - Modifier - .fillMaxSize() - .padding(top = Size10dp, start = Size20dp, end = Size20dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = RowColSpacing, - ) { - BooleanSwitchRow( - sharedPrefs.automaticallyCreateDrafts, - R.string.auto_create_drafts_setting_title, - R.string.auto_create_drafts_setting_description, - ) - BooleanSwitchRow( - sharedPrefs.automaticallyProposeAiImprovements, - R.string.ai_writing_setting_title, - R.string.ai_writing_setting_description, - ) - BooleanSwitchRow( - sharedPrefs.useTrackedBroadcasts, - R.string.tracked_broadcasts_setting_title, - R.string.tracked_broadcasts_setting_description, - ) - } -} - -@Composable -private fun BooleanSwitchRow( - flow: MutableStateFlow, - title: Int, - description: Int, -) { - val value by flow.collectAsState() - - SettingsRow(title, description) { - Switch( - checked = value == BooleanType.ALWAYS, - onCheckedChange = { isOn -> - flow.tryEmit(if (isOn) BooleanType.ALWAYS else BooleanType.NEVER) - }, - ) + ThemeComparisonColumn { + ComposeSettingsContent(UiSettingsFlow(), mockAccountViewModel()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt index 2500e8078d..ad047e37d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt @@ -26,10 +26,14 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -83,7 +87,6 @@ fun HiddenWordsScreen( var selected by remember { mutableStateOf(setOf()) } Scaffold( - modifier = Modifier.imePadding(), topBar = { BlockListTopBar( title = R.string.hidden_words, @@ -104,7 +107,10 @@ fun HiddenWordsScreen( ) }, bottomBar = { - Surface(tonalElevation = 3.dp) { + Surface( + tonalElevation = 3.dp, + modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime)), + ) { AddMuteWordTextField(accountViewModel) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index c040be314d..11faac4803 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -84,8 +84,6 @@ private fun SecurityPreferencesSection(accountViewModel: AccountViewModel) { SettingsDivider() HideCommunityViolationsTile(accountViewModel) SettingsDivider() - DisableClientTagTile(accountViewModel) - SettingsDivider() WarnReportsTile(accountViewModel) SettingsDivider() MaxHashtagsTile(accountViewModel) @@ -147,21 +145,6 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { ) } -@Composable -private fun DisableClientTagTile(accountViewModel: AccountViewModel) { - val disableClientTag by accountViewModel.account.settings.syncedSettings.security - .disableClientTag - .collectAsStateWithLifecycle() - - SwitchTile( - icon = MaterialSymbols.Code, - title = R.string.disable_client_tag_title, - description = R.string.disable_client_tag_explainer, - checked = disableClientTag, - onCheckedChange = accountViewModel::updateDisableClientTag, - ) -} - @Composable private fun WarnReportsTile(accountViewModel: AccountViewModel) { val security = accountViewModel.account.settings.syncedSettings.security diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt index e6e6dada17..704575d6e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt @@ -261,18 +261,7 @@ private fun VideoPlayerButtonItemCard( scaleX = 1.02f scaleY = 1.02f } - }.padding(vertical = 8.dp, horizontal = Size20dp) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag(dragAmount.y) - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - ) - }, + }.padding(vertical = 8.dp, horizontal = Size20dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -296,7 +285,20 @@ private fun VideoPlayerButtonItemCard( } Box( - modifier = Modifier.size(32.dp), + modifier = + Modifier + .size(32.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, contentAlignment = Alignment.CenterEnd, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 36efa974d0..0955d9a960 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -742,5 +743,9 @@ private suspend fun probeSourceMetadata( private fun formatSize(bytes: Long): String { if (bytes <= 0) return "—" val mb = bytes.toDouble() / (1024 * 1024) - return if (mb >= 1) String.format("%.1f MB", mb) else String.format("%.0f KB", bytes / 1024.0) + return if (mb >= 1) { + String.format(Locale.getDefault(), "%.1f MB", mb) + } else { + String.format(Locale.getDefault(), "%.0f KB", bytes / 1024.0) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index e1c01e032c..ec9fc62172 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -176,6 +176,7 @@ val Size22Modifier = Modifier.size(22.dp) val Size24Modifier = Modifier.size(24.dp) val Size25Modifier = Modifier.size(25.dp) val Size26Modifier = Modifier.size(26.dp) +val Size27Modifier = Modifier.size(27.dp) val Size28Modifier = Modifier.size(28.dp) val Size30Modifier = Modifier.size(30.dp) val Size35Modifier = Modifier.size(35.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index d6c4ebf26d..213d04d4e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -107,6 +107,9 @@ private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0. private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f) private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f) +private val DarkOnSurface65 = DarkColorPalette.onSurface.copy(alpha = 0.65f).compositeOver(DarkColorPalette.surface) +private val LightOnSurface65 = LightColorPalette.onSurface.copy(alpha = 0.65f).compositeOver(LightColorPalette.surface) + private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.42f) private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.42f) @@ -437,6 +440,9 @@ val ColorScheme.onBackgroundColorFilter: ColorFilter val ColorScheme.grayText: Color get() = if (isLight) LightGrayText else DarkGrayText +val ColorScheme.onSurface65: Color + get() = if (isLight) LightOnSurface65 else DarkOnSurface65 + val ColorScheme.subtleBorder: Color get() = if (isLight) LightSubtleBorder else DarkSubtleBorder diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index e52557ee7b..b39488d19b 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -661,6 +661,7 @@ البحث عن تطبيق حذف الكل متأكد من حذف جميع المسودات؟ + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index f2a7c9afd3..4e072660b9 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -573,6 +573,7 @@ আপনার অনুদান আমাদের পার্থক্য গড়তে সাহায্য করে। প্রতিটি ইঞ্চি গুরুত্বপূর্ণ! এখনই দান করুন QR কোড স্ক্যান করুন + diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index adc1764c07..fd2fb898de 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -638,7 +638,6 @@ Odeslat Něco napište… Zatím žádné zprávy. Buďte první. - Reagovat Reagovat Upravit místnost Uložit @@ -668,16 +667,8 @@ Požádá klienta uživatele %1$s o ztlumení mikrofonu. Někteří klienti mohou tento příkaz ignorovat. Vynutit ztlumení Zrušit - Audio místnost - Sledujete %1$s. - Přestali jste sledovat %1$s. - %1$s ztlumen/a. - Ztlumení %1$s zrušeno. - %1$s povýšen/a na mluvčího. - %1$s povýšen/a na moderátora. - %1$s snížen/a na posluchače. - %1$s vynuceně ztlumen/a. - %1$s byl/a vykopnut/a z místnosti. + Akce selhala + Akci nelze připravit. Sdílet místnost Minimalizovat Minimalizovat a poslouchat dál @@ -1133,8 +1124,8 @@ Varovat, když příspěvky obsahují hlášení od vašich sledovaných osob Filtrovat spam Skryje příspěvky od cizinců, které byly přesně stejné 5 a více krát - Nepřidávat klientský tag k mým událostem - Pokud je povoleno, Amethyst nebude k tvým publikovaným událostem přidávat klientský tag NIP-89. + Přidávat klientský tag do mých událostí + Když je tato možnost zapnutá, Amethyst připojuje k událostem, které publikuješ, klientský tag podle NIP-89. Upozornit na hlášení Zobrazí varovnou zprávu, když příspěvky mají 5 nebo více hlášení od vašich sledovaných Práh varování o nahlášeních @@ -1145,6 +1136,12 @@ Skryje příspěvky s více hashtagy, než je tento limit. Nastavte 0 pro deaktivaci Skrývat příspěvky porušující pravidla komunity Skryje příspěvky z komunitních feedů, když komunita zveřejní dokument s pravidly NIP-9B a událost by jím neprošla. Bez efektu, pokud komunita nemá strukturovaná pravidla. + Předvolby filtrování + Blokovaný obsah + ∞ + Zatím jsi nezablokoval/a žádné uživatele. + V této relaci nebyly žádné účty označeny jako spam. + Žádná skrytá slova. Přidej slovo níže pro skrytí příspěvků, které ho obsahují. Nový symbol reakce Nebyly vybrány žádné typy reakcí. Dlouhým stiskem změňte Zap-sběr @@ -1342,6 +1339,8 @@ Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. Hashtag-exkluzivní příspěvek Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí. + Komentář k webové stránce + Komentář k externímu zdroji %1$d min čtení Načítání umístění Žádná lokace oprávnění @@ -1679,6 +1678,12 @@ Záložky domovské obrazovky Vyberte, které záložky se zobrazí na domovské obrazovce. Pokud je aktivní jen jedna záložka, lišta záložek se skryje. Vše + Vzhled profilu + Vyberte, které sekce a kanály se zobrazí na profilových obrazovkách uživatelů. Všechny možnosti jsou ve výchozím nastavení povoleny. + Profilové odznaky + Doporučení aplikací + Kanál přijatých zapů + Kanál sledujících Nastavení reakcí Nastavte, která tlačítka reakcí se zobrazují, jejich pořadí a zda se mají zobrazovat počty. Povoleno @@ -1930,8 +1935,11 @@ Oblíbené algoritmy zdrojů Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších. Připněte si oblíbené algoritmy - Klepněte na tlačítko níže pro procházení algoritmů a označte hvězdičkou ty, které si zde chcete ponechat. + Klepněte na \"%1$s\" níže pro procházení algoritmů. + + Klepněte na %1$s vedle kanálu, abyste si jej zde uložili. Přidat nějaké kanály + Přidat další… Žádost %1$s o zdroj… Žádost oblíbených algoritmů o zdroje… Zpracování zdroje… @@ -2538,6 +2546,9 @@ Používá AI model běžící v zařízení k navrhování oprav textu a změn tónu. Sledovaná vysílání Použít sledovaného vysílače při odesílání událostí. Zobrazuje živý průběh a stav jednotlivých relayů během vysílání. + Nastavení editoru + Automaticky vytvářet koncepty + Automaticky uloží koncept události, když píšeš nebo opouštíš editor s neodeslaným textem, a odešle ho na tvé soukromé odchozí relaye. Použít toto Zavřít Opravit diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 559282bd0f..921a2cd8aa 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -632,7 +632,6 @@ anz der Bedingungen ist erforderlich Senden Sag etwas… Noch keine Nachrichten. Sei der Erste. - Reagieren Reagieren Raum bearbeiten Speichern @@ -662,16 +661,8 @@ anz der Bedingungen ist erforderlich Fordert den Client von %1$s auf, das Mikrofon stummzuschalten. Manche Clients ignorieren diesen Befehl. Stummschalten erzwingen Abbrechen - Audioraum - Du folgst %1$s. - Du folgst %1$s nicht mehr. - %1$s stummgeschaltet. - Stummschaltung von %1$s aufgehoben. - %1$s zum Sprecher befördert. - %1$s zum Moderator befördert. - %1$s zum Zuhörer herabgestuft. - %1$s wurde zwangsweise stummgeschaltet. - %1$s aus dem Raum entfernt. + Aktion fehlgeschlagen + Die Aktion konnte nicht vorbereitet werden. Raum teilen Minimieren Minimieren, um weiter zuzuhören @@ -1126,8 +1117,8 @@ anz der Bedingungen ist erforderlich Warnung bei Meldungen von deinen Abonnements Spam filtern Versteckt Beiträge von Fremden, die 5 oder mehr Male genau die gleichen waren - Kein Client-Tag zu meinen Ereignissen hinzufügen - Wenn aktiviert, fügt Amethyst deinen veröffentlichten Ereignissen kein NIP-89 Client-Tag hinzu. + Client-Tag zu meinen Ereignissen hinzufügen + Wenn aktiviert, fügt Amethyst Ereignissen, die du veröffentlichst, einen NIP-89-Client-Tag hinzu. Bei Berichten warnen Zeigt eine Warnmeldung an, wenn die Beiträge 5 oder mehr Berichte von den folgenden Beiträgen enthalten Warnschwelle für Meldungen @@ -1138,6 +1129,12 @@ anz der Bedingungen ist erforderlich Blendet Beiträge mit mehr Hashtags als dieses Limit aus. Auf 0 setzen zum Deaktivieren Beiträge ausblenden, die gegen Community-Regeln verstoßen Verwirft Beiträge aus Community-Feeds, wenn die Community ein NIP-9B-Regeldokument veröffentlicht und ein Ereignis daran scheitern würde. Keine Wirkung, wenn eine Community keine strukturierten Regeln hat. + Filtereinstellungen + Blockierte Inhalte + ∞ + Du hast noch keine Benutzer blockiert. + In dieser Sitzung wurden keine Konten als Spam markiert. + Keine versteckten Wörter. Füge unten ein Wort hinzu, um Beiträge auszublenden, die es enthalten. Neues Reaktionssymbol Keine Reaktionstypen ausgewählt. Lange drücken, um zu ändern Zap-Sammlung @@ -1335,6 +1332,8 @@ anz der Bedingungen ist erforderlich Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen. Hashtag-exklusive Beitrag Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen. + Eine Webseite kommentieren + Eine externe Ressource kommentieren %1$d Min. Lesezeit Standort wird geladen Keine Standortberechtigungen @@ -1672,6 +1671,12 @@ anz der Bedingungen ist erforderlich Startseiten-Tabs Wähle, welche Tabs auf der Startseite erscheinen. Wenn nur ein Tab aktiv ist, wird die Tab-Leiste ausgeblendet. Alles + Profil-Oberfläche + Wähle, welche Abschnitte und Feeds auf den Profilseiten erscheinen. Alle Optionen sind standardmäßig aktiviert. + Profilabzeichen + App-Empfehlungen + Erhaltene-Zaps-Feed + Follower-Feed Reaktions-Einstellungen Konfigurieren Sie, welche Reaktionsschaltflächen angezeigt werden, ihre Reihenfolge und ob Zähler angezeigt werden sollen. Aktiviert @@ -1923,8 +1928,11 @@ anz der Bedingungen ist erforderlich Bevorzugte Feed-Algorithmen Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. Lieblingsalgorithmen anheften - Tippe auf die Schaltfläche unten, um Algorithmen zu durchsuchen, und markiere die, die du hier behalten möchtest, mit einem Stern. + Tippe unten auf \"%1$s\", um Algorithmen zu durchsuchen. + + Tippe auf den %1$s neben einem Feed, um ihn hier zu behalten. Feeds hinzufügen + Weitere hinzufügen… Frage %1$s nach einem Feed… Frage deine bevorzugten Feed-Algorithmen nach Feeds… Feed wird verarbeitet… @@ -2519,6 +2527,9 @@ anz der Bedingungen ist erforderlich Verwendet ein KI-Modell auf dem Gerät, um Textkorrekturen und Tonänderungen vorzuschlagen. Verfolgte Übertragungen Verwende den verfolgten Broadcaster beim Senden von Ereignissen. Zeigt Live-Fortschritt und Status pro Relay während der Übertragung. + Editor-Einstellungen + Entwürfe automatisch erstellen + Speichert automatisch ein Entwurfsereignis, wenn du tippst oder den Editor mit ungesendetem Text verlässt, und sendet es an deine privaten ausgehenden Relays. Verwenden Schließen Korrigieren diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index ba14ef1590..e39adf1b7e 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -515,6 +515,7 @@ Για να λαμβάνετε ειδοποιήσεις Push, εγκαταστήστε οποιαδήποτε εφαρμογή τις υποστηρίζει [Unified Push](https://unifiedpush.org/), όπως πχ το [Ntfy](https://ntfy.sh/). Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index ea22da2d87..e2ee2b11e4 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -8,6 +8,7 @@ Show Anyway 👀 + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 79cc035c1a..cb0593e477 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -443,6 +443,7 @@ Nekapable sendi zapon Mesaĝi la Uzanto Okej + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 59509aa52c..cb2d753093 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1264,6 +1264,7 @@ El evento no tiene suficiente información para crear un enlace magnético Mis listas/conjuntos Selecciona una lista para filtrar el tablón + Cerrar sesión al bloquear dispositivo Mensaje privado Mensaje público diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index e6254dfd48..4bf20bee13 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1251,6 +1251,7 @@ El evento no tiene suficiente información para crear un enlace magnético Mis listas/conjuntos Selecciona una lista para filtrar el feed + Cerrar sesión al bloquear dispositivo Mensaje privado Mensaje público diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index f0dacc580b..9aa28bebea 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1252,6 +1252,7 @@ El evento no tiene suficiente información para crear un enlace magnético Mis listas/conjuntos Selecciona una lista para filtrar el feed + Cerrar sesión al bloquear dispositivo Mensaje privado Mensaje público diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 44d64d05cf..747565aef4 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -946,6 +946,7 @@ فایل باز نشد هیچ اپ تورنتی برای باز کردن و بارگیری فایل نصب نیست. لیستی را برای فیلتر خبرنامه انتخاب کنید + با قفل کردن دستگاه از حساب کاربری خارج شو پیام خصوصی diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index b2183baf63..87b57d23af 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -570,6 +570,7 @@ Palvelin ei antanut URL-osoitetta latauksen jälkeen Mediaa ei voitu ladata palvelimelta Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index b502067cb4..f8956af81f 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1284,6 +1284,7 @@ Hashtags Communautés Listes + Se déconnecter au verrouillage de l\'appareil Message privé Message public diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 37cfa29b17..08cf4d0079 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -372,6 +372,8 @@ बाधित करें मिटाएँ बाधित करें + सूत्र मौन करें + सूत्र मौन हटाएँ सूचना मिटाएँ फिर से ना दिखाएँ @@ -626,7 +628,6 @@ भेजें कुछ बात करें… कोई सन्देश नहीं अब तक। आप ही पहले चर्चा आरम्भ करें। - प्रतिक्रिया प्रतिक्रिया शाला सम्पादन अभिलेखन @@ -648,6 +649,16 @@ अनुचरण ना करें मौन मौन हटाएँ + सम्भाव्यतः उन ग्राहकों द्वारा उपेक्षित जो आज्ञा का सम्मान नहीं करते। + क्या शाला से निष्कासित करें। + %1$s को ध्वनि तल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें। + पदप्रहार + क्या वक्ता को मौन करें। + %1$s के ग्राहक को अपना ध्वनिग्राहक मौन करने का अनुरोध करता है। कुछ ग्राहक इस आदेश की उपेक्षा कर सकते हैं। + मौनकरण + निरस्त + क्रिया असफल + क्रिया का निर्माण नहीं हो सका। शाला बाँटें छोटा करें सुनते रहने के लिए छोटा करें @@ -1077,6 +1088,10 @@ आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। + सूचनाएँ विभाजित करें अनुचरित के अनुसार + दो सूचना पृष्ठसूचक दिखाएँ। अनुचरित (लोग जिनका आप अनुचरण करते हैं) तथा सभी। अपठित संकेतक चमकता है केवल आपके द्वारा अनुचरित लोगों के गतिविधियों के लिए। + का अनुचरण + सभी विद्युत्कोष अनुकूलन सक्रिय आण्ड्रोइड पुनःप्रसारक संयोजनों को प्रतिबंधित कर सकता है पृष्ठतल में। विद्युत्कोष अनुकूलन निष्क्रिय करें अमेथिस्ट के लिए सुनिश्चत रूप से सूचनाएँ प्राप्त करने के लिए। अभी ठीक करें @@ -1101,8 +1116,8 @@ चेतावनी दें जब पत्र सूचित किये गये हों आपके द्वारा अनुचरित व्यक्तियों से कचरालेख छलनी अज्ञात लोगों से पत्र छिपाएँ जो निरन्तर 5 अथवा अधिक बार यथावत समान थे - मेरे घटनाओं में ग्राहक सूचक ना जोडें - जब सक्षम अमेथिस्ट निप॰८९ ग्राहक सूचक नहीं जोडेगा आपके द्वारा प्रकाशित घटनाओं में। + मेरे घटनाओं में ग्राहक सूचक जोडें + जब सक्षम अमेथिस्ट निप॰८९ ग्राहक सूचक जोडेगा आपके द्वारा प्रकाशित घटनाओं में। सूचनाएँ प्राप्त होने पर चेतावनी दें चेतावनी सन्देश दिखाता है जब प्रकाशित पत्र 5 अथवा अधिक बार सूचित हो आपके द्वारा अनुचरित लेखाओं से चेतावनी सीमा सूचना @@ -1113,6 +1128,12 @@ इससे अधिक विषयसूचक युक्त पत्रों को नहीं दिखाता। शून्य रखें अक्षम करने के लिए समुदाय नियमों का उल्लंघन करनेवाले पत्रों को छुपाएँ समुदाय सूचनावलियों से पत्र हटाएँ जब समदाय द्वारा निप॰९ए नियम अभिलेख प्रकाशित है तथा घटना उसका उल्लंघन करेगा। कोई प्रभाव नहीं जब समुदाय के कोई व्यवस्थित नियम नहीं। + छलनी आद्यताएँ + बाधित विषयवस्तु + असीमित + आपने किसी प्रयोक्ता को बाधित नहीं किया अब तक। + कोई लेखा चिह्नित नहीं कचरालेख के लिए इस सत्र में। + कोई बाधित शब्द नहीं। नीचे शब्द जोडें उन पत्रों को अदृश्य करने के लिए जिनमें वह है। नया प्रतिक्रिया चिह्न इस उपयोगकर्ता के लिए कोई प्रतिक्रिया प्रकार पूर्व चयनित नहीं। हृदयचिह्न घुण्डी पर दीर्घतः दबाएँ परिवर्तन करने के लिए ज्सापोपार्जन योजना @@ -1290,6 +1311,7 @@ कचरालेख प्रेषक मौन किया गया। अमौन करने के लिए टाँकें ध्वनि सक्रिय। मौन करने के लिए टाँकें + मौन हटाएँ %d सेकण्ड पूर्व जाएँ %d सेकण्ड आगे जाएँ चित्र में चित्र @@ -1309,6 +1331,8 @@ स्थल के अनुचर ही देखेंगे। आपके सामान्य अनुचर नहीं देखेंगे। विषयसूचक विशेष पत्र केवल विषयसूचक के अनुचर इसे देखेंगे। आपके सामान्य अनुचर नहीं देखेंगे। + जालस्थान पर टिप्पणी + बाहरी संसाधन पर टिप्पणी %1$d मिन॰ पठन स्थान प्राप्त किया जा रहा है स्थान प्राप्त करने की अनुमति नहीं @@ -1413,6 +1437,9 @@ ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए छिपाए गये शब्द नया शब्द अथवा वाक्य छिपाएँ + मौनकृत सूत्र + कोई मौनकृत सूत्र नहीं + अज्ञात सूत्र। %1$s परिचय चित्र परिचय चित्र दिखाएँ विकल्प का चयन करें @@ -1644,6 +1671,12 @@ मुख्यपटल पृष्ठसूचक चयन करें किन पृष्ठसूचक दिखने चाहिए मुख्यपटल पर। जब एक ही सक्रिय है तब पृष्ठसूचक पट्टी छुपाई जाएगी। सभी + परिचय प्रयोगमाध्यम + चयन करें कि किन विभाग तथा सूचनावलियाँ दिखेंगे प्रयोक्ता परिचय पटल पर। सभी विकल्प सक्षम हैं मूलतः। + परिचय पदक + क्रमक अनुशंसाएँ + ज्साप प्राप्त सूचनावली + अनुचर सूचनावली प्रतिक्रिया पंक्ति समाकृति करें कि किन प्रतिक्रिया घुण्डियाँ दिखाए जाएँगे तथा उनके क्रम तथा यदि संकलन सूचक दिखाए जाएँगे। सक्षम @@ -1678,6 +1711,8 @@ चलचित्र का अवरोहण करें आपके यन्त्र तक (सीधे प्रसारों में अदृश्य) चित्र में चित्र चलचित्र दिखाएँ तैरते गवाक्ष में (अदृश्य यदि अनालम्बित) + यन्त्र पर निक्षेपण + चलचित्र को क्रेमकास्ट॰ ग्राहक तक भेजें आपके वैफै॰ पर (स्थानीय अभिलेखों के लिए अदृष्ट) %1$s का परिचय चित्र पुनःप्रसारक %1$s पुनःप्रसारक सूची का विस्तारण करें @@ -1796,6 +1831,18 @@ गिट क्रमलेखकोश : %1$s जाल : अनुकृति : + खुला + विलीन + अवरुद्ध + पाण्डुलिपि + रूपरेखा विवरण + समस्याएँ + सुधारलेख तथा पीआर॰ + जानकारी + योजक + परिपालक + विषय सूची + व्यक्तिगत पतभेद स्थिर जालस्थान : %1$s मूल स्थान स्रोत : @@ -1893,8 +1940,11 @@ इष्ट सूचनावली कलनविधियाँ सूचनावली कलनविधियाँ जिनहें आपने यहाँ ताराचिह्नित किए मूल सूचनावली पर छलनी खण्डों के जैसे दिखेंगे। खोज पटल खोलें अधिक जोडने के लिए। आपके इष्ट कलनविधियाँ अटकाएँ - नीचे घुण्डी दबाएँ कलनविधियों का वीक्षण करने के लिए तथा ताराचिह्नित करें जिनको आप यहाँ रखना चाहते हैं। + नीचे \"%1$s\" दबाएँ कलनविधियों का वीक्षण करने के लिए। + + सूचनावली का पास %1$s दबाएँ यहाँ रखने के लिए। सूचनावलियाँ जोडें + अधिक जोडें… अनुरोध %1$s को सूचनावली के लिए… आपके इष्ट सूचनावली कलनविधियों को अनुरोध किया जा रहा है सूचनावलियों के लिए… आपकी सूचनावली पर कार्य चालू… @@ -2287,6 +2337,10 @@ चालन स्वचालित + यन्त्र पर निक्षेपण + निक्षेपण रोकें + निक्षेपण इस पर… + आपके वैफै॰ पर यन्त्रों का खोज चालू… एचएलएस॰ आरोहण बहुसुलझाव एचएलएस॰ प्रकाशित करें आपके प्रसारसंगणक में @@ -2485,6 +2539,9 @@ यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए। पदचिह्न युक्त प्रसारण पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। तत्काल प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। + सम्पादन स्थापना विकल्प + पाण्डुलिपियाँ स्वतः बनाएँ + स्वतः पाण्डुलिपि घटना का अभिलेखन करता है जब आप टंकण करते है अथवा सम्पादक छोडते हैं अप्रेषित लेख के साथ तथा आपके निजी निर्गतपेटिका पुनःप्रसारकों को भेजता है। इसका प्रयोग करें हटाएँ सम्यक diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 00497279f3..5ea6fd776f 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -372,6 +372,8 @@ Letiltás Törlés Letiltás + Szál némítása + Szál némításának megszüntetése Bejelentés Törlés Ne jelenítse meg újra @@ -485,7 +487,7 @@ Jóváhagyások Nincs olyan közösség, amely megfelelne ennek a szűrőnek. Ellenőrizhető szabályok (nem kötelező) - A közösség mellett egy géppel olvasható szabálydokumentumot (NIP-9B) is közzétesz. Az ezt támogató kliensek küldés előtt blokkolják a szabályoknak meg nem felelő bejegyzéseket. + A közösség mellett egy géppel olvasható szabálydokumentumot (NIP-9B) is közzétesz. Az ezt támogató kliensek küldés előtt eltávolítják a szabályoknak meg nem felelő bejegyzéseket. Engedélyezett eseménytípusok Koppintson egy típus engedélyezéséhez. Tartsa hosszan nyomva az eseményenkénti méretkorlát vagy a napi bejegyzéslimit beállításához. Rövid szöveg (1) @@ -543,7 +545,7 @@ Ön kapott egy kitűzőt Profilkitűzők Kitűzők · %1$d - Válassza ki, hogy a megszerzett jelvények közül melyek jelenjenek meg a profilban. + Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban. Ön még nem kapott kitűzőt. Képek Rövidek @@ -626,7 +628,6 @@ Küldés Mondjon valamit… Még nincsenek üzenetek. Legyen Ön az első hozzászóló. - Reagálás Reagálás Szoba szerkesztése Mentés @@ -656,16 +657,8 @@ Kérés %1$s kliense felé a mikrofon némítására. Néhány kliens figyelmen kívül hagyhatja ezt a parancsot. Kényszerített némítás Mégse - Hangszoba - %1$s követése. - %1$s nem követett. - %1$s némítva. - %1$s nincs némítva. - Előadává léptette elő őt: %1$s - Moderátorrá léptette elő őt: %1$s - Hallgatóvá fokozta le őt: %1$s - Kényszerítetten némította őt: %1$s. - Eltávolította a szobából őt: %1$s. + Sikertelen művelet + A műveletet nem sikerült előkészíteni. Szoba megosztása Minimalizálás Minimalizálás a további hallgatáshoz @@ -1123,8 +1116,8 @@ Figyelmeztessen amikor a bejegyzések jelentve vannak azok által akiket követek Kéretlen tartalomszűrő Elrejti az ismeretlen felhasználók hozzászólásait, amelyek pontosan ugyanazok voltak 5 vagy több alkalommal - Ne legyenek megjelölve az eseményeim klienscímkével - Ha engedélyezve van, akkor az Amethyst nem fűz NIP-89 klienscímkét az Ön által közzétett eseményekhez. + Klienscímke hozzáadása a saját eseményekhez + Ha engedélyezve van, az Amethyst egy NIP-89 klienscímkét fűz hozzá a közzétett eseményekhez. Figyelmeztetés a jelentésekre Figyelmeztető üzenetet jelenít meg, ha az Ön követői bejelentést tettek bejegyzésekről vagy profilokról Jelentési figyelmeztetés küszöbértéke @@ -1135,6 +1128,12 @@ Elrejti azokat a bejegyzéseket, amelyek több kulcsszót tartalmaznak ennél a korlátnál. A kikapcsoláshoz állítsa 0-ra Közösségi szabályokat sértő bejegyzések elrejtése Eltávolítja a bejegyzéseket a közösségi hírfolyamokból, ha a közösség közzétesz egy NIP-9B szabálydokumentumot, és egy esemény nem felel meg annak. Nincs hatása, ha a közösség nem rendelkezik strukturált szabályokkal. + Beállítások szűrése + Letiltott tartalom + ∞ + Ön még nem tiltott le egyetlen felhasználót sem. + Ebben a munkamenetben egyetlen fiókot sem jelölt meg kéretlen tartalomként. + Nincsenek rejtett szavak. Adjon hozzá egy szót alább, hogy elrejtse az azt tartalmazó bejegyzéseket. Új reakció-szimbólum A felhasználó számára nincsenek előre kiválasztott reakciótípusok. Hosszan nyomja meg a szív gombot a módosításhoz Zap-gyűjtés @@ -1312,6 +1311,7 @@ Spamelők Némítva. Koppintson a némitás megszüntetéséhez Hang bekapcsolva. Koppintson a némításhoz + Némítás megszüntetése Visszatekerés %d másodperccel Előretekerés %d másodperccel Kép a képben @@ -1331,6 +1331,8 @@ Csak a helyszín követői láthatják. Az általános követők nem fogják látni. Kulcsszó-exkluzív bejegyzés Csak a kulcsszó követői fogják látni, de az Ön általános követői viszont nem. + Megjegyzés egy weboldalhoz + Megjegyzés egy külső forráshoz Olvasási idő: %1$d perc Helyszín betöltése… A helyszín-meghatározás nincs engedélyezve @@ -1435,6 +1437,9 @@ Nem található Blossom-alkalmazás. Telepítsen egy helyi Blossom-alkalmazást a fájl megtekintéséhez Rejtett szavak Új szó vagy mondat elrejtése + Némított szálak + Nincsenek némított szálak + Ismeretlen szál · %1$s Profilkép Profilképek megjelenítése Válasszon egy lehetőséget @@ -1666,6 +1671,12 @@ Kezdőlap lapjai Válassza ki, mely lapok jelenjenek meg a kezdőlapon. Ha csak egy lap aktív, akkor a lapsáv rejtett. Minden + Profil felhasználói felülete + Válassza ki, hogy mely szekciók és hírfolyamok jelenjenek meg a felhasználói profil képernyőin. Alapértelmezés szerint minden beállítás engedélyezve van. + Profilkitűzők + Ajánlott alkalmazások + Beérkezett zapek hírfolyama + Követők hírfolyama Reakciósor Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését. Engedélyezve @@ -1697,7 +1708,7 @@ Megosztás vagy mentés Ossza meg a videó hivatkozását külső oldalakon Mentés a telefonra - Töltse le a videót a készülékedre (rejtett az élő közvetítéseknél) + Töltse le a videót a készülékére (rejtett az élő közvetítéseknél) Kép a képben Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja) Közvetítés eszközre @@ -1754,7 +1765,7 @@ Átjátszók a bejövő üzenetkhez Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához Az értesítések fogadásához kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez. - Adjon meg 1-3 átjátszót, amelyeken keresztül fogadni tudja az értesítéseket. Győződjön meg arról, hogy azok nem igényelnek fizetést vagy bizalmi hálózatot a feladótól, különben csak a fizetősektől vagy a hosszú távú felhasználóktól fog értesítéseket kapni + Adjon meg 1–3 átjátszót, amelyeken keresztül fogadni tudja az értesítéseket. Győződjön meg arról, hogy azok nem igényelnek fizetést vagy bizalmi hálózatot a feladótól, különben csak a fizetősektől vagy a hosszú távú felhasználóktól fog értesítéseket kapni Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Feltöltés közvetlen üzenetbe Átjátszók beállításai @@ -1770,7 +1781,7 @@ Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős) Kulcscsomagok Kulcscsomag-átjátszók - Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1-3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását. + Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1–3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását. Privát saját átjátszók Adjon hozzá 1–3 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt. Általános átjátszók @@ -1820,6 +1831,18 @@ Git tároló: %1$s Weboldal: Klónozás: + Nyitva + Beolvasztva + Lezárva + Vázlat + Áttekintés + Hibajegyek + Foltok & Beolvasztási kérések + Névjegy + Hivatkozások + Karbantartók + Témák + Személyes ág Statikus weboldal: %1$s Főoldal Forrás: @@ -1917,8 +1940,11 @@ Kedvenc hírfolyam-algoritmusok A kedvencnek jelölt algoritmusok itt jelennek meg, valamint szűrőelemként a Főoldal hírfolyamában. Kedvenc algoritmus kitűzése - Koppintson az alábbi gombra az algoritmusok böngészéséhez, és jelölje meg csillaggal azokat, amelyeket itt szeretne megőrizni. + Koppintson az alábbi „%1$s” gombra az algoritmusok böngészéséhez. + + Koppintson a hírcsatorna melletti %1$s ikonra, ha itt szeretné megtartani azt. Néhány hírfolyam hozzáadása + Továbbiak hozzáadása… %1$s lekérdezése egy hírfolyamért… Hírfolyamok keresése a kedvenc hírfolyam-algoritmusok segítségével… Hírfolyam feldolgozása… @@ -2513,6 +2539,9 @@ Az eszközön futó LLM-modell segítségével szövegjavításokkal és hangnemváltoztatásokkal kapcsolatos javaslatokat kaphat. Nyomon követhető közvetítések Nyomon követhető közvetítők használata az események küldesékor. Közvetítés közben megjeleníti a jelenlegi előrehaladást és az egyes átjátszók állapotát. + Üzenetírási beállítások + Vázlatok automatikus létrehozása + Automatikusan menti a vázlateseményt, amikor beírja a szöveget, vagy elküldetlen szöveggel hagyja a levelezőlistát, és elküldi a privát kimenő üzenetek mappájába. Ennek használata Eltüntetés Javítás diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index d6b9060ab1..2346f64c08 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -512,6 +512,7 @@ Seharusnya %3$s Untuk menerima Pemberitahuan Push, Install aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index e448f52a6e..bac1d8373a 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -433,6 +433,7 @@ Chat Globale Cerca + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 845bf06534..5bbe818ce7 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -390,6 +390,7 @@ 自動的に動画を再生 自動的にURLプレビューを表示 画像読み込み + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index b837335ed3..d54e87f0d2 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -172,6 +172,7 @@ Torrenta datne Lejupielādēt Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni. + diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index e3c4e20933..648a37ebdd 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -613,7 +613,6 @@ Versturen Zeg iets… Nog geen berichten. Wees de eerste die chat. - Reageren Reageren Ruimte bewerken Opslaan @@ -1079,8 +1078,6 @@ Waarschuwen bij rapportages van volgers Spamfilter Verbergt identieke berichten van onbekenden die 5 keer of vaker voorkomen - Geen client-tag toevoegen aan mijn events - Wanneer ingeschakeld voegt Amethyst geen NIP-89 client-tag toe aan events die je publiceert. Waarschuwen bij rapportages Toont waarschuwing wanneer een bericht 5 of meer rapportages van je volgers heeft Gevoelige inhoud tonen @@ -1859,7 +1856,7 @@ Favoriete feed-algoritmes Feed-algoritmes die je hier hebt gesterd, verschijnen als filterchips op de startfeed. Open Ontdekken om meer toe te voegen. Pin je favoriete algoritmes - Tik op de knop hieronder om algoritmes te bekijken en de gewenste te sterren. + Feeds toevoegen %1$s vragen voor een feed… Je favoriete feed-algoritmes vragen voor feeds… diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 749cf30ebe..7267f8f6bd 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -638,7 +638,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wyślij Powiedz coś… Na razie nie ma wiadomości. Zacznij rozmowę jako pierwszy. - Reagowanie Reaguj Edytuj pokój Zapisz @@ -668,16 +667,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Prosi klienta %1$so wyciszenie mikrofonu. Niektórzy klienci mogą zignorować to polecenie. Wymuś wyciszenie Anuluj - Pokój audio - Obserwujesz %1$s. - Nieobserwowany %1$s. - Wyciszono %1$s. - Odblokowany/na %1$s. - Wysunięto %1$s jako lektora. - Wysunięto %1$s jako moderatora. - Obniżono status %1$s na słuchacza. - Wymuszono blokadę %1$s. - Wyrzucono %1$s z pokoju. + Akcja nieudana + Nie udało się przygotować tej akcji. Udostępnij pokój Zminimalizuj Minimalizuj aby słuchać @@ -1135,8 +1126,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ostrzegaj, gdy posty zostały zgłoszone przez osoby które obserwujesz Filtr spamu Ukrywa posty od nieznajomych, które były dokładnie takie same przez 5 lub więcej razy - Nie dodawaj tagu klienta do moich publikacji - Po włączeniu tej opcji Amethyst nie będzie dołączać tagu klienta NIP-89 do Twoich publikacji. + Dodaj tag klienta do moich zdarzeń + Po włączeniu Amethyst dodaje tag klienta NIP-89 do zdarzeń, które publikujesz. Ostrzeżenie o zgłoszeniach Wyświetla komunikat ostrzegawczy, gdy posty zostały zgłoszone przez 5 lub więcej obserwowanych osób Zgłoś próg ostrzegawczy @@ -1147,6 +1138,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ukrywa posty zawierające więcej hashtagów niż ten limit. Ustaw wartość 0, aby wyłączyć tę funkcję Ukryj posty, które naruszają zasady społeczności Usuwa posty z kanałów społeczności, gdy społeczność opublikuje dokument zawierający zasady NIP-9B, a dane wydarzenie nie spełnia tych zasad. Nie ma to żadnego wpływu, jeśli społeczność nie posiada strukturyzowanych zasad. + Preferencje filtrowania + Zablokowane treści + ∞ + Nie zablokowałeś jeszcze żadnych użytkowników. + W tej sesji żadne konta nie zostały oznaczone jako spam. + Brak ukrytych słów. Wpisz poniżej słowo, aby ukryć posty, które je zawierają. Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser @@ -1344,6 +1341,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Tylko obserwatorzy z twojej lokalizacji to zobaczą. Twoi ogólni obserwatorzy nie zobaczą tego. Wpis hashtagowy Tylko obserwujący ten hashtag to zobaczą. Twoi ogólni obserwujący nie zobaczą tego. + Skomentuj na stronie internetowej + Skomentuj na zewnętrznym serwisie Czas czytania: %1$d min. Pobieranie lokalizacji Brak dostępu do lokalizacji @@ -1682,6 +1681,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Karty główne Wybierz, które karty pojawiają się na ekranie głównym. Gdy tylko jedna karta jest aktywna, pasek karty jest ukryty. Wszystko + Interfejs profilu + Wybierz które sekcje i kanały pojawiają się na ekranach profilu użytkownika. Wszystkie opcje są domyślnie włączone. + Odznaki w profilu + Rekomendacje aplikacji + Kanał, który otrzymał Zapa + Kanał Obserwowanych Ustawienia reakcji Skonfiguruj które przyciski reakcji będą wyświetlane, ich kolejność i czy wyświetlić liczniki. Włączone @@ -1836,6 +1841,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Repozytorium Git: %1$s Strona internetowa: Klonuj: + Otwarte + Połączone + Zamknięte + Szkic + Przegląd + Problemy + Łaty & PRs + O programie + Linki + Opiekunowie + Tematy + Osobisty fork Statyczna Witryna: %1$s Strona główna Źródło: @@ -1933,8 +1950,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Algorytmy ulubionych kanałów Algorytmy kanałów, które tutaj oznaczyłeś gwiazdką, pojawiają się jako ikony filtrów na stronie głównej. Otwórz sekcję Odkryj, aby dodać kolejne. Przypnij swoje ulubione algorytmy - Kliknij przycisk poniżej, aby przejrzeć algorytmy i oznaczyć gwiazdką te, które chcesz tutaj zachować. + Kliknij \"%1$s\" poniżej, aby przeglądać algorytmy. + + Kliknij %1$s obok kanału, aby go tu zachować. Dodaj kilka kanałów + Dodaj więcej… Poproś %1$s o wyświetlenie treści… Poproś algorytmy o wyświetlenie ulubionych treści… Przetwarzanie statusu kanału… @@ -2541,6 +2561,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Używa modelu sztucznej inteligencji wbudowanego w urządzenie, proponując poprawki tekstu i zmiany tonu wypowiedzi. Monitorowane transmisje Podczas wysyłania zdarzeń korzystaj z monitora transmisji. Pokazuje postęp na żywo i status transmisji podczas nadawania. + Skonfiguruj ustawienia + Automatycznie twórz wersje robocze + Automatycznie zapisuje wersję roboczą wiadomości podczas pisania lub po zamknięciu okna edycji z niewysłanym tekstem i umieszcza ją w prywatnej skrzynce wysyłkowej transmiterów. Użyj tego Ignoruj Popraw diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 4519431bd6..29736ded6a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -626,7 +626,6 @@ Enviar Diga algo… Ainda sem mensagens. Seja o primeiro a conversar. - Reagir Reagir Editar sala Salvar @@ -656,16 +655,8 @@ Pede ao cliente de %1$s para silenciar o microfone. Alguns clientes podem ignorar este comando. Silenciar à força Cancelar - Sala de áudio - Seguindo %1$s. - Você deixou de seguir %1$s. - %1$s silenciado(a). - Som de %1$s reativado. - %1$s promovido(a) a palestrante. - %1$s promovido(a) a moderador(a). - %1$s rebaixado(a) a ouvinte. - %1$s silenciado(a) à força. - %1$s expulso(a) da sala. + Falha na ação + A ação não pôde ser preparada. Compartilhar sala Minimizar Minimizar para continuar ouvindo @@ -1121,8 +1112,8 @@ Avise quando as postagens tiverem denuncias de seus seguidores Filtrar spam Esconde publicações de desconhecidos que eram exatamente o mesmo por 5 ou mais vezes - Não adicionar tag de cliente aos meus eventos - Quando ativado, o Amethyst não anexará uma tag de cliente NIP-89 aos eventos que você publicar. + Adicionar tag de cliente aos meus eventos + Quando ativado, o Amethyst anexa uma tag de cliente NIP-89 aos eventos que você publica. Avisar em relatórios Mostra uma mensagem de aviso quando as postagens tiverem 5 ou mais relatórios de suas seguintes Limite de aviso de denúncias @@ -1133,6 +1124,12 @@ Oculta postagens com mais hashtags do que este limite. Defina como 0 para desativar Ocultar posts que violam as regras da comunidade Remove posts dos feeds da comunidade quando ela publica um documento de regras NIP-9B e o evento não passaria. Sem efeito quando uma comunidade não tem regras estruturadas. + Preferências de filtragem + Conteúdo bloqueado + ∞ + Você ainda não bloqueou nenhum usuário. + Nenhuma conta foi marcada como spam nesta sessão. + Nenhuma palavra oculta. Adicione uma palavra abaixo para ocultar publicações que a contenham. Novo símbolo de reação Nenhum tipo de reação selecionado. Pressione e segure para alterar Arrecadação de Zaps @@ -1330,6 +1327,8 @@ Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. Postagem exclusiva de Hashtag Somente seguidores da hashtag verão isso. Seus seguidores gerais não verão isso. + Comentar em um site + Comentar em um recurso externo %1$d min de leitura Carregando localização Sem permissões para localização @@ -1667,6 +1666,12 @@ Abas da Tela Inicial Escolha quais abas aparecem na Tela Inicial. Quando apenas uma aba está ativa, a barra de abas fica oculta. Tudo + Interface do perfil + Escolha quais seções e feeds aparecem nas telas de perfil dos usuários. Todas as opções estão habilitadas por padrão. + Selos de perfil + Recomendações de Apps + Feed de Zaps Recebidos + Feed de Seguidores Configurações de Reações Configure quais botões de reação são exibidos, sua ordem e se os contadores devem ser mostrados. Ativado @@ -1918,8 +1923,11 @@ Algoritmos de feed favoritos Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. Fixe seus algoritmos favoritos - Toque no botão abaixo para explorar algoritmos e marque com estrela os que deseja manter aqui. + Toque em \"%1$s\" abaixo para navegar pelos algoritmos. + + Toque na %1$s ao lado de um feed para mantê-lo aqui. Adicionar alguns feeds + Adicionar mais… Solicitando feed a %1$s… Solicitando feeds aos seus algoritmos favoritos… Processando seu feed… @@ -2514,6 +2522,9 @@ Usa um modelo de IA no dispositivo para sugerir correções de texto e mudanças de tom. Transmissões rastreadas Usar o transmissor rastreado ao enviar eventos. Mostra progresso ao vivo e status por relay durante a transmissão. + Configurações de composição + Criar rascunhos automaticamente + Salva um evento de rascunho automaticamente quando você digita ou sai do compositor com texto não enviado e envia para seus relays de saída privados. Usar isto Dispensar Corrigir diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 586f0262e3..741cba59fb 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -539,6 +539,7 @@ Еда Разное Другое + Поделиться изображением… Не удается поделиться изображением, повторите попытку позже… Поделиться видео… diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 1373b5fbc0..fa2ca819e6 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -30,6 +30,7 @@ Kopiraj tekst Kopiraj ID avtorja Kopiraj ID zapiska + Kopiraj izvorni JSON Razpošlji Uporabi časovni žig Časovni žig: Potrditve v obdelavi @@ -125,7 +126,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ime kanala Moja vrhunska skupina URL slike - Url slike (izbirno) + Url slike (poljubno) Opis Ne najdem opisa "O nas… " @@ -168,6 +169,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zaimki Lightning naslov (LUD-16) Zastarel lightning naslov (LUD-06) + Izvorni releji morda niso posodobljeni + Opozorilo o neposodobljenih relejih Shrani v telefon Shrani v galerijo Slika shranjena v foto galerijo telefona @@ -380,6 +383,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Blokiraj Izbriši Blokiraj + Utišaj niz objav + Vklopi zvok niza objav Prijavi Izbriši Ne prikaži ponovno @@ -393,7 +398,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prijavi zlorabo Vse poslane prijave bojo javno vidne. - Opcijsko lahko dodaš dodatna pojasnila glede tvoje prijave… + Poljubno lahko dodaš dodatna pojasnila glede tvoje prijave… Dodaten kontekst Razlog Izberi razlog… @@ -409,6 +414,75 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Premakni vse med nove zaznamke Migracija zaznamkov uspešna Osnutki + Načrtovane objave + Časovno načrtovanje + Načrtovani čas + Objave bodo objavljene v približno 15 minutah od načrtovanega časa. + Izberi načrtovani čas + Načrtovano za… + Objavljleno v %1$s + Poteklo pred %1$s + Čas + Načrtovana objava + Prekliči načrtovanje + Trajna obvestila so izklopljena + Načrtovane objave morda ne bodo objavljene, dokler znova ne odprete aplikacije. Za zanesljivo načrtovanje v ozadju vklopite trajna obvestila v Nastavitve → Nastavitve vmesnika. + Načrtovane objave morda ne bodo objavljene, dokler znova ne odprete aplikacije. Načrtovane objave drugih računov se ne bodo sprožile, ko je aktiven ta račun. Za zanesljivo načrtovanje v ozadju vklopite trajna obvestila v Nastavitve → Nastavitve vmesnika. + Čez 1 uro + Jutri ob 9 AM + Naslednji Petek ob 9 AM + Omogočim trajna obvestila? + Načrtovane objave se zanesljivo objavljajo le, ko so omogočena trajna obvestila. V nasprotnem primeru se lahko sprožijo šele, ko znova odprete aplikacijo. + Odpri nastavitve + Vseeno nadaljuj + %1$s · čez %2$s + %1$s · %2$s nazaj + Jutri + Izpisani + + Odjavljeni · %d načrtovana objava je bila izbrisana + Odjavljeni · %d načrtovani objavi sta bili izbrisani + Odjavljeni · %d načrtovane objave so bile izbrisane + Odjavljeni · %d načrtovanih objav je bilo izbrisanih + + Načrtovana objava objavljena + Načrtovana objava ni bila objavljena + Načrtovane objave + Obvestila ob uspešni ali neuspešni objavi načrtovane objave. + Pošlji zdaj + Ni načrtovanih objav + Sestavite objavo in tapnite ikono ure, da jo načrtujete za pozneje. + Napaka: %1$s + Načrtovano + Pošiljam… + Neuspešno + Poslano + Preklicano + + Imate %d načrtovano objavo, ki še ni bila objavljena. Z odjavo jo boste trajno izbrisali. + Imate %d načrtovani objavi, ki še nista bili objavljeni. Z odjavo ju boste trajno izbrisali. + Imate %d načrtovane objave, ki še niso bile objavljene. Z odjavo jih boste trajno izbrisali. + Imate %d načrtovanih objav, ki še niso bile objavljene. Z odjavo jih boste trajno izbrisali. + + + %d v čakalni vrsti + %d v čakalni vrsti + %d v čakalni vrsti + %d v čakalni vrsti + + + %d na vrsti čez 1 h + %d na vrsti čez 1 h + %d na vrsti čez 1 h + %d na vrsti čez 1 h + + + na %d rele + na %d releja + na %d releje + na %d relejev + + ID zapiska je kopiran Glasovanja Odpri Končani @@ -433,6 +507,34 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zahtevki Odobritve Nobena skupnost ne ustreza temu filtru. + Preverljiva pravila (poljubno) + Objavi strojno berljiv dokument s pravili (NIP-9B) skupaj s skupnostjo. Nostr odjemalci, ki to podpirajo, blokirajo neustrezne objave še pred pošiljanjem. + Dovoljeni tipi (kind) dogodka + Tapnite za odobritev tipa (kind). Pridržite tip (kind) za nastavitev omejitev velikosti dogodka ali dnevnih objav. + Kratko besedilo (1) + Članek v daljši obliki (30023) + Slikovna objava (20) + Video objava (21) + Kratki videoposnetki (22) + Komentar skupnosti (1111) + Poljuben tip (kind) + Dodaj tip (kind) + Omejitve za tip (kind) %1$d + Največja velikost dogodka (bajtov) + Največje število objav na avtorja na dan + Pustite prazno za brez omejitev + Shrani omejitve + Blokirani uporabniki + Ti javni ključi ne morejo objavljati v skupnosti. + Dodaj blokiranega uporabnika + Ime, npub, ali NIP-05 + Web-of-trust vrata (poljubno) + Dovoli le avtorje, ki so od korenskega javnega ključa oddaljeni največ N skokov. Če je pogojev več, je dostop odobren, ko je izpolnjen vsaj eden. + Korenski npub ali hex javni ključ + Globina + Dodaj WoT vrata + Največja velikost dogodka (izbirno) + Bajtov \u2014 pustite prazno za brez omejitve. Prejeto Moje Podeljene @@ -549,7 +651,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pošlji Povej kaj… Še ni sporočil. Začni klepet. - Odzovi se Odzovi se Uredi sobo Shrani @@ -571,6 +672,16 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prenehaj slediti Utišaj Vklopi zvok + Nostr odjemalci, ki tega ukaza ne upoštevajo, ga lahko prezrejo. + Odstranim iz sobe? + Uporabnik %1$s bo odstranjen iz avdio prostora in seznama udeležencev. Ponovno se lahko pridruži, če ima povezavo do sobe. + Odstrani + Prisilno utišam govorca? + Nostr odjemalcu uporabnika %1$s pošlje zahtevek za utišanje mikrofona. Nekateri Nostr odjemalci lahko ta ukaz prezrejo. + Prisilno utišaj + Prekliči + Dejanje ni uspelo + Priprava dejanja ni uspela. Deli sobo Pomanjšaj Pomanjšaj za nadaljnje poslušanje @@ -601,7 +712,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Začni sobo za klepet Načrtovano za kasneje Izberi začetni čas - Strežnikignezda + Strežniki gnezda Izberite strežnike MoQ, na katerih Amethyst objavlja vaša gnezda. Prvi vnos bo privzeto uporabljen ob začetku novega pogovora. Vaši strežniki Shranjeno kot zamenljiv dogodek kind-10112, da lahko vaše nastavitve berejo tudi drugi odjemalci. @@ -760,6 +871,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem LnNaslov ali @Uporabnik Medijski strežniki Nastavi svoje prednostne medijske strežnike za nalaganje medijev. + Uporabi lokalni predpomnilnik Blossom + Ko se na tej napravi izvaja predpomnilnik Blossom (vrata 24242), se prenosi slik in videoposnetkov preusmerijo skoznje. + Na vratih 24242 je bil zaznan lokalni predpomnilnik. + Na vratih 24242 ni zaznati lokalnega predpomnilnika. + V predpomnilnik shrani le slike profilov + Lokalni predpomnilnik omeji le na slike profilov. Slike in videoposnetki iz vsebin se bodo prenašali neposredno z izvornih strežnikov. Nimate nastavljenih NIP-96 strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ Nimate nastavljenih Blossom strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ Priporočljivi medijski strežniki @@ -850,7 +967,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nastavi novo ime/opis za kloniran seznam spodaj Ime seznama Novo ime seznama - Opis seznama (izbirno) + Opis seznama (poljubno) Nov opis seznama Ustvari seznam Kopiraj/kloniraj seznam @@ -881,7 +998,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prisili tor pri relejih za odhodno/prejeto pošto Zaupanja vredni releji Prisili Tor na vseh relejih v tvojem spisku - Profilne slike + Slike profilov Prisili tor pri nalaganju profilnih slik URL predogledi Prisili tor pri nalaganju url predogledov @@ -994,6 +1111,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Povezovanje vhodnih relejev\u2026 Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. + Razdeli opozorila glede na sledilce + Prikaže dva zavihka z obvestili — Sledim (ljudje, ki jim slediš) in Vsi ostali. Indikator neprebranih obvestil sveti le za aktivnosti ljudi, ki jim slediš. + Sledim + Vsi Optimizacija baterije je aktivna Android lahko omeji povezave z releji v ozadju. Onemogočite optimizacijo baterije za Amethyst, da zagotovite zanesljivo prejemanje obvestil. Popravi zdaj @@ -1018,12 +1139,24 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Opozori, ko objave vsebujejo prijave od oseb, ki jim slediš Filtriraj nezaželeno vsebino Skrije objave neznancev, ki so bile popolnoma enake več kot petkrat + Vključi oznako Nostr odjemalca v moje dogodke + Če je omogočeno, Amethyst vašim objavljenim dogodkom doda NIP-87 oznako Nostr odjemalca. Opozori o prijavah Prikaže opozorilo, ko objava preseže 5 ali več prijav od oseb, ki jim slediš + Prag za opozorilo o prijavah + Prikaže opozorilo, ko objave ali profili dosežejo toliko prijav s strani ljudi, ki jim slediš Prikaži občutljivo vsebino Prikaže opozorilo, če je avtor objavo označil kot občutljivo Največje število ključnikov na objavo Skrije objave, ki imajo več oznak od te omejitve. Za izklop nastavi na 0 + Skrij objave, ki kršijo pravila skupnosti + Iz vsebin skupnosti odstrani objave, ki kršijo pravila NIP-9B, če jih je skupnost objavila. Nima učinka, če skupnost nima strukturiranih pravil. + Možnosti filtriranja + Blokirana vsebina + ∞ + Zaenkrat še niste blokirali nobenega uporabnika + V tej seji noben račun ni bil označen kot nezaželjen. + Ni skritih besed. Spodaj dodaj besedo, da skriješ objave, ki jo vsebujejo. Nov reakcijski simbol Za tega uporabnika niso predhodno izbrane nobene vrste reakcij. Dolgo pritisnite na gumb za srce, da jih spremenite Zapraiser @@ -1078,7 +1211,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zahteve Zadnje preverjanje %1$d ms - Sprejeti tipi (Kinds) + Sprejeti tipi (kind) Lokacija Aktivne naročnine Izhodni dogodki v teku @@ -1156,7 +1289,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta skupnost nima opisa. Obrnite se na lastnika, da ga doda Občutljiva vsebina Doda opozorilo o občutljivi vsebini pred prikazom te vsebine - UI Možnosti + Možnosti UI Nastavitve aplikacije Nastavitve uporabnika Prevodi @@ -1201,6 +1334,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pošiljatelji nezaželenih vsebin Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš + Vklopi zvok Skoči nazaj za %d sekund Skoči nazprej za %d sekund Slika v sliki @@ -1220,6 +1354,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. Ekskluzivna objava ključnika Vidno bo le sledilcem tega ključnika. Tvoji splošni sledilci tega ne bojo videli. + Komentiraj na spletni strani + Komentiraj na zunanjem viru %1$d min branja Nalaganje lokacije Brez dovoljenj za lokacijo @@ -1324,8 +1460,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Aplikacije Blossom niso bile najdene. Za ogled te datoteke namestite lokalno aplikacijo Blossom. Skrite besede Skrij novo besedo ali stavek + Utišaj nize objav + Ni utišanih nizov objav + Neznan niz objav · %1$s Slika profila - Prikaži profilne slike + Prikaži slike profilov Uporabi možnost Fakture ni bilo mogoče plačati Ni bilo mogoče dvigniti @@ -1478,7 +1617,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Plačilo uspešno Pošiljam plačilo… Vsota (sats) - Opis (izbirno) + Opis (poljubno) Ustvari fakturo Ustvarjam fakturo… Kopiraj fakturo @@ -1526,8 +1665,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Novi kratki videoposnetek Novi dolg videoposnetek Naslov - Povzetek (izbirno) - URL naslov naslovne slike (neobvezno) + Povzetek (poljubno) + URL naslov naslovne slike (poljubno) Napiši članek v obliki Markdown… Predogled Uredi @@ -1555,6 +1694,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Domači zavihki Izberite zavihke, ki se prikažejo na začetnem zaslonu. Če je aktiven le en zavihek, je vrstica z zavihki skrita. Vse + UI profila + Izberi, kateri razdelki in vsebine se prikažejo na profilih uporabnikov. Vse možnosti so privzeto omogočene. + profilne zančke + Priporočene aplikacije + Vsebina prejetih zapov + Vsebina sledilcev Vrstica odzivnih ikon Nastavite prikaza gumbov za odzive, njihov vrstni red in prikaz števcev. Omogočeno @@ -1589,6 +1734,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prenesite video na svojo napravo (skrito med prenosi v živo) Slika v sliki Predvajajte video v lebdečem oknu (skrito, če ni podprto). + Prenesi v napravo + Prenesi video v napravo Chromecast v svojem omrežju Wi-Fi (skrito za lokalne datoteke) Slika profila %1$s Rele %1$s Razširi seznam relejev @@ -1708,6 +1855,18 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Git skladišče: %1$s Splet: Klon: + Odprto + Združeno + Zaključeno + Osnutek + Predogled + Težave + Popravki in PR-ji + Vizitka repozitorija + Povezave + Vzdrževalci + Teme + Osebni fork Statična spletna stran: %1$s Izhodiščno spletišče Vir: @@ -1804,6 +1963,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odstrani iz priljubljenih Priljubljeni algoritmi vsebin Algoritmi vsebin, ki jih tukaj označite z zvezdico, se bodo prikazali kot filtri na začetnem zaslonu. Za nove odprite »Odkrij«. + Pripni svoje najljubše algoritme + Tapni \"%1$s\" spodaj za brskanje med algoritmi. + + Tapni %1$s ob vsebini, da jo ohraniš tukaj. + Dodaj nekaj vsebine + Dodaj več… Poizvedba pri %1$s za vsebine… Pridobivanje vsebin iz vaših najljubših algoritmov… Priprava vaših vsebin… @@ -1885,7 +2050,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Razpošlji paket Izbriši seznam Izbriši paket - Tipi + Tipi (kind) Ponovni poskus ni uspel Kliknite za ogled podrobnosti %1$s poslano @@ -2208,6 +2373,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Predvajaj Samodejno + Prenesi v napravo + Prenehaj s prenašanjem + Prenesi k… + Iščem naprave na vašem WI-Fi… Naloži HLS Objava HLS v več ločljivostih na medijski strežnik @@ -2302,11 +2471,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Namen zbiranja sredstev? Kratek povzetek Kratek opis za predogled - URL slike (izbirno) + URL slike (poljubno) https://example.com/image.jpg - URL spletišča (izbirno) + URL spletišča (poljubno) https://example.com - Končni rok (izbirno) + Končni rok (poljubno) Izberi končni rok Nov cilj Ustvari cilj @@ -2318,7 +2487,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem S tem boste vsem relejem poslali zahtevo za izbris vse vsebine, povezane z vašim ključem, do izbranega datuma. Ta dogodek bo oddan v najširšem možnem obsegu. Tega dejanja ni mogoče razveljaviti. Izbriši podatke do Vsi vaši dogodki, ustvarjeni pred tem datumom, bodo na izbranem releju predlagani za izbris. - Razlog (izbirno) + Razlog (poljubno) Razlog ali pravni pouk za upravitelja releja Oddaj zahtevo za izbris sledi Potrdi zahtevo za izbris sledi @@ -2358,7 +2527,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Noben dogodek ni potreben moderiranja Blokirani dogodki Ni Blokiranih dogodkov - Dovoljeni tipi + Dovoljeni tipi (kind) Ni dovoljenih tipov Blokirani IP-ji Ni blokiranih IP-jev @@ -2375,7 +2544,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem ID dogodka (hex) Številka tipa (kind) IP naslov - Razlog (izbirno) + Razlog (poljubno) Potrdi Prekini Uveljavi @@ -2406,6 +2575,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Uporablja model umetne inteligence v napravi za predloge popravkov besedila in spremembe tona. Sledeni prenosi Pri pošiljanju dogodkov uporabi sledilnik oddajanja. V živo prikazuje napredek in stanje posameznih relejev med oddajanjem. + Nastavitve urejevalnika + Samodejno ustvari osnutke + Samodejno shrani osnutek, ko tipkaš ali zapustiš urejevalnik z neposlanim besedilom, in ga pošlje v tvoje zasebne odhodne releje. Uporabi to Prekliči Pravilno @@ -2455,4 +2627,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zasebni emojiji so šifrirano shranjeni na relejih in vidni le vam. V meniju za odzive in pri samodejnem dokončanju z »:« se pojavijo enako kot javni. Gif + Kršitev pravil skupnosti + Ste na seznamu blokiranih v tej skupnosti. Objave ne bodo sprejete. + Dogodki tipa (kind) %1$d v tej skupnosti niso dovoljene. + Osnutek obsega %1$d bajtov; ta skupnost omejuje te tipe (kind) na %2$d bajtov. + Osnutek obsega %1$d bajtov; ta skupnost omejuje dogodke na %2$d bajtov. + Danes imate že %1$d dogodkov tega tipa (kind) (omejitev je %2$d). + Ne izpolnjuješ pogojev mreže zaupanja (WoT) te skupnosti. + Najnovejša pravila skupnosti so bila zavrnjena, ker so zastarela. diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 2add28a31a..50626e11f3 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -34,6 +34,7 @@ Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове Нема подешавања износа Зап. Дуго притисните да бисте променили + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ec92c18fdc..0e63d19028 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -626,7 +626,6 @@ Skicka Säg något… Inga meddelanden än. Var först att chatta. - Reagera Reagera Redigera rum Spara @@ -656,16 +655,8 @@ Ber %1$s:s klient att stänga av mikrofonen. Vissa klienter kan ignorera detta kommando. Tysta påtvingat Avbryt - Ljudrum - Följer %1$s. - Slutade följa %1$s. - %1$s tystad. - %1$s avtystad. - %1$s befordrad till talare. - %1$s befordrad till moderator. - %1$s degraderad till lyssnare. - %1$s tystad påtvingat. - %1$s utsparkad från rummet. + Åtgärden misslyckades + Åtgärden kunde inte förberedas. Dela rum Minimera Minimera för att fortsätta lyssna @@ -1120,8 +1111,8 @@ Varna när inlägg har rapporter från dina följare Filtrera spam Döljer inlägg från främlingar som var exakt samma för 5 eller fler gånger - Lägg inte till klienttagg i mina händelser - När aktiverat kommer Amethyst inte att lägga till en NIP-89 klienttagg till händelser du publicerar. + Lägg till klient-tagg i mina händelser + När det är aktiverat lägger Amethyst till en NIP-89-klient-tagg i händelser du publicerar. Varna vid rapporter Visar ett varningsmeddelande när inlägg har 5 eller fler rapporter från följande Tröskel för rapportvarning @@ -1132,6 +1123,12 @@ Döljer inlägg med fler hashtaggar än denna gräns. Sätt till 0 för att inaktivera Dölj inlägg som bryter mot gemenskapens regler Filtrerar bort inlägg från gemenskapsflöden när gemenskapen publicerar ett NIP-9B-regeldokument och en händelse inte skulle godkännas. Ingen effekt när en gemenskap inte har strukturerade regler. + Filterinställningar + Blockerat innehåll + ∞ + Du har inte blockerat några användare än. + Inga konton har flaggats som spam under denna session. + Inga dolda ord. Lägg till ett ord nedan för att dölja inlägg som innehåller det. Ny reaktionssymbol Inga reaktionstyper valda. Håll ned för att ändra Zap insamling @@ -1329,6 +1326,8 @@ Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. Hashtag-exklusivt inlägg Endast anhängare av hashtaggen kommer att se den. Dina generella följare kommer inte att se den. + Kommentera en webbplats + Kommentera en extern resurs %1$d min läsning Laddar position Inga platsbehörigheter @@ -1666,6 +1665,12 @@ Hemflikar Välj vilka flikar som visas på startskärmen. När endast en flik är aktiv döljs flikraden. Allt + Profilgränssnitt + Välj vilka sektioner och flöden som visas på användarprofiler. Alla alternativ är aktiverade som standard. + Profilmärken + App-rekommendationer + Mottagna zaps-flöde + Följarflöde Reaktionsinställningar Konfigurera vilka reaktionsknappar som visas, deras ordning och om räknare ska visas. Aktiverad @@ -1917,8 +1922,11 @@ Favorit-flödesalgoritmer Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. Fäst dina favoritalgoritmer - Tryck på knappen nedan för att bläddra bland algoritmer och stjärnmarkera de du vill behålla här. + Tryck på \"%1$s\" nedan för att bläddra bland algoritmer. + + Tryck på %1$s bredvid ett flöde för att behålla det här. Lägg till några flöden + Lägg till fler… Frågar %1$s om ett flöde… Frågar dina favorit-flödesalgoritmer om flöden… Bearbetar ditt flöde… @@ -2513,6 +2521,9 @@ Använder en AI-modell på enheten för att föreslå textkorrigeringar och tonändringar. Spårade sändningar Använd den spårade sändaren när du skickar händelser. Visar liveförlopp och status per relä under sändning. + Skrivinställningar + Skapa utkast automatiskt + Sparar ett utkast automatiskt när du skriver eller lämnar redigeraren med osänd text och skickar det till dina privata utgående reläer. Använd detta Avvisa Korrigera diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index 7692d8094b..b6980623de 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -444,6 +444,7 @@ Soga Ulimwenguni Tafuta + diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index c73d9a7317..d536c5885b 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -370,6 +370,7 @@ அங்கீகரிக்கப்பட்ட குறிப்பு இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 654694e56c..8adaee1844 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -814,6 +814,7 @@ ดาวน์โหลด ไม่สามารถเปิดไฟล์ได้ แลือกลิสต์เพื่อโชว์ + ออกจากระบบ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 5ea3a79286..37c2dfccf1 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -168,6 +168,7 @@ Tümünü okundu olarak işaretle Hata + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index f1401cc924..6d0fa218cf 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -500,6 +500,7 @@ Сервер не задав URL після завантаження Не вдалося завантажити завантажені медіафайли з сервера Не вдалося підготувати локальний файл для завантаження: %1$s + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index f5bba9d47a..d0a6a9148d 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -69,6 +69,7 @@ "bu kanalda: " Profil banneri + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index a1c0e53fdf..a126b42de0 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -12,6 +12,7 @@ Lưu ý cho người nhận Cảm ơn rất nhiều! + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 0c0d6df56e..1c6010ea0a 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -16,7 +16,7 @@ 群聊图片 露骨内容 中继通知 - 重复的贴文 + 重复的帖子 垃圾信息 来自该中继的垃圾事件数量 冒充 @@ -30,7 +30,7 @@ 复制文本 复制作者公钥 ID 复制笔记 ID - 复制原始JSON + 复制原始 JSON 广播 获取公开时间戳 OpenTimestamps:待确认 @@ -121,7 +121,7 @@ "关于我们.. " 你在想什么? 写一条消息… - 贴文 + 帖子 保存 创建 重命名 @@ -201,7 +201,7 @@ 私聊 新请求 已隐藏用户 - 新动态 + 新主题帖 会话 源 Mod 队列 @@ -372,6 +372,8 @@ 阻止 删除 隐藏 + 主题帖静音 + 取消主题帖静音 举报 删除 不再次显示 @@ -501,6 +503,7 @@ 添加被封禁用户 姓名, npub, 或 NIP-05 信任网络网关(可选) + 只允许从根公钥跳转 N 次内可触达的贴文作者。只要任何一条 WoT 信任链满足就能获得许可。 根npub 或16进制 pubkey 深度 添加信任网络(WoT)网关 @@ -556,7 +559,7 @@ 断开连接 静音 取消静音 - 正在连接... + 正在连接… 正在解析房间 正在打开传输 正在协商音频 @@ -619,7 +622,6 @@ 发送 说些什么… 还没有消息。成为第一个说话的。 - 回应 回应 编辑聊天室 保存 @@ -649,16 +651,8 @@ 要求 %1$s的客户端降低麦克风音量。有些客户端可能忽略此命令。 强制降低音量 取消 - 音频聊天室 - 正在关注 %1$s。 - 取消关注了 %1$s。 - 静音了 %1$s。 - 取消静音了 %1$s。 - 已提升 %1$s 为演讲者。 - 已提升 %1$s 为协管。 - 已降级 %1$s 为听众。 - 强制静音了 %1$s。 - 把 %1$s 踢出了房间。 + 操作失败 + 无法准备该操作。 分享聊天室 最小化 最小化以继续收听 @@ -754,7 +748,7 @@ 私密文章(%1$s) 公开文章 公开文章(%1$s) - 管理书签列表中的 %1$s + 管理书签列表中的%1$s 将此帖子加入书签 将此文章加入书签 在这里是一个公开书签 @@ -1116,8 +1110,8 @@ 当帖子有你的关注的报告时警告 过滤垃圾信息 隐藏陌生人的五次或五次以上完全相同的帖子 - 不要将客户标签添加到我的事件 - 启用时,Amethyst 将不会在您发布的事件中附加一个 NIP-89 客户端标签。 + 将客户端标签添加到我的事件 + 启用时,Amethyst 将会附加一个 NIP-89 客户端标签到您发布的事件。 举报警示 当帖子有来自你关注的五次及以上举报时显示警告消息 举报警告阈值 @@ -1128,6 +1122,12 @@ 隐藏话题超过此限制的帖子。设为 0 表示禁用 隐藏违反社区规则的帖子 当社区发布了一份NIP-9B规则文档而一个事件会让它失灵时,丢弃社区的帖子。 当社区没有结构化规则时没有任何影响。 + 过滤的首选项 + 拦截的内容 + ∞ + 您尚未屏蔽任何用户。 + 本次会话中还没有账户被标记为垃圾信息。 + 没有隐藏的单词。在下方添加一个单词来隐藏包含它的帖子。 新回应符号 未选择回应类型。长按可更改 Zapraiser @@ -1249,8 +1249,8 @@ 中继器 关注包 这些是您推荐给其他人的用户列表。只允许公开用户。 - 次浏览 - 动态源算法 + 阅读 + 算法动态 市场 直播 社区 @@ -1305,6 +1305,7 @@ 垃圾邮件 静音。点击取消静音 声音开启。点击静音 + 取消静音 后退 %d 秒 前进 %d 秒 画中画 @@ -1321,9 +1322,11 @@ 将位置显示为 将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里) 位置限定帖子 - 只有处于同一地理位置的关注者才能看到贴文。其他追随者无法看到。 - 话题限定贴文 - 只有关注了话题的人才能看到贴文,您的普通关注者也无法看到。 + 只有处于同一地理位置的关注者才能看到贴帖子。一般关注者无法看到。 + 话题限定帖 + 只有关注了话题的人才能看到帖子,您的普通关注者无法看到。 + 评论网站 + 评论外部资源 需 %1$d 分钟读完 加载位置中 没有位置信息权限 @@ -1428,6 +1431,9 @@ 找不到Blossom应用。请安装本地Blossom应用查看此文件 隐藏单词 隐藏新单词或句子 + 静音的主题帖 + 无静音的主题帖 + 未知主题帖 · %1$s 个人头像 显示个人头像 选择一个选项 @@ -1659,6 +1665,12 @@ 主页标签 选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。 全部 + 个人资料界面 + 选择在用户个人资料屏幕上显示哪些部分和源。默认情况下所有选项都启用。 + 个人资料徽章 + 应用推荐 + 收到的打闪源 + 关注者源 回应设置 配置显示的回应按钮、按钮顺序及是否显示回应计数。 已启用 @@ -1747,7 +1759,7 @@ 收件箱中继 设置您的公共收件箱中继来接收通知 创建专为接收通知而设计的中继列表对于您的 Nostr 体验至关重要。 - 插入 1-3 个为你接受通知的中继。确保它们不需要发送者的付款或 web of trust,否则你只能接收付款人或长期用户的通知 + 插入 1–3 个为你接受通知的中继。确保它们不需要发送者的付款或 web of trust,否则你只能接收付款人或长期用户的通知 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social DM 上传 中继设置 @@ -1789,7 +1801,7 @@ 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 广播中继 广播中继 - 推送你的贴文到其他中继的中继(例如:sendit.nosflare.com),应用会把这个中继作为提示嵌入到你发送的所有事件的 JSON 结构中。 + 推送你的帖子到其他中继的中继,例如:sendit.nosflare.com,应用会把这个中继作为提示嵌入到你发送的所有事件的 Json 结构中 索引器中继 索引器中继 专门保存每个人的账户元数据的中继(例如:purplepag.es),应用会使用这些中继来查找不在你列表中的账户元数据。 @@ -1813,6 +1825,18 @@ Git 仓库: %1$s 网址 克隆: + 打开 + 已合并 + 已关闭 + 草稿 + 概述 + 议题 + 补丁 & PR + 关于 + 链接 + 维护者 + 话题 + 个人派生 静态网站: %1$s 根站点 源: @@ -1910,8 +1934,11 @@ 收藏源算法 此处加星的订阅源算法在主页源上显示为筛选缺口。打开发现添加更多。 固定您喜欢的算法 - 轻触下面的按钮来浏览算法并将您想要保留在这里的算法加星。 + 点击下面的“%1$s”来浏览算法。 + + 点击源旁边的 %1$s 将它保留在此处。 添加一些源 + 添加更多… 正请求 %1$s 获得一个源… 正向收藏的源算法请求订阅源… 正处理你的源… @@ -2500,6 +2527,9 @@ 使用设备上的 AI 模型来提出文本更正和音调更改。 已跟踪的广播 在发送事件时使用已跟踪的广播。在广播时显示实时进度和每个中继的状态。 + 撰写设置 + 自动创建草稿 + 当您输入或在编辑器中有未发送的文本时自动保存草稿事件并发送到您的私人发件箱中继。 使用它 忽略 更正 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index b7f8fa3688..9e418beb03 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -240,6 +240,7 @@ 聰數量 "“正在查找事件%1$s”" + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 3acaff9aca..b5ef0966f6 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,6 +1,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 77e4050fd6..09d45120ce 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -763,6 +763,7 @@ 付款請求已發送,正在等待來自你的錢包的確認 正在等待 DVM 確認付款或返回結果 添加 NIP-96 服務器 + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a25a9cca6d..0438aefc12 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -659,7 +659,6 @@ Send Say something… No messages yet. Be the first to chat. - React React Edit room Save @@ -689,16 +688,8 @@ Asks %1$s\'s client to mute its mic. Some clients may ignore this command. Force-mute Cancel - Audio room - Following %1$s. - Unfollowed %1$s. - Muted %1$s. - Unmuted %1$s. - Promoted %1$s to speaker. - Promoted %1$s to moderator. - Demoted %1$s to listener. - Force-muted %1$s. - Kicked %1$s from the room. + Action failed + The action could not be prepared. Share room Minimize Minimize to keep listening @@ -1241,8 +1232,8 @@ Filter spam Hides posts from strangers that were exactly the same for 5 or more times - Don\'t add client tag to my events - When enabled, Amethyst will not append a NIP-89 client tag to events you publish. + Add client tag to my events + When enabled, Amethyst appends a NIP-89 client tag to events you publish. Warn on reports Shows a warning message when posts or profiles have reports from your follows Report warning threshold @@ -1484,6 +1475,9 @@ Hashtag-exclusive Post Only followers of the hashtag will see it. Your general followers won\'t see it. + Comment on a website + Comment on an external resource + %1$d min read @@ -2073,6 +2067,18 @@ Git Repository: %1$s Web: Clone: + Open + Merged + Closed + Draft + Overview + Issues + Patches & PRs + About + Links + Maintainers + Topics + Personal fork Static Website: %1$s Root Site Source: @@ -2186,8 +2192,11 @@ Favorite Feed Algorithms Feed algorithms you star appear here and as filter chips on the Home feed. Pin your favorite algorithms - Tap the button below to browse algorithms and star the ones you want to keep here. + Tap \"%1$s\" below to browse algorithms. + + Tap the %1$s next to a feed to keep it here. Add some feeds + Add more… Asking %1$s for a feed… Asking your favorite feed algorithms for feeds… Processing your feed… diff --git a/amethyst/src/main/res/xml/locales_config.xml b/amethyst/src/main/res/xml/locales_config.xml index c33cc296db..217b6f15d2 100644 --- a/amethyst/src/main/res/xml/locales_config.xml +++ b/amethyst/src/main/res/xml/locales_config.xml @@ -42,6 +42,7 @@ + diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystemCoilIntegrationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystemCoilIntegrationTest.kt new file mode 100644 index 0000000000..b1978a47f9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystemCoilIntegrationTest.kt @@ -0,0 +1,197 @@ +/* + * 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.images + +import coil3.disk.DiskCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toOkioPath +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Integration test against the real Coil 3 [DiskCache], pinned to whatever + * Coil version the app actually ships. + * + * Verifies the assumption [DeferredDeleteFileSystem] is built on: that Coil's + * eviction (`trimToSize` → `removeEntry`) routes its unlink through + * `fileSystem.delete()`. The pure unit tests prove the wrapper's behaviour; + * this proves Coil drives it. If a future Coil version stops deleting via the + * injected `FileSystem`, [coilEviction_routesEvictionThroughDeferredDelete] + * fails and the wrapper has silently become a no-op. + */ +class DeferredDeleteFileSystemCoilIntegrationTest { + private lateinit var tmpRoot: File + private lateinit var cacheDir: Path + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + tmpRoot = Files.createTempDirectory("coil-integration-test").toFile() + cacheDir = tmpRoot.resolve("image_cache").toOkioPath() + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + } + + @After + fun tearDown() { + scope.cancel("tearDown") + tmpRoot.deleteRecursively() + } + + /** Count of regular files Coil has physically on disk in the cache dir. */ + private fun entryFilesOnDisk(fs: FileSystem): Int = fs.list(cacheDir).count { fs.metadataOrNull(it)?.isRegularFile == true } + + private fun writeEntry( + diskCache: DiskCache, + key: String, + bytes: Int, + ) { + val editor = diskCache.openEditor(key) ?: error("openEditor returned null for $key") + try { + diskCache.fileSystem.write(editor.data) { write(ByteArray(bytes) { 1 }) } + editor.commit() + } catch (e: Throwable) { + editor.abort() + throw e + } + } + + @Test + fun coilEviction_routesEvictionThroughDeferredDelete() { + // Inert drainer: nothing is enqueued before the cancel, so the background + // drainer never runs and deferred deletes accumulate for inspection. + val deferredFs = DeferredDeleteFileSystem(FileSystem.SYSTEM, scope) + scope.cancel("test inspects pendingDeletes directly") + + val maxSize = 16L * 1024 // 16 KB + val entrySize = 1024 // 1 KB payload each + val entryCount = 40 // 40 KB written → Coil must evict well past maxSize + + val diskCache = + DiskCache + .Builder() + .directory(cacheDir) + .fileSystem(deferredFs) + .maxSizeBytes(maxSize) + .build() + + try { + repeat(entryCount) { i -> writeEntry(diskCache, "key$i", entrySize) } + + // Coil's cleanup is async on its own cleanupScope. Wait for it to + // bring its size accounting at/under the cap. + val deadline = System.currentTimeMillis() + 5_000 + while (diskCache.size > maxSize && System.currentTimeMillis() < deadline) { + Thread.sleep(20) + } + + // Coil actually evicted: its size accounting is at/under the cap. + assertTrue( + "Coil should have evicted down to <= maxSize, size=${diskCache.size}", + diskCache.size <= maxSize, + ) + + // The headline assertion: eviction's unlink syscalls were intercepted + // by DeferredDeleteFileSystem.delete() and are sitting in its queue. + // If Coil ever stops deleting via the injected FileSystem this is 0. + assertTrue( + "Coil eviction must route unlink() through DeferredDeleteFileSystem.delete()", + deferredFs.pendingCount() > 0, + ) + + // Because the drainer is inert, those files are still physically on + // disk even though Coil's size counter already dropped — i.e. the + // unlink was genuinely deferred, not executed inline. + val filesBeforeDrain = entryFilesOnDisk(FileSystem.SYSTEM) + assertTrue( + "deferred entry files should still be on disk before drain " + + "(onDisk=$filesBeforeDrain, coilSize=${diskCache.size})", + filesBeforeDrain * entrySize.toLong() > diskCache.size, + ) + + // Draining executes the real unlinks. + deferredFs.drainNow() + assertEquals(0, deferredFs.pendingCount()) + + val filesAfterDrain = entryFilesOnDisk(FileSystem.SYSTEM) + assertTrue( + "drain must physically unlink the deferred files " + + "(before=$filesBeforeDrain, after=$filesAfterDrain)", + filesAfterDrain < filesBeforeDrain, + ) + } finally { + diskCache.shutdown() + } + } + + @Test + fun coilReFetchAfterEviction_survivesDeferredDelete() { + // Live drainer this time: exercises the re-create race against real Coil. + val deferredFs = DeferredDeleteFileSystem(FileSystem.SYSTEM, scope) + + val maxSize = 8L * 1024 + val entrySize = 1024 + + val diskCache = + DiskCache + .Builder() + .directory(cacheDir) + .fileSystem(deferredFs) + .maxSizeBytes(maxSize) + .build() + + try { + // Saturate, forcing eviction of the earliest keys. + repeat(30) { i -> writeEntry(diskCache, "key$i", entrySize) } + + // Re-fetch a key that was almost certainly evicted (an early one). + // Coil opens a new editor and atomicMoves into the same path that + // a deferred delete may still be queued for. + writeEntry(diskCache, "key0", entrySize) + + // Let any background drain settle. + val deadline = System.currentTimeMillis() + 5_000 + while (deferredFs.pendingCount() > 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(20) + } + + // The re-fetched entry must be readable — the deferred delete of its + // path must not have clobbered the freshly committed file. + val snapshot = diskCache.openSnapshot("key0") + assertTrue("re-fetched entry must survive the deferred-delete race", snapshot != null) + snapshot?.use { + val size = diskCache.fileSystem.metadataOrNull(it.data)?.size ?: -1L + assertEquals(entrySize.toLong(), size) + } + } finally { + diskCache.shutdown() + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystemTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystemTest.kt new file mode 100644 index 0000000000..0354fde3a4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/images/DeferredDeleteFileSystemTest.kt @@ -0,0 +1,292 @@ +/* + * 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.images + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toOkioPath +import okio.buffer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class DeferredDeleteFileSystemTest { + private lateinit var tmpRoot: File + private lateinit var tmpDir: Path + + @Before + fun setUp() { + tmpRoot = Files.createTempDirectory("deferred-delete-test").toFile() + tmpDir = tmpRoot.toOkioPath() + } + + @After + fun tearDown() { + tmpRoot.deleteRecursively() + } + + private fun write( + name: String, + content: String, + ): Path { + val path = tmpDir.resolve(name) + FileSystem.SYSTEM.write(path) { writeUtf8(content) } + return path + } + + private fun read(path: Path): String = FileSystem.SYSTEM.read(path) { readUtf8() } + + /** + * Builds a wrapper whose background drainer is immediately cancelled, so + * the test drives eviction deterministically through [DeferredDeleteFileSystem.drainNow]. + * Nothing is enqueued before the cancel, so the drainer never processes a delete. + */ + private fun newInertWrapper(): DeferredDeleteFileSystem { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val fs = DeferredDeleteFileSystem(FileSystem.SYSTEM, scope) + scope.cancel("test drives drainNow() explicitly") + return fs + } + + @Test + fun delete_doesNotUnlinkImmediately() { + val fs = newInertWrapper() + val path = write("a.txt", "hello") + + fs.delete(path) + + assertTrue("file must still exist right after delete()", FileSystem.SYSTEM.exists(path)) + assertEquals(1, fs.pendingCount()) + } + + @Test + fun drainNow_unlinksPendingFiles() { + val fs = newInertWrapper() + val a = write("a.txt", "1") + val b = write("b.txt", "2") + + fs.delete(a) + fs.delete(b) + assertEquals(2, fs.pendingCount()) + + fs.drainNow() + + assertFalse(FileSystem.SYSTEM.exists(a)) + assertFalse(FileSystem.SYSTEM.exists(b)) + assertEquals(0, fs.pendingCount()) + } + + @Test + fun delete_dedupesSamePath() { + val fs = newInertWrapper() + val path = write("a.txt", "x") + + fs.delete(path) + fs.delete(path) + fs.delete(path) + + assertEquals(1, fs.pendingCount()) + } + + @Test + fun atomicMove_cancelsPendingDeleteOfTarget() { + val fs = newInertWrapper() + val target = write("entry.0", "old-evicted-content") + val source = write("entry.0.tmp", "fresh-refetched-content") + + // Coil evicts the entry... + fs.delete(target) + assertEquals(1, fs.pendingCount()) + + // ...then re-fetches the same URL and commits a new file to the same path. + fs.atomicMove(source, target) + + // The pending delete of the target must have been cancelled. + assertEquals(0, fs.pendingCount()) + fs.drainNow() + assertTrue("freshly committed file must survive", FileSystem.SYSTEM.exists(target)) + assertEquals("fresh-refetched-content", read(target)) + } + + @Test + fun atomicMove_cancelsPendingDeleteOfSource() { + val fs = newInertWrapper() + val source = write("entry.0.tmp", "data") + val target = tmpDir.resolve("entry.0") + + fs.delete(source) + assertEquals(1, fs.pendingCount()) + + fs.atomicMove(source, target) + + assertEquals(0, fs.pendingCount()) + fs.drainNow() + assertTrue(FileSystem.SYSTEM.exists(target)) + assertFalse(FileSystem.SYSTEM.exists(source)) + } + + @Test + fun sink_cancelsPendingDeleteOfPath() { + val fs = newInertWrapper() + val path = write("entry.0", "old") + + fs.delete(path) + assertEquals(1, fs.pendingCount()) + + // Re-create the path by opening a sink and writing fresh content. + fs.sink(path, mustCreate = false).use { sink -> + sink.buffer().use { it.writeUtf8("new") } + } + + assertEquals(0, fs.pendingCount()) + fs.drainNow() + assertTrue(FileSystem.SYSTEM.exists(path)) + assertEquals("new", read(path)) + } + + @Test + fun appendingSink_cancelsPendingDeleteOfPath() { + val fs = newInertWrapper() + val path = write("entry.0", "old") + + fs.delete(path) + assertEquals(1, fs.pendingCount()) + + fs.appendingSink(path, mustExist = false).use { sink -> + sink.buffer().use { it.writeUtf8("-more") } + } + + assertEquals(0, fs.pendingCount()) + fs.drainNow() + assertTrue(FileSystem.SYSTEM.exists(path)) + assertEquals("old-more", read(path)) + } + + @Test + fun openReadWrite_cancelsPendingDeleteOfPath() { + val fs = newInertWrapper() + val path = write("entry.0", "old") + + fs.delete(path) + assertEquals(1, fs.pendingCount()) + + fs.openReadWrite(path, mustCreate = false, mustExist = false).use { handle -> + handle.write(0, "X".toByteArray(), 0, 1) + } + + assertEquals(0, fs.pendingCount()) + fs.drainNow() + assertTrue(FileSystem.SYSTEM.exists(path)) + } + + @Test + fun createDirectory_cancelsPendingDeleteOfDir() { + val fs = newInertWrapper() + val dir = tmpDir.resolve("subdir") + FileSystem.SYSTEM.createDirectory(dir) + + fs.delete(dir) + assertEquals(1, fs.pendingCount()) + + fs.createDirectory(dir, mustCreate = false) + + assertEquals(0, fs.pendingCount()) + fs.drainNow() + assertTrue(FileSystem.SYSTEM.exists(dir)) + } + + @Test + fun nonDeleteOperations_passThroughToDelegate() { + val fs = newInertWrapper() + val path = write("a.txt", "payload") + + // Reads, metadata, and listing must work unchanged through the wrapper. + assertEquals("payload", fs.read(path) { readUtf8() }) + assertTrue(fs.exists(path)) + assertTrue(fs.metadataOrNull(path)?.isRegularFile == true) + assertTrue(fs.list(tmpDir).contains(path)) + } + + @Test + fun backgroundDrainer_eventuallyUnlinks() { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + try { + val fs = DeferredDeleteFileSystem(FileSystem.SYSTEM, scope) + val path = write("bg.txt", "data") + + fs.delete(path) + + val deadline = System.currentTimeMillis() + 5_000 + while (FileSystem.SYSTEM.exists(path) && System.currentTimeMillis() < deadline) { + Thread.sleep(10) + } + + assertFalse("background drainer should have unlinked the file", FileSystem.SYSTEM.exists(path)) + assertEquals(0, fs.pendingCount()) + } finally { + scope.cancel("tearDown") + } + } + + @Test + fun backgroundDrainer_handlesBurstThenReCreate() { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + try { + val fs = DeferredDeleteFileSystem(FileSystem.SYSTEM, scope) + // Evict a burst of entries. + val evicted = (1..50).map { write("e$it.0", "v$it") } + evicted.forEach { fs.delete(it) } + + // Immediately re-create one of them (re-fetch race). + val reFetched = evicted[25] + fs.sink(reFetched, mustCreate = false).use { sink -> + sink.buffer().use { it.writeUtf8("refetched") } + } + + val deadline = System.currentTimeMillis() + 5_000 + while (fs.pendingCount() > 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(10) + } + + assertEquals(0, fs.pendingCount()) + // Every evicted file is gone... + evicted.forEachIndexed { i, p -> + if (i == 25) { + assertTrue("re-fetched file must survive the burst drain", FileSystem.SYSTEM.exists(p)) + assertEquals("refetched", read(p)) + } else { + assertFalse("evicted file e${i + 1}.0 must be unlinked", FileSystem.SYSTEM.exists(p)) + } + } + } finally { + scope.cancel("tearDown") + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt index 2edbe3c55c..199f089e43 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -163,6 +163,31 @@ class LocalBlossomCacheRedirectInterceptorTest { response.close() } + @Test + fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { + // nostr.build /i/ layout: _.. The hex inside the + // filename isn't a Blossom blob — rewriting to the local cache and + // sending xs=https://nostr.build/i would 404 because the real blob + // is at /i/nostr.build_.jpg, not /i/. + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val url = "https://nostr.build/i/nostr.build_$sha.jpg" + val response = interceptor.intercept(fakeChain(url, captured)) + assertEquals(url, captured.single()) + response.close() + } + + @Test + fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() { + // A filename like _thumb.jpg isn't a BUD-01 blob URL either. + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val url = "https://example.com/${sha}_thumb.jpg" + val response = interceptor.intercept(fakeChain(url, captured)) + assertEquals(url, captured.single()) + response.close() + } + private fun fakeChain( url: String, captured: MutableList, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStoreTest.kt new file mode 100644 index 0000000000..730bdbb2f1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStoreTest.kt @@ -0,0 +1,158 @@ +/* + * 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 okhttp3.Dns +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream +import java.net.InetAddress +import java.net.UnknownHostException + +class SurgeDnsStoreTest { + @get:Rule val tempFolder = TemporaryFolder() + + private fun ip(value: String): ByteArray = InetAddress.getByName(value).address + + /** Drives [SurgeDnsStore] without going through a real upstream resolver. */ + private class StubDns : Dns { + override fun lookup(hostname: String): List = throw UnknownHostException(hostname) + } + + private fun newStore(file: File): Pair { + val dns = SurgeDns(delegate = StubDns(), positiveTtlMs = 60_000, positiveTtlJitterMs = 0) + return SurgeDnsStore(file, dns) to dns + } + + @Test + fun `round trips empty list`() { + val file = File(tempFolder.root, "cache.bin") + val (store, dns) = newStore(file) + + // Force a save with no entries by marking dirty then snapshotting empty. + dns.markDirty() + store.save() + assertTrue("empty save should still produce a file", file.exists()) + + val (store2, dns2) = newStore(file) + store2.load() + assertTrue("loading empty blob should not populate cache", dns2.snapshot().isEmpty()) + } + + @Test + fun `round trips mixed ipv4 and ipv6 with multiple ips per host`() { + val file = File(tempFolder.root, "cache.bin") + val (store, dns) = newStore(file) + + val expires = System.currentTimeMillis() + 60_000 + dns.restore( + listOf( + DnsCacheRecord("relay.example", listOf(ip("1.2.3.4"), ip("5.6.7.8")), expires), + DnsCacheRecord("v6.example", listOf(ip("2001:db8::1")), expires + 1), + DnsCacheRecord( + "dual.example", + listOf(ip("9.9.9.9"), ip("2001:db8::dead:beef"), ip("10.0.0.1")), + expires + 2, + ), + ), + ) + dns.markDirty() + store.save() + + val (store2, dns2) = newStore(file) + store2.load() + + val restored = dns2.snapshot().sortedBy { it.hostname } + assertEquals(3, restored.size) + + val dual = restored.single { it.hostname == "dual.example" } + assertEquals(3, dual.addresses.size) + assertArrayEquals(ip("9.9.9.9"), dual.addresses[0]) + assertArrayEquals(ip("2001:db8::dead:beef"), dual.addresses[1]) + assertArrayEquals(ip("10.0.0.1"), dual.addresses[2]) + assertEquals(expires + 2, dual.expiresAtMillis) + + val v6 = restored.single { it.hostname == "v6.example" } + assertArrayEquals(ip("2001:db8::1"), v6.addresses.single()) + + val relay = restored.single { it.hostname == "relay.example" } + assertArrayEquals(ip("1.2.3.4"), relay.addresses[0]) + assertArrayEquals(ip("5.6.7.8"), relay.addresses[1]) + } + + @Test + fun `load deletes file with bad magic`() { + val file = File(tempFolder.root, "cache.bin") + DataOutputStream(FileOutputStream(file)).use { out -> + out.writeInt(0xDEADBEEF.toInt()) + out.writeShort(1) + out.writeInt(0) + } + val (store, dns) = newStore(file) + store.load() + assertFalse("corrupt blob should be deleted", file.exists()) + assertTrue(dns.snapshot().isEmpty()) + } + + @Test + fun `load deletes truncated file`() { + val file = File(tempFolder.root, "cache.bin") + // Write a header claiming one record, then EOF before the record. + DataOutputStream(FileOutputStream(file)).use { out -> + out.writeInt(0x534E5343) // 'SNSC' + out.writeShort(1) + out.writeInt(1) + // No record body — readFully will throw EOFException. + } + val (store, _) = newStore(file) + store.load() + assertFalse(file.exists()) + } + + @Test + fun `load rejects oversized record count`() { + val file = File(tempFolder.root, "cache.bin") + DataOutputStream(FileOutputStream(file)).use { out -> + out.writeInt(0x534E5343) + out.writeShort(1) + // Way over the 100_000 cap. + out.writeInt(50_000_000) + } + val (store, _) = newStore(file) + store.load() + assertFalse("oversized record count should be treated as corruption", file.exists()) + } + + @Test + fun `load tolerates missing file`() { + val file = File(tempFolder.root, "absent.bin") + val (store, dns) = newStore(file) + store.load() // must not throw + assertTrue(dns.snapshot().isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsTest.kt index 92384ab578..8e4fd72f56 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.okhttp import okhttp3.Dns +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -343,7 +344,8 @@ class SurgeDnsTest { val snapshot = dns.snapshot() assertEquals(1, snapshot.size) assertEquals("live.example", snapshot[0].hostname) - assertEquals(listOf("1.2.3.4"), snapshot[0].addresses) + assertEquals(1, snapshot[0].addresses.size) + assertArrayEquals(ip("1.2.3.4").address, snapshot[0].addresses[0]) } @Test @@ -352,7 +354,7 @@ class SurgeDnsTest { val dns = SurgeDns(delegate = upstream) val expiresAt = System.currentTimeMillis() + 60_000 - dns.restore(listOf(DnsCacheRecord("relay.example", listOf("9.9.9.9"), expiresAt))) + dns.restore(listOf(DnsCacheRecord("relay.example", listOf(ip("9.9.9.9").address), expiresAt))) assertEquals(listOf(ip("9.9.9.9")), dns.lookup("relay.example")) assertEquals("Restored entry should serve without upstream", 0, upstream.calls("relay.example")) @@ -364,7 +366,7 @@ class SurgeDnsTest { val dns = SurgeDns(delegate = upstream) val expiredAt = System.currentTimeMillis() - 1_000 - dns.restore(listOf(DnsCacheRecord("relay.example", listOf("9.9.9.9"), expiredAt))) + dns.restore(listOf(DnsCacheRecord("relay.example", listOf(ip("9.9.9.9").address), expiredAt))) dns.lookup("relay.example") assertEquals(1, upstream.calls("relay.example")) @@ -387,7 +389,7 @@ class SurgeDnsTest { listOf( DnsCacheRecord( "relay.example", - listOf("9.9.9.9"), + listOf(ip("9.9.9.9").address), System.currentTimeMillis() + 60_000, ), ), @@ -427,7 +429,7 @@ class SurgeDnsTest { dns.restore( listOf( - DnsCacheRecord("Example.COM", listOf("9.9.9.9"), System.currentTimeMillis() + 60_000), + DnsCacheRecord("Example.COM", listOf(ip("9.9.9.9").address), System.currentTimeMillis() + 60_000), ), ) @@ -497,6 +499,128 @@ class SurgeDnsTest { assertThrows(UnknownHostException::class.java) { dns.lookup("a.example") } } + @Test + fun `loopback address from upstream is rejected for non-loopback host`() { + val upstream = CountingDns(mapOf("relay.example" to listOf(ip("127.0.0.1")))) + val dns = SurgeDns(delegate = upstream) + + assertThrows(UnknownHostException::class.java) { dns.lookup("relay.example") } + assertFalse("Rejected answer must not be cached as positive", dns.isDirty()) + } + + @Test + fun `any-local address from upstream is rejected for non-loopback host`() { + val upstream = CountingDns(mapOf("relay.example" to listOf(ip("0.0.0.0")))) + val dns = SurgeDns(delegate = upstream) + + assertThrows(UnknownHostException::class.java) { dns.lookup("relay.example") } + } + + @Test + fun `loopback addresses are stripped but routable addresses are kept`() { + val upstream = + CountingDns(mapOf("relay.example" to listOf(ip("127.0.0.1"), ip("5.6.7.8")))) + val dns = SurgeDns(delegate = upstream) + + val result = dns.lookup("relay.example") + assertEquals(listOf(ip("5.6.7.8")), result) + } + + @Test + fun `localhost hostname keeps loopback answers`() { + val upstream = CountingDns(mapOf("localhost" to listOf(ip("127.0.0.1")))) + val dns = SurgeDns(delegate = upstream) + + assertEquals(listOf(ip("127.0.0.1")), dns.lookup("localhost")) + } + + @Test + fun `dot-localhost subdomain keeps loopback answers`() { + val upstream = CountingDns(mapOf("relay.localhost" to listOf(ip("127.0.0.1")))) + val dns = SurgeDns(delegate = upstream) + + assertEquals(listOf(ip("127.0.0.1")), dns.lookup("relay.localhost")) + } + + @Test + fun `trailing-dot FQDN form of localhost keeps loopback answers`() { + // RFC 1034: `localhost.` is the same name as `localhost`, just in FQDN form. + val upstream = CountingDns(mapOf("localhost." to listOf(ip("127.0.0.1")))) + val dns = SurgeDns(delegate = upstream) + + assertEquals(listOf(ip("127.0.0.1")), dns.lookup("localhost.")) + } + + @Test + fun `ipv4 loopback literal keeps loopback answers`() { + val upstream = CountingDns(mapOf("127.0.0.1" to listOf(ip("127.0.0.1")))) + val dns = SurgeDns(delegate = upstream) + + assertEquals(listOf(ip("127.0.0.1")), dns.lookup("127.0.0.1")) + } + + @Test + fun `ipv6 loopback literal keeps loopback answers`() { + val upstream = CountingDns(mapOf("::1" to listOf(ip("::1")))) + val dns = SurgeDns(delegate = upstream) + + assertEquals(listOf(ip("::1")), dns.lookup("::1")) + } + + @Test + fun `restore filters loopback poison and marks cache dirty`() { + val upstream = CountingDns(mapOf("relay.example" to listOf(ip("5.6.7.8")))) + val dns = SurgeDns(delegate = upstream) + + val expiresAt = System.currentTimeMillis() + 60_000 + dns.restore(listOf(DnsCacheRecord("relay.example", listOf(ip("127.0.0.1").address), expiresAt))) + + assertTrue("Dropping poison must dirty the cache so the snapshot is rewritten", dns.isDirty()) + // Cache should not hold the poisoned entry, so the next lookup hits upstream. + assertEquals(listOf(ip("5.6.7.8")), dns.lookup("relay.example")) + assertEquals(1, upstream.calls("relay.example")) + } + + @Test + fun `restore keeps non-loopback addresses when poison is mixed in`() { + val upstream = CountingDns(mapOf("relay.example" to listOf(ip("9.9.9.9")))) + val dns = SurgeDns(delegate = upstream) + + val expiresAt = System.currentTimeMillis() + 60_000 + dns.restore( + listOf( + DnsCacheRecord("relay.example", listOf(ip("127.0.0.1").address, ip("5.6.7.8").address), expiresAt), + ), + ) + + assertTrue("Dropping one poisoned address still dirties the cache", dns.isDirty()) + assertEquals(listOf(ip("5.6.7.8")), dns.lookup("relay.example")) + assertEquals("Restored survivors should serve without upstream", 0, upstream.calls("relay.example")) + } + + @Test + fun `restore keeps loopback for localhost host`() { + val upstream = CountingDns(emptyMap()) + val dns = SurgeDns(delegate = upstream) + + val expiresAt = System.currentTimeMillis() + 60_000 + dns.restore(listOf(DnsCacheRecord("localhost", listOf(ip("127.0.0.1").address), expiresAt))) + + assertFalse("Loopback entry for localhost is legitimate, not poison", dns.isDirty()) + assertEquals(listOf(ip("127.0.0.1")), dns.lookup("localhost")) + } + + @Test + fun `restore of clean snapshot does not dirty the cache`() { + val upstream = CountingDns(emptyMap()) + val dns = SurgeDns(delegate = upstream) + + val expiresAt = System.currentTimeMillis() + 60_000 + dns.restore(listOf(DnsCacheRecord("relay.example", listOf(ip("5.6.7.8").address), expiresAt))) + + assertFalse("Clean restore must not dirty the cache", dns.isDirty()) + } + @Test fun `refresh executor rejection cleans up the inflight slot`() { val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4")))) diff --git a/ammolite/.gitignore b/ammolite/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/ammolite/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/ammolite/build.gradle b/ammolite/build.gradle deleted file mode 100644 index fffb5cf023..0000000000 --- a/ammolite/build.gradle +++ /dev/null @@ -1,60 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.androidLibrary) -} - -android { - namespace = 'com.vitorpamplona.ammolite' - compileSdk = libs.versions.android.compileSdk.get().toInteger() - - defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInteger() - targetSdk = libs.versions.android.targetSdk.get().toInteger() - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildFeatures { - buildConfig = true - } - - buildTypes { - release { - minifyEnabled = true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - create("benchmark") { - initWith(getByName("release")) - signingConfig = signingConfigs.debug - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 - } - packagingOptions { - resources { - excludes += ['**/libscrypt.dylib'] - } - } -} - -kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_21 - } -} - -dependencies { - // Lru Cache used in Relay stats - implementation libs.androidx.core.ktx - - implementation project(path: ':quartz') - - testImplementation libs.junit - - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.espresso.core -} \ No newline at end of file diff --git a/ammolite/consumer-rules.pro b/ammolite/consumer-rules.pro deleted file mode 100644 index b89c71a40e..0000000000 --- a/ammolite/consumer-rules.pro +++ /dev/null @@ -1,40 +0,0 @@ -# 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 -# preserve the line number information for debugging stack traces. --keepattributes LocalVariableTable --keepattributes LocalVariableTypeTable --keepattributes Signature --keepattributes Exceptions --keepattributes InnerClasses --keepattributes EnclosingMethod --keepattributes MethodParameters --keepparameternames - --keepdirectories libs - -# Keep all names --keepnames class ** { *; } - -# Keep All enums --keep enum ** { *; } - --keep class com.vitorpamplona.ammolite.** { *; } \ No newline at end of file diff --git a/ammolite/proguard-rules.pro b/ammolite/proguard-rules.pro deleted file mode 100644 index fd7f2b7dbf..0000000000 --- a/ammolite/proguard-rules.pro +++ /dev/null @@ -1,44 +0,0 @@ -# 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 -# preserve the line number information for debugging stack traces. --dontobfuscate --keepattributes LocalVariableTable --keepattributes LocalVariableTypeTable --keepattributes *Annotation* --keepattributes SourceFile --keepattributes LineNumberTable --keepattributes Signature --keepattributes Exceptions --keepattributes InnerClasses --keepattributes EnclosingMethod --keepattributes MethodParameters --keepparameternames - --keepdirectories libs - -# Keep all names --keepnames class ** { *; } - -# Keep All enums --keep enum ** { *; } - --keep class com.vitorpamplona.ammolite.** { *; } \ No newline at end of file diff --git a/ammolite/src/main/AndroidManifest.xml b/ammolite/src/main/AndroidManifest.xml deleted file mode 100644 index 44008a4332..0000000000 --- a/ammolite/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/benchmark/build.gradle b/benchmark/build.gradle index 2db4e6e8d3..91df941ea6 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -60,7 +60,6 @@ dependencies { androidTestImplementation libs.androidx.benchmark.junit4 androidTestImplementation project(path: ':quartz') androidTestImplementation project(path: ':commons') - androidTestImplementation project(path: ':ammolite') // Custom C secp256k1 (libschnorr256k1) for the 3-way Android benchmark androidTestImplementation libs.schnorr256k1.kmp diff --git a/build.gradle b/build.gradle index 437eb14d50..1f0c6a9279 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ allprojects { } spotlessPredeclare { kotlin { - ktlint() + ktlint('1.7.1') } } } else { @@ -38,7 +38,7 @@ allprojects { kotlin { target 'src/**/*.kt' - ktlint() + ktlint('1.7.1') licenseHeaderFile rootProject.file('.spotless/copyright.kt'), "@file:|package|import|class|object|sealed|open|interface|abstract " } diff --git a/commons/proguard-rules.pro b/commons/proguard-rules.pro index 283e8b07f3..0bf7b32616 100644 --- a/commons/proguard-rules.pro +++ b/commons/proguard-rules.pro @@ -21,5 +21,4 @@ #-renamesourcefileattribute SourceFile -keep class com.vitorpamplona.quartz.** { *; } --keep class com.vitorpamplona.amethyst.** { *; } --keep class com.vitorpamplona.ammolite.** { *; } \ No newline at end of file +-keep class com.vitorpamplona.amethyst.** { *; } \ No newline at end of file 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 5a3a16c873..d4bf2a061c 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 @@ -821,15 +821,20 @@ open class Note( return author?.reportsOrNull()?.hasReportNewerThan(dayAgo) ?: false } - fun isNewThread(): Boolean = - ( + fun isNewThread(): Boolean { + val event = event + return ( event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0 ) && + // A comment scoped to an external identifier (`I` tag) is a reply to that + // scope, even though there is no in-cache parent note to populate replyTo. + !(event is CommentEvent && event.hasRootScopeIdentifier()) && event !is ChannelMessageEvent && event !is LiveActivitiesChatMessageEvent + } fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt index 245e7f7a5c..8d3fb20199 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt @@ -45,7 +45,7 @@ sealed interface Nip05State { fun markAsVerifying() = verificationState.tryEmit(Nip05VerifState.Verifying(TimeUtils.fiveMinutesAhead())) - fun markAsError() = verificationState.tryEmit(Nip05VerifState.Verified(TimeUtils.fiveMinutesAhead())) + fun markAsError() = verificationState.tryEmit(Nip05VerifState.Error(TimeUtils.fiveMinutesAhead())) fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index fd203d4262..c00ed10519 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri private val sha256HexRegex = Regex("[0-9a-f]{64}") -private val sha256InPathRegex = Regex("(?` or `.`. URLs whose filename merely embeds a + // 64-char hex (e.g. "nostr.build_.jpg") aren't Blossom blobs and + // the bridge must leave them alone — rewriting them would point the + // local cache at a fallback `xs=` server that doesn't host the blob. val pathPart = url.substringBefore('?').substringBefore('#') val lastSegment = pathPart.substringAfterLast('/') - val match = sha256InPathRegex.find(lastSegment) ?: return null - return match.value.lowercase() + val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null + return match.groupValues[1].lowercase() } private fun guessExtension( 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 e3cb263137..abddd23250 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 @@ -420,7 +420,7 @@ class RichTextParser { ) val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") - val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a") + val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a") val pdfExt = listOf("pdf") val imageExtensions = imageExt + imageExt.map { it.uppercase() } @@ -556,6 +556,7 @@ val mimeTypeMap: Map = "wav" to "audio/wav", "ogg" to "audio/ogg", "m4a" to "audio/mp4", + "f4a" to "audio/mp4", "aac" to "audio/aac", "flac" to "audio/flac", // Documents diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 33a5f1dbe2..0cd319ccd1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -174,6 +174,18 @@ class NestViewModel( private val _recentReactions = MutableStateFlow>>(emptyMap()) val recentReactions: StateFlow>> = _recentReactions.asStateFlow() + /** + * Recent kind-9735 zap receipts for the floating zap-avatar overlay. + * Same map shape as [recentReactions] (keyed by target pubkey, + * room-wide zaps under the empty-string key) so the zap overlay + * can mirror the reaction overlay's grouping logic. Sliding + * [REACTION_WINDOW_SEC] window driven by the platform layer's + * eviction tick. + */ + private val zapsAgg = RoomZapsAggregator() + private val _recentZaps = MutableStateFlow>>(emptyMap()) + val recentZaps: StateFlow>> = _recentZaps.asStateFlow() + /** * moq-lite `catalog.json` payload per speaker pubkey. Populated * lazily as the per-speaker subscriptions land — listeners @@ -610,6 +622,30 @@ class NestViewModel( _recentReactions.value = reactionsAgg.evictAndSnapshot(olderThanSec) } + /** + * Apply one kind-9735 zap receipt to the sliding-window aggregator. + * Mirror of [onReactionEvent] — the floating zap chip uses the + * same window so the visual cadence matches the React button. + */ + fun onZapEvent( + event: com.vitorpamplona.quartz.nip57Zaps.LnZapEvent, + nowSec: Long, + windowSec: Long = REACTION_WINDOW_SEC, + ) { + if (closed) return + _recentZaps.value = zapsAgg.apply(event, nowSec, windowSec) + } + + /** + * Drop zaps older than the staleness threshold. Platform layer + * drives this on a 1-s tick — the same cadence as [evictReactions] + * — so the zap and reaction overlays age out in lockstep. + */ + fun evictZaps(olderThanSec: Long) { + if (closed) return + _recentZaps.value = zapsAgg.evictAndSnapshot(olderThanSec) + } + /** * Whether this VM was constructed with capture + encoder factories. The * UI uses this to decide whether to render the talk button at all — diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt index 877fb2f238..19e7868d32 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt @@ -69,6 +69,16 @@ data class ParticipantGrid( * AND whose latest presence advertises `onstage != false`. A * speaker who explicitly emitted `onstage=0` ("step off the * stage") drops to audience without losing their role tag. + * + * Caveat: a presence event is only honoured for the on-stage + * gate when it's NEWER than the role grant ([roleGrantSec], + * conservatively the kind-30312 `created_at`). Otherwise a + * freshly-promoted audience member whose pre-promotion + * `kind-10312` carried `onstage=0` would be pinned to the + * audience tab until they re-heartbeat — which on nostrnests + * never happens, since audience members there don't toggle + * their `onstage` flag back on after a role bump. Stale + * presence ⇒ trust the role. * * Audience: every pubkey with recent presence that isn't on * stage, plus participant-tagged users who haven't emitted * presence yet (rendered as `absent = true`). @@ -86,6 +96,7 @@ fun buildParticipantGrid( participants: List, presences: Map, hostPubkey: HexKey? = null, + roleGrantSec: Long = 0L, ): ParticipantGrid { val effectiveParticipants = if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) { @@ -103,7 +114,10 @@ fun buildParticipantGrid( val pres = presences[p.pubKey] val role = p.effectiveRole() val canSpeak = p.canSpeak() - val onstageFlag = pres?.onstage ?: true // default true for backwards compat + // Stale-presence override: trust the role when the presence + // event predates the role grant. See KDoc above for why. + val presenceIsFresh = pres != null && pres.updatedAtSec >= roleGrantSec + val onstageFlag = if (presenceIsFresh) pres.onstage else true val member = RoomMember( pubkey = p.pubKey, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt index a538d1c592..4fddcd8b12 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt @@ -78,9 +78,6 @@ class RoomReactionsAggregator { // event id so the same kind-7 from two relays only counts once. private val byEventId = LinkedHashMap() - /** Stable key for room-wide reactions in the returned map. */ - private val roomWideKey = "" - /** Apply one reaction and return the post-evict snapshot. */ fun apply( event: ReactionEvent, @@ -102,13 +99,24 @@ class RoomReactionsAggregator { * cadence (typically every second so the floating-up animation * frame rate is set by the eviction tick rather than by a * per-Composable timer). + * + * Reactions are grouped by [RoomReaction.sourcePubkey] — the + * user who SENT the reaction. The chip floats up from the + * reactor's own avatar (matching nostrnests' UX) rather than + * from the target speaker's avatar (which would surface the + * NIP-25 `p`-tag's "originalAuthor" semantics — useful for + * comment threads but confusing in a live audio room, where the + * audience expects to see who's reacting, not who's being + * reacted to). Room-wide reactions (no `p`-tag at all) still + * land under their sender's key, so they show up on the + * reactor's avatar just like targeted ones. */ fun evictAndSnapshot(olderThanSec: Long): Map> { val it = byEventId.entries.iterator() while (it.hasNext()) { if (it.next().value.createdAtSec < olderThanSec) it.remove() } - return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey } + return byEventId.values.groupBy { it.sourcePubkey } } /** Whether the aggregator currently holds any unevicted reactions. */ 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 new file mode 100644 index 0000000000..89deb9d672 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -0,0 +1,114 @@ +/* + * 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.viewmodels + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +/** + * One in-flight kind-9735 zap to render as a floating overlay on the + * zapper's avatar. Same ephemeral contract as [RoomReaction]: the + * aggregator drops entries older than the staleness window so the + * overlay clears itself without per-component animation bookkeeping. + * + * `targetPubkey == null` means the receipt carried no `p` tag — the + * zap still floats from the zapper's avatar, so the target is only + * informational. + */ +@Immutable +data class RoomZap( + /** Zap receipt event id — used for dedup across re-emits. */ + val eventId: String, + /** Who SENT the zap (the kind-9734 request author, not the LN service). */ + val sourcePubkey: String, + /** Participant the zap names via its `p` tag, or `null` if none. */ + val targetPubkey: String?, + /** Amount in sats, or `null` if the invoice couldn't be parsed. */ + val amountSats: Long?, + val createdAtSec: Long, +) { + companion object { + /** + * Project a kind-9735 [LnZapEvent] into a [RoomZap]. The + * source pubkey is the embedded kind-9734 request author — + * the actual zapper — NOT the receipt's own `pubKey`, which + * is the lnurl service provider's signing key. Falls back to + * the receipt issuer only when the request can't be parsed. + */ + fun from(event: LnZapEvent): RoomZap = + RoomZap( + eventId = event.id, + sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey, + targetPubkey = event.zappedAuthor().firstOrNull(), + amountSats = event.amount?.toLong(), + createdAtSec = event.createdAt, + ) + } +} + +/** + * Sliding-window aggregator for room zaps. Mirrors + * [RoomReactionsAggregator] — same dedup-by-event-id rule and + * group-by-sender output shape so the UI can use one overlay + * placement convention for both streams. + * + * Not thread-safe — call from the VM's single coroutine. + */ +class RoomZapsAggregator { + // Insertion-ordered so groupBy preserves arrival order; keyed by + // event id so the same receipt from two relays only counts once. + private val byEventId = LinkedHashMap() + + /** Apply one zap and return the post-evict snapshot. */ + fun apply( + event: LnZapEvent, + nowSec: Long, + windowSec: Long, + ): Map> { + val incoming = RoomZap.from(event) + // Dedup: a relay re-delivery (or LocalCache.observeNotes's + // full-list re-emit) of the same receipt must not stack. + byEventId[incoming.eventId] = incoming + return evictAndSnapshot(nowSec - windowSec) + } + + /** + * Drop zaps older than [olderThanSec]. Caller drives the cadence + * — typically every second so the floating-up animation frame + * rate is set by the eviction tick rather than by a + * per-Composable timer. + * + * Zaps are grouped by [RoomZap.sourcePubkey] — the user who SENT + * the zap — so the chip floats up from the zapper's own avatar, + * matching how [RoomReactionsAggregator] groups reactions. In a + * live audio room the audience expects to see who's zapping, not + * who's being zapped; and the zapper may be an audience member, + * so the overlay is wired into the audience grid as well as the + * stage. + */ + fun evictAndSnapshot(olderThanSec: Long): Map> { + val it = byEventId.entries.iterator() + while (it.hasNext()) { + if (it.next().value.createdAtSec < olderThanSec) it.remove() + } + return byEventId.values.groupBy { it.sourcePubkey } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/F4aAudioParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/F4aAudioParserTest.kt new file mode 100644 index 0000000000..12d6c1c3cd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/F4aAudioParserTest.kt @@ -0,0 +1,53 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class F4aAudioParserTest { + @Test + fun detectsF4aByExtension() { + val url = "https://example.com/audio/track.f4a" + val state = RichTextParser().parseText(url, EmptyTagList, null) + + val media = state.mediaForPager[url] + assertTrue(media is MediaUrlVideo, "Expected MediaUrlVideo for .f4a URL") + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is VideoSegment, "Expected VideoSegment for .f4a URL, got ${segment::class.simpleName}") + assertEquals(url, segment.segmentText) + } + + @Test + fun isVideoUrlHelperMatchesF4aExtension() { + assertTrue(RichTextParser.isVideoUrl("https://example.com/track.f4a")) + assertTrue(RichTextParser.isVideoUrl("https://example.com/track.F4A")) + assertTrue(RichTextParser.isVideoUrl("https://example.com/track.f4a?sig=abc")) + } + + @Test + fun f4aMapsToMp4AudioMimeType() { + assertEquals("audio/mp4", mimeTypeMap["f4a"]) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index ce50e3e0c3..19301c0dc5 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -213,4 +213,27 @@ class MediaUrlContentExtTest { val url = "https://example.com/$sha/avatar.jpg" assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) } + + @Test + fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { + // nostr.build /i/ layout: _.. The hex inside the + // filename isn't a Blossom blob per BUD-01 — the last segment must + // be exactly or .. + val url = "https://nostr.build/i/nostr.build_$sha.jpg" + val image = MediaUrlImage(url = url, hash = null) + assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) + } + + @Test + fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { + val url = "https://nostr.build/i/nostr.build_$sha.jpg" + assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) + } + + @Test + fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() { + val url = "https://example.com/${sha}_thumb.jpg" + val image = MediaUrlImage(url = url, hash = null) + assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index ceff41e3ff..10293574a4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -313,7 +313,7 @@ class NestViewModelTest { } @Test - fun onReactionEventGroupsByTargetAndEvictsOnTick() = + fun onReactionEventGroupsBySenderAndEvictsOnTick() = runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) @@ -339,10 +339,13 @@ class NestViewModelTest { sig = "0".repeat(128), ) - // Two reactions land within the 30-s window. + // Two reactions sent BY alice land within the 30-s window. + // The aggregator groups by sender (so the floating chip + // rises from the reactor's avatar), NOT by the `p`-tag + // target — both land under alice's key. vm.onReactionEvent(rxn(alice, bob, "🔥", 100L), nowSec = 100L) vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L) - assertEquals(2, vm.recentReactions.value[bob]!!.size) + assertEquals(2, vm.recentReactions.value[alice]!!.size) // LocalCache.observeEvents re-emits the full matching list // on every cache mutation; the same kind-7 must collapse @@ -350,7 +353,7 @@ class NestViewModelTest { val replayed = rxn(alice, bob, "🔥", 100L, id = "f".repeat(64)) vm.onReactionEvent(replayed, nowSec = 105L) vm.onReactionEvent(replayed, nowSec = 105L) - assertEquals(3, vm.recentReactions.value[bob]!!.size) + assertEquals(3, vm.recentReactions.value[alice]!!.size) // Tick advances past the window — all reactions evicted. vm.evictReactions(olderThanSec = 200L) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt index 2a5ff4bcb7..ac4871e3cf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt @@ -168,6 +168,50 @@ class ParticipantGridTest { assertEquals(ROLE.HOST, hostRow?.role) } + @Test + fun stalePresenceWithOnstageFalseIsIgnoredAfterRoleGrant() { + // Audience-promoted-to-speaker scenario: presence event was + // emitted at t=100 with onstage=false (their pre-promotion + // audience-mode kind-10312). The host then promotes them at + // t=200 (the room's new createdAt). Without the staleness + // gate, this lands the speaker in the audience tab even + // though their role just changed — the bug reported on + // 2026-05-13. With roleGrantSec=200, the t=100 presence is + // stale and the role tag wins → speaker on stage. + val grid = + buildParticipantGrid( + participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)), + presences = + mapOf( + host to presence(host, 200L), + speaker to presence(speaker, 100L, onstage = false), + ), + roleGrantSec = 200L, + ) + assertEquals(setOf(host, speaker), grid.onStage.map { it.pubkey }.toSet()) + assertEquals(0, grid.audience.size) + } + + @Test + fun freshOnstageFalseAfterRoleGrantStillDropsToAudience() { + // Inverse: speaker emits onstage=0 AFTER the role grant + // (deliberate "stepped off stage" while keeping the role). + // Should still drop to audience — the existing leave-stage + // contract. + val grid = + buildParticipantGrid( + participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)), + presences = + mapOf( + host to presence(host, 200L), + speaker to presence(speaker, 300L, onstage = false), + ), + roleGrantSec = 200L, + ) + assertEquals(setOf(host), grid.onStage.map { it.pubkey }.toSet()) + assertEquals(setOf(speaker), grid.audience.map { it.pubkey }.toSet()) + } + @Test fun explicitHostInPTagsIsNotDuplicated() { // The author already tagged themselves with role=host. The diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt index 38df298574..7a894efa85 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -import kotlin.test.assertTrue class RoomReactionsStateTest { private val alice = "a".repeat(64) @@ -71,14 +70,19 @@ class RoomReactionsStateTest { } @Test - fun aggregatorGroupsBySpeaker() { + fun aggregatorGroupsBySender() { + // Two reactions sent BY alice and charlie, both targeting bob. + // Group by sender so the floating chip rises from the + // reactor's avatar rather than the target speaker's. val agg = RoomReactionsAggregator() agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L) val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L) - // Two reactions on bob. - assertEquals(setOf(bob), snap.keys) - assertEquals(2, snap[bob]!!.size) + assertEquals(setOf(alice, charlie), snap.keys) + assertEquals(1, snap[alice]!!.size) + assertEquals("🔥", snap[alice]!![0].content) + assertEquals(1, snap[charlie]!!.size) + assertEquals("👏", snap[charlie]!![0].content) } @Test @@ -89,20 +93,22 @@ class RoomReactionsStateTest { // Fresh reaction (T=105) — inside the window. val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L) - // bob has only the fresh one left. - assertEquals(1, snap[bob]!!.size) - assertEquals("👏", snap[bob]!![0].content) + // alice's reaction is evicted, charlie's stays. + assertNull(snap[alice]) + assertEquals(1, snap[charlie]!!.size) + assertEquals("👏", snap[charlie]!![0].content) } @Test - fun aggregatorRoomWideReactionsKeyedByEmptyString() { + fun aggregatorRoomWideReactionsKeyedBySender() { val agg = RoomReactionsAggregator() val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L) - // Room-wide reactions land under the empty-string key so the - // map's value-type stays uniform; the UI can split them on render. - assertTrue(snap.containsKey("")) - assertEquals(1, snap[""]!!.size) + // A reaction with no `p`-tag (room-wide) still groups under + // the sender's key, so it floats from the reactor's avatar + // exactly the same way a speaker-targeted reaction does. + assertEquals(setOf(alice), snap.keys) + assertEquals(1, snap[alice]!!.size) } @Test @@ -129,6 +135,7 @@ class RoomReactionsStateTest { agg.apply(replay, nowSec = 100L, windowSec = 30L) val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) - assertEquals(1, snap[bob]!!.size) + // Sender-grouped: only one entry, under the sender (alice). + assertEquals(1, snap[alice]!!.size) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt new file mode 100644 index 0000000000..eea3ffcd04 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt @@ -0,0 +1,135 @@ +/* + * 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.viewmodels + +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RoomZapsStateTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val charlie = "c".repeat(64) + + private var nextEventId = 1 + + /** + * Builds a kind-9735 zap receipt with no embedded `description` + * request, so [RoomZap.from] falls back to the receipt's own + * `pubKey` as the sender — i.e. [from] is the grouping key. A + * `p` tag for [to] is attached when non-null (informational + * `targetPubkey`); no `bolt11` tag, so the amount resolves to + * null. The aggregator's grouping / eviction / dedup logic does + * not depend on the amount, which keeps the fixtures invoice-free. + */ + private fun zap( + from: String, + to: String?, + createdAt: Long, + id: String = "%064x".format(nextEventId++), + ): LnZapEvent { + val tags = + buildList> { + add(arrayOf("a", "30312:host:room")) + if (to != null) add(arrayOf("p", to)) + }.toTypedArray() + return LnZapEvent( + id = id, + pubKey = from, + createdAt = createdAt, + tags = tags, + content = "", + sig = "0".repeat(128), + ) + } + + @Test + fun fromEventUsesSenderAndTarget() { + val zap = RoomZap.from(zap(alice, bob, 100L)) + assertEquals(alice, zap.sourcePubkey) + assertEquals(bob, zap.targetPubkey) + assertNull(zap.amountSats) + assertEquals(100L, zap.createdAtSec) + } + + @Test + fun fromEventNullTargetWhenNoPTag() { + val zap = RoomZap.from(zap(alice, null, 100L)) + assertNull(zap.targetPubkey) + // Source still resolves even without a `p` tag — the zap + // floats from the zapper's avatar regardless of target. + assertEquals(alice, zap.sourcePubkey) + } + + @Test + fun aggregatorGroupsBySender() { + val agg = RoomZapsAggregator() + // Two zaps from alice, aimed at different participants — both + // float from alice's avatar, so both land under alice's key. + agg.apply(zap(alice, bob, 100L), nowSec = 100L, windowSec = 30L) + val snap = agg.apply(zap(alice, charlie, 100L), nowSec = 100L, windowSec = 30L) + + assertEquals(setOf(alice), snap.keys) + assertEquals(2, snap[alice]!!.size) + } + + @Test + fun aggregatorEvictsOlderThanWindow() { + val agg = RoomZapsAggregator() + // Old zap (T=70) — outside the window when now=110, windowSec=30. + agg.apply(zap(alice, bob, 70L), nowSec = 70L, windowSec = 30L) + // Fresh zap (T=105) — inside the window. + val snap = agg.apply(zap(bob, charlie, 105L), nowSec = 110L, windowSec = 30L) + + // alice's stale zap is gone; only bob's fresh one remains. + assertNull(snap[alice]) + assertEquals(1, snap[bob]!!.size) + assertEquals(105L, snap[bob]!![0].createdAtSec) + } + + @Test + fun evictAndSnapshotIsIdempotentWhenNothingChanged() { + val agg = RoomZapsAggregator() + agg.apply(zap(alice, bob, 100L), nowSec = 100L, windowSec = 30L) + val a = agg.evictAndSnapshot(olderThanSec = 90L) + val b = agg.evictAndSnapshot(olderThanSec = 90L) + // Same input → same output, by VALUE (data class equality on + // RoomZap so the StateFlow doesn't re-emit on no-op ticks). + assertEquals(a, b) + } + + @Test + fun aggregatorDedupsRepeatedEventIds() { + val agg = RoomZapsAggregator() + // LocalCache.observeNotes re-emits the full matching list on + // every cache mutation; the same receipt must collapse into one + // overlay entry instead of stacking on each replay. + val sharedId = "f".repeat(64) + val first = zap(alice, bob, 100L, id = sharedId) + val replay = zap(alice, bob, 100L, id = sharedId) + agg.apply(first, nowSec = 100L, windowSec = 30L) + agg.apply(replay, nowSec = 100L, windowSec = 30L) + val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) + + assertEquals(1, snap[alice]!!.size) + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index c9797b90d8..0ec8f6836e 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -129,6 +129,22 @@ compose.desktop { rpmPackageVersion = appVersion.replace("-", "~") } } + + // Compose Multiplatform 1.11.0 wired ProGuard 7.7.0 into the release + // build. The Android (mobile) module already solved the same class of + // problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum` + // rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in + // `compose-rules.pro` so the desktop release survives JNI callbacks + // (secp256k1-kmp, sqlite-bundled, jkeychain, VLCj) and reflection-heavy + // libraries (Jackson, JNA) without renaming. + // + // Shrink and optimize stay ON. One ProGuard optimize sub-pass is + // disabled in `compose-rules.pro` to avoid a generated okio bridge + // whose declared return type the JVM verifier rejects (R8 doesn't hit + // this — it generates bridges differently from ProGuard). + buildTypes.release.proguard { + configurationFiles.from(project.file("compose-rules.pro")) + } } } @@ -170,26 +186,39 @@ tasks.withType().configureEach { // --- AppImage packaging (Linux) --- // // Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101). -// Instead: wrap `createReleaseDistributable` output with `linuxdeploy` (which -// auto-bundles libraries, handles rpath, and calls appimagetool internally). +// Instead: wrap `createReleaseDistributable` output with `appimagetool`, which +// just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here — +// linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps, +// but jpackage already ships a self-contained tree we don't want it touching: +// - The bundled JRE puts libjvm.so under usr/lib/runtime/lib/server/ while +// sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so) have +// RPATH=$ORIGIN, so ldd cannot resolve libjvm.so without help. +// - The bundled VLC plugins are UPX-compressed; linuxdeploy aborts on those +// with "patchelf: no section headers" because they look like static ELFs. +// - Several VLC libs have RUNPATH that does not point at sibling libs in +// the same directory, so ldd errors with "Could not find dependency". +// appimagetool sidesteps all of this — it only embeds the AppDir into a +// SquashFS, runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH +// at launch. // -// Build inputs live in packaging/appimage/: +// Build inputs live in desktopApp/packaging/appimage/: // - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC) // - amethyst.desktop XDG desktop entry // - amethyst.png 512x512 icon // -// linuxdeploy binary is fetched by CI (SHA-verified) into packaging/appimage/ -// as linuxdeploy-x86_64.AppImage. BUILDING.md documents local-dev fetch. +// appimagetool binary is fetched by CI (SHA-verified) into +// desktopApp/packaging/appimage/ as appimagetool-x86_64.AppImage. +// BUILDING.md documents local-dev fetch. val createReleaseAppImage by tasks.registering(Exec::class) { group = "compose desktop" - description = "Bundle createReleaseDistributable output into a Linux AppImage via linuxdeploy." + description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool." dependsOn("createReleaseDistributable") val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst") val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir") val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-x86_64.AppImage") - val toolRoot = layout.projectDirectory.dir("../packaging/appimage") - val linuxdeployTool = toolRoot.file("linuxdeploy-x86_64.AppImage") + val toolRoot = layout.projectDirectory.dir("packaging/appimage") + val appimagetool = toolRoot.file("appimagetool-x86_64.AppImage") inputs.dir(distDir) inputs.dir(toolRoot) @@ -214,19 +243,16 @@ val createReleaseAppImage by tasks.registering(Exec::class) { if (dirIcon.exists()) dirIcon.delete() Files.createSymbolicLink(dirIcon.toPath(), File("amethyst.png").toPath()) - if (!linuxdeployTool.asFile.canExecute()) { - linuxdeployTool.asFile.setExecutable(true) + if (!appimagetool.asFile.canExecute()) { + appimagetool.asFile.setExecutable(true) } } commandLine( - linuxdeployTool.asFile.absolutePath, - "--appdir", appDir.get().asFile.absolutePath, - "--output", "appimage", - "--desktop-file", "${appDir.get().asFile}/amethyst.desktop", - "--icon-file", "${appDir.get().asFile}/amethyst.png", + appimagetool.asFile.absolutePath, + appDir.get().asFile.absolutePath, + outFile.get().asFile.absolutePath, ) - environment("OUTPUT", outFile.get().asFile.absolutePath) environment("ARCH", "x86_64") // Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2). // AppImage standard env var: extracts + runs without mounting. diff --git a/desktopApp/compose-rules.pro b/desktopApp/compose-rules.pro new file mode 100644 index 0000000000..bb824643d1 --- /dev/null +++ b/desktopApp/compose-rules.pro @@ -0,0 +1,187 @@ +# ProGuard rules consumed by Compose Multiplatform's `proguardReleaseJars` +# task (see `compose.desktop.application.buildTypes.release.proguard` wiring +# in build.gradle.kts). +# +# Strategy mirrors the Android (mobile) module's `amethyst/proguard-rules.pro`: +# +# - shrink: ON (size win, removes whole unused classes, no renames) +# - optimize: ON (speed win; one bridge-generating sub-pass disabled below +# because ProGuard's specialized return-type bridges trip +# the JVM verifier on okio — R8 doesn't hit this) +# - obfuscate: OFF (via `-dontobfuscate`; every native library we ship +# resolves Java callbacks by *class name* through JNI +# `FindClass` / `GetMethodID`. Renaming breaks them.) +# +# The Android module solved the exact same JNI-rename problem with: +# +# -dontobfuscate +# -keepnames class ** { *; } +# -keep enum ** { *; } +# +# Plus per-library `-keep` rules for JNA, libsodium, libscrypt, and the +# first-party (`com.vitorpamplona.**`) JSON-mapped types. We replicate that +# verbatim here, then add desktop-only rules for Jackson, VLCj, OkHttp, +# and SLF4J that the mobile build does not need (those code paths are +# Android-stripped on mobile but compiled on desktop). + +# ============================================================================ +# Mirror amethyst/proguard-rules.pro +# ============================================================================ +# Preserve the line number information for debugging stack traces, and keep +# every class name + every enum intact so JNI / reflection / ServiceLoader +# lookups continue to resolve after the optimize pass. +-dontobfuscate +-keepattributes LocalVariableTable +-keepattributes LocalVariableTypeTable +-keepattributes *Annotation* +-keepattributes SourceFile +-keepattributes LineNumberTable +-keepattributes Signature +-keepattributes Exceptions +-keepattributes InnerClasses +-keepattributes EnclosingMethod +-keepattributes MethodParameters +-keepparameternames + +-keepdirectories libs + +# Keep all names — belt-and-suspenders against any rename ProGuard might +# still attempt with obfuscate off (e.g. on synthetic inner classes). +-keepnames class ** { *; } + +# Keep all enums whole — Jackson, Kotlin serialization, and many libraries +# enumerate features via `Class.getEnumConstants()` -> `values()`. Stripping +# `values()` / `valueOf(String)` returns null and crashes initialisation. +-keep enum ** { *; } + +# preserve access to native classes (mirrors mobile) +-keep class fr.acinq.secp256k1.** { *; } + +# JNA For Libsodium (mirrors mobile) +-keep class com.goterl.lazysodium.** { *; } + +# libscrypt (mirrors mobile) +-keep class com.lambdaworks.codec.** { *; } +-keep class com.lambdaworks.crypto.** { *; } +-keep class com.lambdaworks.jni.** { *; } + +-keep class info.guardianproject.** { *; } + +# JSON-mapped first-party types (mirrors mobile) +-keep class com.vitorpamplona.quartz.** { *; } +-keep class com.vitorpamplona.amethyst.** { *; } + +# ============================================================================ +# Desktop-only JNI keep rules — libraries the mobile module doesn't ship +# ============================================================================ +# `-keepnames class ** { *; }` above prevents renames, but does NOT prevent +# the shrink pass from removing members whose only callers are native (JNI +# `FindClass` + `GetStaticMethodID` from a bundled .dylib/.so/.dll). Each of +# these libraries was the source of a v1.09.1 release crash: +# +# - androidx.sqlite (sqlite-bundled): nativeThreadSafeMode() stripped, +# so `BundledSQLiteDriver.threadingMode` threw NoSuchMethodError on the +# first relay-store query (LocalRelayStore.refreshStats). +# - pt.davidafsilva.apple (jkeychain): macOS Keychain JNI for nsec storage. +# +# secp256k1-kmp is already covered by the `-keep class fr.acinq.secp256k1.**` +# rule mirrored from mobile above. +-keep class androidx.sqlite.** { *; } +-keepclassmembers class androidx.sqlite.** { + native ; + static ; +} +-keep class pt.davidafsilva.apple.** { *; } +-keepclassmembers class pt.davidafsilva.apple.** { + native ; +} + +# ============================================================================ +# Optimize sub-pass — disable the one that produces invalid okio bytecode +# ============================================================================ +# ProGuard's `method/specialization/returntype` pass specialised +# `Okio__OkioKt.buffer(Source): BufferedSource` to a synthetic bridge +# `buffer$` whose declared return type was the more concrete +# `RealBufferedSource` while the bytecode body returned the +# `BufferedSource` super-interface. The JVM verifier rejected the bridge: +# +# VerifyError: Bad return type +# Exception Details: +# Location: okio/Okio__OkioKt.buffer$5ae116e(...) +# Reason: Type 'okio/BufferedSource' is not assignable to +# 'okio/RealBufferedSource'. +# +# Disabling just this sub-pass keeps merging, inlining, peephole and +# dead-code optimizations on. Everything else in `optimize` stays. +-optimizations !method/specialization/returntype + +# ============================================================================ +# Desktop-only: Jackson — mobile module does not ship Jackson on Android +# ============================================================================ +# Jackson uses reflection extensively at construction time (ObjectMapper, +# JsonMapper, SerializationConfig, DeserializationConfig). The +# `-keep enum **` rule above already covers `values()`/`valueOf()`; this +# block also keeps the annotation/databind/core/kotlin-module classes +# intact and silences value-class MethodHandle warnings. +-keep class com.fasterxml.jackson.databind.** { *; } +-keep class com.fasterxml.jackson.annotation.** { *; } +-keep class com.fasterxml.jackson.core.** { *; } +-keep class com.fasterxml.jackson.module.kotlin.** { *; } +-dontwarn com.fasterxml.jackson.module.kotlin.** + +# ============================================================================ +# Desktop-only: full JNA — mobile narrows JNA because Android has no AWT +# ============================================================================ +# On desktop we run a real JVM with AWT, so VLCj / kmp-tor / etc. can use +# the full JNA surface. Keep it wholesale rather than the narrowed mobile +# allowlist. +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-keepclassmembers class * extends com.sun.jna.Structure { + ; +} +-dontwarn com.sun.jna.internal.Cleaner +-dontwarn com.sun.jna.internal.Cleaner$* + +# ============================================================================ +# Desktop-only: VLCj — discovery providers loaded via ServiceLoader +# ============================================================================ +# VLCj enumerates implementations of +# `uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider` +# via META-INF/services and resolves them with `Class.forName(...).newInstance()`. +-keep class uk.co.caprica.vlcj.** { *; } +-keepclassmembers class * implements uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider { + public (...); +} +-keepclassmembers class * implements uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryProvider { + public (...); +} + +# ============================================================================ +# Desktop-only: OkHttp / Conscrypt / BouncyCastle / OpenJSSE / Graal +# ============================================================================ +# okhttp ships TLS adapters for GraalVM native-image, Conscrypt, BouncyCastle, +# OpenJSSE, and Jetty boot. None are on the desktop classpath; okhttp falls +# back to the JDK default via `Class.forName(...)`-style probes that catch +# NoClassDefFoundError. Suppress the warnings without keeping the unused +# classes. +-dontwarn okhttp3.internal.graal.** +-dontwarn okhttp3.internal.platform.BouncyCastlePlatform +-dontwarn okhttp3.internal.platform.BouncyCastlePlatform$* +-dontwarn okhttp3.internal.platform.ConscryptPlatform +-dontwarn okhttp3.internal.platform.ConscryptPlatform$* +-dontwarn okhttp3.internal.platform.OpenJSSEPlatform +-dontwarn okhttp3.internal.platform.OpenJSSEPlatform$* +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** +-dontwarn org.graalvm.** +-dontwarn com.oracle.svm.** + +# ============================================================================ +# Desktop-only: SLF4J — used reflectively by transitive deps +# ============================================================================ +# jackson-databind and a few other deps `Class.forName("org.slf4j.LoggerFactory")` +# to detect logging. We ship slf4j-nop; keep it intact so detection succeeds. +-keep class org.slf4j.** { *; } +-dontwarn org.slf4j.** diff --git a/packaging/appimage/AppRun b/desktopApp/packaging/appimage/AppRun similarity index 100% rename from packaging/appimage/AppRun rename to desktopApp/packaging/appimage/AppRun diff --git a/packaging/appimage/amethyst.desktop b/desktopApp/packaging/appimage/amethyst.desktop similarity index 100% rename from packaging/appimage/amethyst.desktop rename to desktopApp/packaging/appimage/amethyst.desktop diff --git a/packaging/appimage/amethyst.png b/desktopApp/packaging/appimage/amethyst.png similarity index 100% rename from packaging/appimage/amethyst.png rename to desktopApp/packaging/appimage/amethyst.png diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 210eb3f58a..5b92c6c1e5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen +import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog @@ -118,6 +119,7 @@ import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings +import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -628,6 +630,7 @@ fun main() { onShowAppDrawer = { showAppDrawer = true }, replyToNote = replyToNote, showImportFollowListDialog = showImportFollowListDialog, + onShowImportFollowListDialog = { showImportFollowListDialog = true }, onDismissImportFollowListDialog = { showImportFollowListDialog = false }, onRestartApp = { appRestartKey++ }, torManager = torManager, @@ -658,6 +661,7 @@ fun App( onShowAppDrawer: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, showImportFollowListDialog: Boolean = false, + onShowImportFollowListDialog: () -> Unit = {}, onDismissImportFollowListDialog: () -> Unit = {}, onRestartApp: () -> Unit = {}, torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, @@ -887,24 +891,20 @@ fun App( // Load account list from encrypted storage accountManager.refreshAccountListOnStartup() - if (accountManager.hasBunkerAccount()) { - // Show connecting UI while dedicated NIP-46 client connects - accountManager.setConnectingRelays() - } val result = accountManager.loadSavedAccount() if (result.isSuccess) { - // Ensure loaded account is in multi-account storage - accountManager.ensureCurrentAccountInStorage() accountManager.refreshAccountList() val current = accountManager.currentAccount() if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { accountManager.startHeartbeat(scope) } - } else if (accountManager.hasBunkerAccount()) { - // Corrupt bunker state — fall back to login screen - accountManager.logout(deleteKey = true) + // Load per-account NWC + if (current != null) { + accountManager.loadNwcConnection(current.npub) + } } + // If failure: state remains LoggedOut → login screen shows automatically } onDispose { @@ -965,18 +965,14 @@ fun App( val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() - // Lazy-load Namecoin services — almost never used, no need to keep in - // memory from the start (matches Android lazy pattern) + // Lazy-load Namecoin services val namecoinPreferences = remember { DesktopNamecoinPreferences() } val namecoinService = remember { DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current }) } - // Load NWC connection on first composition - LaunchedEffect(Unit) { - accountManager.loadNwcConnection() - } + // NWC loaded during startup in loadSavedAccount flow val currentTorStatus = torManager.status.collectAsState().value androidx.compose.runtime.CompositionLocalProvider( @@ -1020,7 +1016,21 @@ fun App( com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS onShowAppDrawer() }, + onShowImportFollowListDialog = onShowImportFollowListDialog, ) + + // Import Follow List dialog (triggered from File menu / + // Cmd+Shift+I). Rendered inside this CompositionLocalProvider + // so LocalNamecoinService is available for .bit / d/ / id/ + // identifier resolution. + if (showImportFollowListDialog) { + ImportFollowListDialog( + onDismiss = onDismissImportFollowListDialog, + relayManager = relayManager, + account = account, + localCache = localCache, + ) + } } // Compose dialog @@ -1123,6 +1133,7 @@ fun MainContent( onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowAppDrawer: () -> Unit, onOpenFeedsDrawer: () -> Unit = onShowAppDrawer, + onShowImportFollowListDialog: () -> Unit = {}, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -1340,6 +1351,7 @@ fun MainContent( onOpenFeedsDrawer = onOpenFeedsDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onShowImportFollowListDialog = onShowImportFollowListDialog, onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, @@ -1376,6 +1388,7 @@ fun MainContent( deckState.addColumn(DeckColumnType.Settings) } }, + onShowImportFollowListDialog = onShowImportFollowListDialog, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, torStatus = torStatus, @@ -1498,10 +1511,7 @@ fun RelaySettingsScreen( var nwcInput by remember { mutableStateOf("") } var nwcError by remember { mutableStateOf(null) } - // Load NWC on first composition - LaunchedEffect(Unit) { - accountManager.loadNwcConnection() - } + val nwcScope = rememberCoroutineScope() com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { val sidePadding = @@ -1567,7 +1577,7 @@ fun RelaySettingsScreen( ) } OutlinedButton( - onClick = { accountManager.clearNwcConnection() }, + onClick = { nwcScope.launch { accountManager.clearNwcConnection(account.npub) } }, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error, @@ -1597,11 +1607,13 @@ fun RelaySettingsScreen( ) Button( onClick = { - val result = accountManager.setNwcConnection(nwcInput) - result.fold( - onSuccess = { nwcInput = "" }, - onFailure = { nwcError = it.message ?: "Invalid connection string" }, - ) + nwcScope.launch { + val result = accountManager.setNwcConnection(account.npub, nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + } }, enabled = nwcInput.isNotBlank(), ) { @@ -1633,6 +1645,31 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) + // Namecoin Settings (ElectrumX servers for .bit / d/ / id/ resolution) + val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current + if (namecoinPrefsHere != null) { + val namecoinScope = rememberCoroutineScope() + val namecoinSettings by namecoinPrefsHere.settings.collectAsState() + NamecoinSettingsSection( + settings = namecoinSettings, + onToggleEnabled = { enabled -> + namecoinScope.launch { namecoinPrefsHere.setEnabled(enabled) } + }, + onAddServer = { server -> + namecoinScope.launch { namecoinPrefsHere.addServer(server) } + }, + onRemoveServer = { server -> + namecoinScope.launch { namecoinPrefsHere.removeServer(server) } + }, + onReset = { + namecoinScope.launch { namecoinPrefsHere.reset() } + }, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + } + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui 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 3b6b113cfe..9b580b467a 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 @@ -67,8 +67,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermission sealed class AccountState { data object LoggedOut : AccountState() @@ -111,8 +109,15 @@ class AccountManager internal constructor( File(homeDir, ".amethyst") } + private val _storageCorruption = MutableStateFlow(null) + val storageCorruption: StateFlow = _storageCorruption.asStateFlow() + + fun clearStorageCorruption() { + _storageCorruption.value = null + } + val accountStorage: DesktopAccountStorage by lazy { - DesktopAccountStorage(secureStorage, homeDir) + DesktopAccountStorage(secureStorage, homeDir, onCorruption = { _storageCorruption.value = it }) } private val _allAccounts = MutableStateFlow>(persistentListOf()) @@ -232,16 +237,28 @@ class AccountManager internal constructor( suspend fun loadSavedAccount(): Result = try { - val lastNpub = getLastNpub() - val bunkerUri = getBunkerUri() + // Clean up legacy files (one-time, silent) + File(amethystDir, "last_account.txt").delete() + File(amethystDir, "bunker_uri.txt").delete() + File(amethystDir, "nwc_connection.txt").delete() - if (bunkerUri != null) { - loadBunkerAccount(bunkerUri, lastNpub) - } else if (lastNpub != null) { - loadInternalAccount(lastNpub) - } else { - Result.failure(Exception("No saved account")) + // Single source of truth: accounts.json.enc + val activeNpub = + accountStorage.currentAccount() + ?: return Result.failure(Exception("No saved account")) + + val accounts = accountStorage.loadAccounts() + val info = + accounts.find { it.npub == activeNpub } + ?: return Result.failure(Exception("Account not found in storage")) + + when (info.signerType) { + is SignerType.Internal -> loadInternalAccount(activeNpub) + is SignerType.Remote -> loadBunkerAccount((info.signerType as SignerType.Remote).bunkerUri, activeNpub) + is SignerType.ViewOnly -> loadReadOnlyAccount(activeNpub) } + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } @@ -298,7 +315,6 @@ class AccountManager internal constructor( } val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub() - if (npub == null) saveLastNpub(resolvedNpub) val state = AccountState.LoggedIn( @@ -439,18 +455,14 @@ class AccountManager internal constructor( ephemeralPrivKeyHex: String, npub: String, ) { - saveLastNpub(npub) secureStorage.savePrivateKey(bunkerEphemeralKeyAlias(npub), ephemeralPrivKeyHex) - saveBunkerUri(bunkerUri) - // Also save to multi-account storage + // Save to multi-account storage (sole source of truth) val info = AccountInfo(npub = npub, signerType = SignerType.Remote(bunkerUri)) addAccountToStorage(info) accountStorage.setCurrentAccount(npub) } - fun hasBunkerAccount(): Boolean = getBunkerFile().exists() - fun setConnectingRelays() { _accountState.value = AccountState.ConnectingRelays } @@ -481,8 +493,6 @@ class AccountManager internal constructor( } } - saveLastNpub(current.npub) - // Always save to multi-account storage (including read-only) val info = AccountInfo(npub = current.npub, signerType = current.signerType) addAccountToStorage(info) @@ -564,23 +574,30 @@ class AccountManager internal constructor( // Clean up remote signer if bunker account if (current.signerType is SignerType.Remote) { (current.signer as? NostrSignerRemote)?.closeSubscription() - if (deleteKey) { - try { - secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(current.npub)) - } catch (_: SecureStorageException) { - } - getBunkerFile().delete() - } } if (deleteKey) { + // Remove ALL credentials for this account try { secureStorage.deletePrivateKey(current.npub) - clearLastNpub() } catch (_: SecureStorageException) { } + try { + secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(current.npub)) + } catch (_: SecureStorageException) { + } + try { + secureStorage.deletePrivateKey(nwcKeyAlias(current.npub)) + } catch (_: SecureStorageException) { + } + // Remove from metadata — fixes ghost accounts + try { + accountStorage.deleteAccount(current.npub) + } catch (_: Exception) { + } } } disconnectNip46Client() + _nwcConnection.value = null _signerConnectionState.value = SignerConnectionState.NotRemote _lastPingTimeSec.value = null _accountState.value = AccountState.LoggedOut @@ -708,6 +725,10 @@ class AccountManager internal constructor( secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(npub)) } catch (_: SecureStorageException) { } + try { + secureStorage.deletePrivateKey(nwcKeyAlias(npub)) + } catch (_: SecureStorageException) { + } refreshAccountList() @@ -758,7 +779,7 @@ class AccountManager internal constructor( } // Reload NWC for the new account - loadNwcConnection() + loadNwcConnection(targetNpub) return newState } @@ -785,91 +806,62 @@ class AccountManager internal constructor( fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn - // --- NWC --- + // --- NWC (per-account, secret in keychain) --- + + private fun nwcKeyAlias(npub: String) = "nwc_$npub" fun hasNwcSetup(): Boolean = _nwcConnection.value != null - fun setNwcConnection(uri: String): Result = + suspend fun setNwcConnection( + npub: String, + uri: String, + ): Result = try { val parsed = Nip47WalletConnect.parse(uri) + if (parsed.secret == null) throw IllegalArgumentException("NWC URI has no secret") + + // Store entire URI in keychain (not plaintext file) + secureStorage.savePrivateKey(nwcKeyAlias(npub), uri) + _nwcConnection.value = parsed - saveNwcUri(uri) Result.success(parsed) + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } - fun clearNwcConnection() { - _nwcConnection.value = null - getNwcFile().delete() - } - - fun loadNwcConnection() { - val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim() - if (!uri.isNullOrEmpty()) { - try { - _nwcConnection.value = Nip47WalletConnect.parse(uri) - } catch (_: Exception) { - getNwcFile().delete() - } - } - } - - // --- File storage helpers --- - - private fun ensureAmethystDir() { - if (!amethystDir.exists()) { - amethystDir.mkdirs() - } + suspend fun clearNwcConnection(npub: String) { try { - Files.setPosixFilePermissions( - amethystDir.toPath(), - setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, - ), - ) - } catch (_: UnsupportedOperationException) { - // Windows — file system ACLs handle this + secureStorage.deletePrivateKey(nwcKeyAlias(npub)) + } catch (_: SecureStorageException) { + } + _nwcConnection.value = null + } + + suspend fun loadNwcConnection(npub: String) { + val secret = + try { + secureStorage.getPrivateKey(nwcKeyAlias(npub)) + } catch (_: SecureStorageException) { + null + } + + if (secret == null) { + _nwcConnection.value = null + return + } + + // Reconstruct Nip47URINorm from the NWC URI stored in the keychain. + // The secret is the full NWC URI in hex form. + // For simplicity, the entire NWC URI is stored in keychain (not just the secret). + // This avoids needing to persist pubkey+relay separately. + try { + _nwcConnection.value = Nip47WalletConnect.parse(secret) } catch (_: Exception) { + _nwcConnection.value = null } } - - private fun saveNwcUri(uri: String) { - ensureAmethystDir() - getNwcFile().writeText(uri) - } - - private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt") - - private fun getLastNpub(): String? { - val file = getPrefsFile() - return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null - } - - private fun saveLastNpub(npub: String) { - ensureAmethystDir() - getPrefsFile().writeText(npub) - } - - private fun clearLastNpub() { - getPrefsFile().delete() - } - - private fun getPrefsFile(): File = File(amethystDir, "last_account.txt") - - private fun getBunkerUri(): String? { - val file = getBunkerFile() - return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null - } - - private fun saveBunkerUri(uri: String) { - ensureAmethystDir() - getBunkerFile().writeText(uri) - } - - private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt") } internal fun parseBunkerRelays(uri: String): Set { 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 cf7aab8295..40c32e2ad4 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 @@ -47,9 +47,22 @@ import javax.crypto.spec.SecretKeySpec * * File: ~/.amethyst/accounts.json.enc */ +sealed class StorageCorruption( + val backupPath: String?, +) { + class FileCorrupted( + backupPath: String?, + ) : StorageCorruption(backupPath) + + class JsonMalformed( + backupPath: String?, + ) : StorageCorruption(backupPath) +} + class DesktopAccountStorage( private val secureStorage: SecureKeyStorage, private val homeDir: File = File(System.getProperty("user.home")), + private val onCorruption: (StorageCorruption) -> Unit = {}, ) : AccountStorage { companion object { private const val METADATA_KEY_ALIAS = "account-metadata-key" @@ -115,16 +128,50 @@ class DesktopAccountStorage( val file = getAccountsFile() if (!file.exists()) return AccountMetadata() + val encrypted = file.readBytes() + if (encrypted.size < GCM_IV_SIZE) { + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) + return AccountMetadata() + } + return try { - val encrypted = file.readBytes() 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) + onCorruption(StorageCorruption.FileCorrupted(backup)) + AccountMetadata() + } catch (e: javax.crypto.BadPaddingException) { + Log.e("DesktopAccountStorage", "Decryption failed — file corrupted", e) + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) + AccountMetadata() + } catch (e: com.fasterxml.jackson.core.JacksonException) { + Log.e("DesktopAccountStorage", "JSON malformed after decryption", e) + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.JsonMalformed(backup)) + AccountMetadata() } catch (e: Exception) { Log.e("DesktopAccountStorage", "Failed to read accounts metadata", e) + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) AccountMetadata() } } + private fun backupCorruptFile(file: File): String? = + try { + val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}") + java.nio.file.Files + .copy(file.toPath(), backup.toPath()) + file.delete() + backup.absolutePath + } catch (_: Exception) { + null + } + private suspend fun writeMetadataToDisk(metadata: AccountMetadata) { ensureDir() val json = mapper.writeValueAsBytes(metadata) @@ -226,6 +273,8 @@ internal data class AccountInfoDto( val bunkerUri: String? = null, val displayName: String? = null, val isTransient: Boolean = false, + val nwcPubKey: String? = null, // NWC wallet service pubkey (non-secret) + val nwcRelay: String? = null, // NWC wallet relay URL (non-secret) ) { fun toAccountInfo(): AccountInfo = AccountInfo( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index bfe4b9e64c..6cea5f0013 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -56,6 +56,7 @@ fun DeckSidebar( onRemoveAccount: (String) -> Unit, onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + onShowImportFollowListDialog: () -> Unit = {}, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, torStatus: TorServiceStatus, @@ -90,6 +91,14 @@ fun DeckSidebar( ) } + IconButton(onClick = onShowImportFollowListDialog) { + Icon( + MaterialSymbols.PersonAdd, + contentDescription = "Import Follow List", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.weight(1f)) BunkerHeartbeatIndicator( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 33c8e8df27..9e7fb7d7d9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -95,6 +95,7 @@ fun SinglePaneLayout( onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onShowImportFollowListDialog: () -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, @@ -178,6 +179,26 @@ fun SinglePaneLayout( ) } + NavigationRailItem( + selected = false, + onClick = onShowImportFollowListDialog, + icon = { + Icon( + MaterialSymbols.PersonAdd, + contentDescription = "Import Follow List", + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + "Import", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + }, + colors = NavigationRailItemDefaults.colors(), + ) + NavigationRailItem( selected = false, onClick = onOpenAppDrawer, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt index 2df73bf8ed..9bf1d6dbbb 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt @@ -27,7 +27,6 @@ import kotlin.io.path.createTempDirectory import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertFalse import kotlin.test.assertTrue class AccountManagerBunkerLoginTest { @@ -50,17 +49,6 @@ class AccountManagerBunkerLoginTest { tempDir.deleteRecursively() } - @Test - fun hasBunkerAccountReturnsFalseWhenNoFile() { - assertFalse(manager.hasBunkerAccount()) - } - - @Test - fun hasBunkerAccountReturnsTrueWhenFileExists() { - File(amethystDir, "bunker_uri.txt").writeText("bunker://${"a".repeat(64)}?relay=wss://r.com") - assertTrue(manager.hasBunkerAccount()) - } - @Test fun setConnectingRelaysUpdatesState() { manager.setConnectingRelays() 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 7038f22688..3d7f09763c 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 @@ -21,6 +21,7 @@ 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 com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -45,6 +46,8 @@ class AccountManagerLoadAccountTest { @BeforeTest fun setup() { storage = mockk(relaxed = true) + // Return null so DesktopAccountStorage generates a fresh AES key + coEvery { storage.getPrivateKey("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-load-test").toFile() amethystDir = File(tempDir, ".amethyst") amethystDir.mkdirs() @@ -57,9 +60,9 @@ class AccountManagerLoadAccountTest { } @Test - fun loadSavedAccountNoNpubReturnsFailure() = + fun loadSavedAccountNoAccountsReturnsFailure() = runTest { - // No last_account.txt file + // Empty accounts.json.enc (no accounts saved) val result = manager.loadSavedAccount() assertTrue(result.isFailure) } @@ -71,10 +74,11 @@ class AccountManagerLoadAccountTest { val npub = keyPair.pubKey.toNpub() val privKeyHex = keyPair.privKey!!.toHexKey() - // Write last_account.txt - File(amethystDir, "last_account.txt").writeText(npub) + // Save account via storage API + manager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.Internal)) + manager.accountStorage.setCurrentAccount(npub) - // Mock storage to return the private key + // Mock keychain to return the private key coEvery { storage.getPrivateKey(npub) } returns privKeyHex val result = manager.loadSavedAccount() @@ -90,7 +94,8 @@ class AccountManagerLoadAccountTest { val keyPair = KeyPair() val npub = keyPair.pubKey.toNpub() - File(amethystDir, "last_account.txt").writeText(npub) + manager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.Internal)) + manager.accountStorage.setCurrentAccount(npub) coEvery { storage.getPrivateKey(npub) } returns null val result = manager.loadSavedAccount() @@ -106,10 +111,13 @@ class AccountManagerLoadAccountTest { val keyPair = KeyPair() val npub = keyPair.pubKey.toNpub() - File(amethystDir, "last_account.txt").writeText(npub) - File(amethystDir, "bunker_uri.txt").writeText( - "bunker://$validHex?relay=wss://r.com", + manager.accountStorage.saveAccount( + AccountInfo(npub = npub, signerType = SignerType.Remote("bunker://$validHex?relay=wss://r.com")), ) + manager.accountStorage.setCurrentAccount(npub) + coEvery { + storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub)) + } returns null coEvery { storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS) } returns null @@ -121,23 +129,49 @@ class AccountManagerLoadAccountTest { @Test fun loadSavedAccountBunkerSuccess() = runTest { + val walletKeyPair = KeyPair() + val validHex = walletKeyPair.pubKey.toHexKey() val keyPair = KeyPair() val npub = keyPair.pubKey.toNpub() - val ephemeralKeyPair = KeyPair() - val ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey() - val validHex = keyPair.pubKey.toHexKey() + val ephemeralKey = KeyPair() - File(amethystDir, "last_account.txt").writeText(npub) - File(amethystDir, "bunker_uri.txt").writeText( - "bunker://$validHex?relay=wss://r.com", + manager.accountStorage.saveAccount( + AccountInfo(npub = npub, signerType = SignerType.Remote("bunker://$validHex?relay=wss://relay.nsec.app")), ) + manager.accountStorage.setCurrentAccount(npub) + coEvery { - storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS) - } returns ephemeralPrivKeyHex + storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub)) + } returns ephemeralKey.privKey!!.toHexKey() val result = manager.loadSavedAccount() assertTrue(result.isSuccess) val state = result.getOrThrow() assertIs(state.signerType) } + + @Test + fun loadSavedAccountDeletesLegacyFiles() = + runTest { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val privKeyHex = keyPair.privKey!!.toHexKey() + + // Create legacy files + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText("bunker://test") + File(amethystDir, "nwc_connection.txt").writeText("nostr+walletconnect://test") + + // Save via storage + manager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.Internal)) + manager.accountStorage.setCurrentAccount(npub) + coEvery { storage.getPrivateKey(npub) } returns privKeyHex + + manager.loadSavedAccount() + + // Legacy files should be deleted + assertTrue(!File(amethystDir, "last_account.txt").exists()) + assertTrue(!File(amethystDir, "bunker_uri.txt").exists()) + assertTrue(!File(amethystDir, "nwc_connection.txt").exists()) + } } 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 4956cab81c..2f74a42390 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 @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip19Bech32.toNsec +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -44,6 +45,7 @@ class AccountManagerLogoutTest { @BeforeTest fun setup() { storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey("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 acb1603a1f..f2688a2819 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 @@ -55,6 +55,7 @@ class AccountManagerNip46IsolationTest { @BeforeTest fun setup() { storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile() amethystDir = File(tempDir, ".amethyst") amethystDir.mkdirs() @@ -69,16 +70,21 @@ class AccountManagerNip46IsolationTest { @Test fun bunkerLoadCreatesRemoteSignerInternally() = runTest { - val validHex = "a".repeat(64) + val walletKeyPair = KeyPair() + val validHex = walletKeyPair.pubKey.toHexKey() val ephemeralKeyPair = KeyPair() - File(amethystDir, "bunker_uri.txt").writeText( - "bunker://$validHex?relay=wss://relay.nsec.app", - ) - File(amethystDir, "last_account.txt").writeText( - ephemeralKeyPair.pubKey.toNpub(), + val npub = ephemeralKeyPair.pubKey.toNpub() + val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app" + + manager.accountStorage.saveAccount( + com.vitorpamplona.amethyst.commons.model.account.AccountInfo( + npub = npub, + signerType = SignerType.Remote(bunkerUri), + ), ) + manager.accountStorage.setCurrentAccount(npub) coEvery { - storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS) + storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub)) } returns ephemeralKeyPair.privKey!!.toHexKey() val result = manager.loadSavedAccount() @@ -130,16 +136,21 @@ class AccountManagerNip46IsolationTest { @Test fun logoutResetsStateAfterBunkerLoad() = runTest { - val validHex = "a".repeat(64) + val walletKeyPair = KeyPair() + val validHex = walletKeyPair.pubKey.toHexKey() val ephemeralKeyPair = KeyPair() - File(amethystDir, "bunker_uri.txt").writeText( - "bunker://$validHex?relay=wss://relay.nsec.app", - ) - File(amethystDir, "last_account.txt").writeText( - ephemeralKeyPair.pubKey.toNpub(), + val npub = ephemeralKeyPair.pubKey.toNpub() + val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app" + + manager.accountStorage.saveAccount( + com.vitorpamplona.amethyst.commons.model.account.AccountInfo( + npub = npub, + signerType = SignerType.Remote(bunkerUri), + ), ) + manager.accountStorage.setCurrentAccount(npub) coEvery { - storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS) + storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub)) } returns ephemeralKeyPair.privKey!!.toHexKey() manager.loadSavedAccount() @@ -155,23 +166,28 @@ class AccountManagerNip46IsolationTest { @Test fun logoutThenReloadCreatesFreshNip46Client() = runTest { - val validHex = "a".repeat(64) + val walletKeyPair = KeyPair() + val validHex = walletKeyPair.pubKey.toHexKey() val ephemeralKeyPair = KeyPair() + val npub = ephemeralKeyPair.pubKey.toNpub() val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app" - fun writeBunkerFiles() { - File(amethystDir, "bunker_uri.txt").writeText(bunkerUri) - File(amethystDir, "last_account.txt").writeText( - ephemeralKeyPair.pubKey.toNpub(), + suspend fun setupBunkerAccount() { + manager.accountStorage.saveAccount( + com.vitorpamplona.amethyst.commons.model.account.AccountInfo( + npub = npub, + signerType = SignerType.Remote(bunkerUri), + ), ) + manager.accountStorage.setCurrentAccount(npub) } coEvery { - storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS) + storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub)) } returns ephemeralKeyPair.privKey!!.toHexKey() // First load - writeBunkerFiles() + setupBunkerAccount() manager.loadSavedAccount() val firstSigner = manager.currentAccount()?.signer assertNotNull(firstSigner) @@ -181,7 +197,7 @@ class AccountManagerNip46IsolationTest { assertIs(manager.accountState.value) // Second load — should create a fresh NIP-46 client - writeBunkerFiles() + setupBunkerAccount() manager.loadSavedAccount() val secondSigner = manager.currentAccount()?.signer assertNotNull(secondSigner) 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 988cbf96ae..f9adf3bff9 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 @@ -55,6 +55,7 @@ class AccountManagerStateTransitionTest { @BeforeTest fun setup() { storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey("account-metadata-key") } returns null tempDir = createTempDirectory("acctmgr-state-test").toFile() amethystDir = File(tempDir, ".amethyst") amethystDir.mkdirs() @@ -81,7 +82,13 @@ class AccountManagerStateTransitionTest { // Then load an internal account (simulating successful load) val keyPair = KeyPair() val npub = keyPair.pubKey.toNpub() - File(amethystDir, "last_account.txt").writeText(npub) + manager.accountStorage.saveAccount( + com.vitorpamplona.amethyst.commons.model.account.AccountInfo( + npub = npub, + signerType = com.vitorpamplona.amethyst.commons.model.account.SignerType.Internal, + ), + ) + manager.accountStorage.setCurrentAccount(npub) coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey() manager.loadSavedAccount() diff --git a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md index e2491643b9..64813e1d4b 100644 --- a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md +++ b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md @@ -92,7 +92,6 @@ re-exporting. `:nestsClient` drops its `transport/` subpackage and declares ```groovy include ':quartz' include ':commons' -include ':ammolite' include ':quic' // NEW — pure-Kotlin QUIC + HTTP/3 + WebTransport include ':nestsClient' // gains `implementation project(':quic')` include ':desktopApp' diff --git a/docs/plans/2026-04-28-multi-account-testing-sheet.md b/docs/plans/2026-04-28-multi-account-testing-sheet.md index 3568619841..198774e992 100644 --- a/docs/plans/2026-04-28-multi-account-testing-sheet.md +++ b/docs/plans/2026-04-28-multi-account-testing-sheet.md @@ -103,4 +103,4 @@ ## Test Environment - OS: - JDK version: -- Amethyst version: 1.08.0 (feat/desktop-multi-account) +- Amethyst version: 1.09.2 (feat/desktop-multi-account) diff --git a/docs/plans/2026-05-14-fix-account-security-hardening-plan.md b/docs/plans/2026-05-14-fix-account-security-hardening-plan.md new file mode 100644 index 0000000000..8aea7b996c --- /dev/null +++ b/docs/plans/2026-05-14-fix-account-security-hardening-plan.md @@ -0,0 +1,462 @@ +--- +title: "fix: Account Security Hardening" +type: fix +status: active +date: 2026-05-14 +origin: docs/brainstorms/2026-05-14-fix-account-security-hardening-brainstorm.md +--- + +# Account Security Hardening + +## Deepening Insights (2026-05-14) + +1. **NWC URI reconstruction**: `Nip47URINorm` has a direct constructor `(pubKeyHex, relayUri, secret, lud16)` — no URI string parsing needed. Store pubKeyHex+relayUri in metadata, secret in keychain, reconstruct via constructor. +2. **SecureKeyStorage alias**: Accepts any arbitrary string — `"nwc_npub1..."` confirmed to work. No format validation on alias. +3. **Corruption detection**: Use `StateFlow` on `AccountManager` (matches existing `forceLogoutReason` pattern). Distinguish key-lost (`getPrivateKey("account-metadata-key")` returns null) vs file-corrupted (`AEADBadTagException`) vs JSON-malformed (`JacksonException`). +4. **NWC save failure**: Don't fall back to plaintext or in-memory — fail the connection entirely. NWC secrets control real money. +5. **Cold boot ConnectingRelays**: Must read `accounts.json.enc` first to know account type, then show ConnectingRelays UI if Remote. Can't show it before reading. +6. **ensureCurrentAccountInStorage**: No longer needed — `loadSavedAccount()` only loads from `accounts.json.enc`, so account is guaranteed to be there. +7. **Backup pattern**: Use timestamped backups (`accounts.json.enc.corrupt.`) to avoid overwriting previous backups. + +## Overview + +Single PR fixing 6 security and correctness issues in desktop account management. +Makes `accounts.json.enc` the sole source of truth, moves NWC secrets to OS keychain, +makes NWC per-account, and eliminates stale file bugs. (see brainstorm for full rationale) + +## Problem Statement + +1. **CRITICAL:** NWC wallet secret in plaintext `nwc_connection.txt` — funds can be stolen +2. **HIGH:** NWC is global — switching accounts doesn't switch wallets +3. **HIGH:** Cold boot reads stale `bunker_uri.txt` before `accounts.json.enc` — nsec misidentified as bunker +4. **MEDIUM:** `logout(deleteKey=true)` doesn't remove from `accounts.json.enc` — ghost accounts +5. **MEDIUM:** `accounts.json.enc` corruption silently returns empty — data loss +6. **LOW:** `bunker_uri.txt` is singleton — can't support multi-bunker + +## Implementation Phases + +### Phase 1: NWC Secret to Keychain + Per-Account (Issues 1, 2) + +#### 1a: Add NWC fields to AccountInfoDto + +**File:** `DesktopAccountStorage.kt:223-258` + +```kotlin +internal data class AccountInfoDto( + val npub: String, + val signerKind: String, + val bunkerUri: String? = null, + val displayName: String? = null, + val isTransient: Boolean = false, + val nwcPubKey: String? = null, // NEW: wallet service pubkey (non-secret) + val nwcRelay: String? = null, // NEW: wallet relay URL (non-secret) +) +``` + +Jackson handles new nullable fields transparently — old `accounts.json.enc` files +deserialize without them (null defaults). `AccountInfo` in commons also needs +matching fields (or `AccountInfoDto.toAccountInfo()` maps them). + +#### 1b: Rewrite NWC methods in AccountManager + +**File:** `AccountManager.kt:788-844` + +Replace `setNwcConnection()`, `clearNwcConnection()`, `loadNwcConnection()`. + +**Key insight from deepening:** `Nip47URINorm` has a direct constructor — no URI +string reconstruction needed. Store components, rebuild via constructor. + +```kotlin +// --- NWC (per-account, secret in keychain) --- + +private fun nwcKeyAlias(npub: String) = "nwc_$npub" + +fun setNwcConnection(npub: String, uri: String): Result = + try { + val parsed = Nip47WalletConnect.parse(uri) + val secret = parsed.secret ?: throw IllegalArgumentException("NWC URI has no secret") + + // Secret → keychain (fail entirely if keychain unavailable — don't fall back to plaintext) + secureStorage.savePrivateKey(nwcKeyAlias(npub), secret) + + // Non-secret parts → accounts.json.enc + scope.launch { + val info = accountStorage.loadAccounts().find { it.npub == npub } + if (info != null) { + accountStorage.saveAccount( + info.copy( + nwcPubKey = parsed.pubKeyHex, + nwcRelay = parsed.relayUri.toString(), + ), + ) + } + } + + _nwcConnection.value = parsed + Result.success(parsed) + } catch (e: Exception) { + Result.failure(e) + } + +fun clearNwcConnection(npub: String) { + try { secureStorage.deletePrivateKey(nwcKeyAlias(npub)) } catch (_: SecureStorageException) {} + scope.launch { + val info = accountStorage.loadAccounts().find { it.npub == npub } + if (info != null) { + accountStorage.saveAccount(info.copy(nwcPubKey = null, nwcRelay = null)) + } + } + _nwcConnection.value = null +} + +fun loadNwcConnection(npub: String) { + val secret = try { + secureStorage.getPrivateKey(nwcKeyAlias(npub)) + } catch (_: SecureStorageException) { null } + + if (secret != null) { + scope.launch { + val info = accountStorage.loadAccounts().find { it.npub == npub } + if (info?.nwcPubKey != null && info.nwcRelay != null) { + // Reconstruct directly — no URI parsing needed + _nwcConnection.value = Nip47WalletConnect.Nip47URINorm( + pubKeyHex = info.nwcPubKey, + relayUri = NormalizedRelayUrl(info.nwcRelay), + secret = secret, + ) + } + } + } else { + _nwcConnection.value = null + } +} +``` + +#### 1c: Delete legacy NWC file helpers + +**Remove from AccountManager.kt:** +- `saveNwcUri()` (line 839-841) +- `getNwcFile()` (line 844) + +**Delete on startup:** Add to `loadSavedAccount()`: +```kotlin +// Clean up legacy plaintext NWC file +File(amethystDir, "nwc_connection.txt").delete() +``` + +#### 1d: Update callers + +- `switchAccount()` (line 761): `loadNwcConnection()` → `loadNwcConnection(targetNpub)` +- `Main.kt`: any `loadNwcConnection()` calls → pass npub +- Wallet column: `setNwcConnection(uri)` → `setNwcConnection(npub, uri)` + +--- + +### Phase 2: Cold Boot from accounts.json.enc (Issue 3) + +#### 2a: Rewrite loadSavedAccount() + +**File:** `AccountManager.kt:233-247` + +Replace entirely: + +```kotlin +suspend fun loadSavedAccount(): Result = + try { + val activeNpub = accountStorage.currentAccount() + ?: return Result.failure(Exception("No saved account")) + + val accounts = accountStorage.loadAccounts() + val info = accounts.find { it.npub == activeNpub } + ?: return Result.failure(Exception("Account not found in storage")) + + // Clean up legacy files (one-time) + File(amethystDir, "last_account.txt").delete() + File(amethystDir, "bunker_uri.txt").delete() + File(amethystDir, "nwc_connection.txt").delete() + + when (info.signerType) { + is SignerType.Internal -> loadInternalAccount(activeNpub) + is SignerType.Remote -> loadBunkerAccount( + (info.signerType as SignerType.Remote).bunkerUri, + activeNpub, + ) + is SignerType.ViewOnly -> loadReadOnlyAccount(activeNpub) + } + } catch (e: Exception) { + Result.failure(e) + } +``` + +#### 2b: Rewrite saveCurrentAccount() + +**File:** `AccountManager.kt:460-492` + +Remove `saveLastNpub()` call (line 484). The `accountStorage.setCurrentAccount()` +call is sufficient. + +#### 2c: Remove legacy file helpers + +**Remove from AccountManager.kt:** +- `getLastNpub()` (line 846-848) +- `saveLastNpub()` (line 851-853) +- `clearLastNpub()` (line 856-857) +- `getPrefsFile()` (line 860) +- `getBunkerUri()` (line 862-864) +- `saveBunkerUri()` (line 867-869) +- `getBunkerFile()` (line 872) +- `hasBunkerAccount()` — uses `getBunkerUri()`, no longer needed + +#### 2d: Update saveBunkerAccount() + +Stop writing to `bunker_uri.txt`. The bunker URI is already saved in +`accounts.json.enc` via `AccountInfoDto.bunkerUri`. + +#### 2e: Update Main.kt startup + +**File:** `Main.kt:831-853` + +**Deepening insight:** `ensureCurrentAccountInStorage()` is no longer needed +since `loadSavedAccount()` only loads from `accounts.json.enc` (account is +guaranteed to be there). `ConnectingRelays` UI must come AFTER reading +metadata to know the account type. + +```kotlin +scope.launch(Dispatchers.IO) { + accountManager.refreshAccountListOnStartup() + + val result = accountManager.loadSavedAccount() + if (result.isSuccess) { + accountManager.refreshAccountList() + + val current = accountManager.currentAccount() + if (current?.signerType is SignerType.Remote) { + accountManager.startHeartbeat(scope) + } + // Load per-account NWC + accountManager.loadNwcConnection(current!!.npub) + } + // If failure: state remains LoggedOut → login screen shows automatically +} +``` + +Removed: +- `hasBunkerAccount()` — was based on `bunker_uri.txt` existence +- `ensureCurrentAccountInStorage()` — no longer needed +- `logout(deleteKey=true)` fallback for corrupt bunker state — corruption handling in Phase 4 covers this + +--- + +### Phase 3: Logout Cleanup (Issue 4) + +#### 3a: Fix logout(deleteKey=true) + +**File:** `AccountManager.kt:561-589` + +```kotlin +suspend fun logout(deleteKey: Boolean = false) { + val current = currentAccount() + if (current != null) { + if (current.signerType is SignerType.Remote) { + (current.signer as? NostrSignerRemote)?.closeSubscription() + } + if (deleteKey) { + try { secureStorage.deletePrivateKey(current.npub) } catch (_: SecureStorageException) {} + try { secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(current.npub)) } catch (_: SecureStorageException) {} + try { secureStorage.deletePrivateKey(nwcKeyAlias(current.npub)) } catch (_: SecureStorageException) {} + // Remove from metadata — fixes ghost accounts + accountStorage.deleteAccount(current.npub) + } + } + disconnectNip46Client() + _nwcConnection.value = null + _signerConnectionState.value = SignerConnectionState.NotRemote + _lastPingTimeSec.value = null + _accountState.value = AccountState.LoggedOut + stopHeartbeat() +} +``` + +Key changes: +- Add `secureStorage.deletePrivateKey(nwcKeyAlias(current.npub))` +- Add `accountStorage.deleteAccount(current.npub)` when `deleteKey=true` +- Remove `getBunkerFile().delete()` and `clearLastNpub()` (files eliminated) + +--- + +### Phase 4: Corruption Handling (Issue 5) + +**Deepening insight:** Use `StateFlow` pattern (matches existing `forceLogoutReason`). +Distinguish key-lost vs file-corrupted. Use timestamped backups. + +#### 4a: Add StorageCorruption sealed class to AccountManager + +```kotlin +sealed class StorageCorruption { + data class KeyLost(val backupPath: String?) : StorageCorruption() + data class FileCorrupted(val backupPath: String?) : StorageCorruption() + data class JsonMalformed(val backupPath: String?) : StorageCorruption() +} + +private val _storageCorruption = MutableStateFlow(null) +val storageCorruption: StateFlow = _storageCorruption.asStateFlow() +fun clearStorageCorruption() { _storageCorruption.value = null } +``` + +#### 4b: Rewrite readMetadataFromDisk in DesktopAccountStorage + +**File:** `DesktopAccountStorage.kt:114-126` + +Accept a corruption callback (constructor param), catch specific exceptions: + +```kotlin +class DesktopAccountStorage( + homeDir: File, + private val secureStorage: SecureKeyStorage, + private val onCorruption: (StorageCorruption) -> Unit = {}, // NEW +) : AccountStorage { + // ... + + private suspend fun readMetadataFromDisk(): AccountMetadata { + val file = getAccountsFile() + if (!file.exists()) return AccountMetadata() + + val encrypted = file.readBytes() + if (encrypted.size < GCM_IV_SIZE) { + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) + return AccountMetadata() + } + + return try { + val decrypted = decrypt(encrypted) + mapper.readValue(decrypted) + } catch (e: javax.crypto.AEADBadTagException) { + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) + AccountMetadata() + } catch (e: javax.crypto.BadPaddingException) { + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) + AccountMetadata() + } catch (e: com.fasterxml.jackson.core.JacksonException) { + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.JsonMalformed(backup)) + AccountMetadata() + } catch (e: Exception) { + Log.e("DesktopAccountStorage", "Failed to read accounts metadata", e) + val backup = backupCorruptFile(file) + onCorruption(StorageCorruption.FileCorrupted(backup)) + AccountMetadata() + } + } + + private fun backupCorruptFile(file: File): String? = + try { + val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}") + Files.copy(file.toPath(), backup.toPath()) + file.delete() + backup.absolutePath + } catch (_: Exception) { null } +} +``` + +#### 4c: Wire in AccountManager + +Pass corruption callback when constructing `DesktopAccountStorage`: + +```kotlin +val accountStorage = DesktopAccountStorage( + homeDir = homeDir, + secureStorage = secureStorage, + onCorruption = { _storageCorruption.value = it }, +) +``` + +#### 4d: Show warning dialog in Main.kt + +Observe `storageCorruption` StateFlow (same pattern as `forceLogoutReason`): + +```kotlin +val corruption by accountManager.storageCorruption.collectAsState() +corruption?.let { c -> + AlertDialog( + onDismissRequest = { accountManager.clearStorageCorruption() }, + title = { Text("Account Data Issue") }, + text = { + Text(when (c) { + is StorageCorruption.KeyLost -> "Encryption key not found. Account list cannot be recovered." + is StorageCorruption.FileCorrupted -> "Account data was corrupted." + is StorageCorruption.JsonMalformed -> "Account data was partially written." + } + if (c.backupPath != null) "\n\nA backup was saved." else "") + }, + confirmButton = { + Button(onClick = { accountManager.clearStorageCorruption() }) { Text("OK") } + }, + ) +} +``` + +--- + +## Files Modified + +| File | Lines | Changes | +|------|-------|---------| +| `AccountManager.kt` | 233-247, 460-492, 561-589, 788-872 | Rewrite loadSavedAccount, saveCurrentAccount, logout, NWC methods; remove 8 legacy helpers | +| `DesktopAccountStorage.kt` | 114-126, 223-258 | Corruption backup; add nwcPubKey/nwcRelay to AccountInfoDto | +| `Main.kt` | 835-852 | Remove hasBunkerAccount check; add corruption warning dialog | + +## Files Deleted (Legacy, stop reading/writing) + +| File | Content | Risk | +|------|---------|------| +| `~/.amethyst/nwc_connection.txt` | NWC URI with spending secret | CRITICAL — deleted on startup | +| `~/.amethyst/last_account.txt` | Last active npub | Low — deleted on startup | +| `~/.amethyst/bunker_uri.txt` | Bunker URI | Medium — deleted on startup | + +## Test Cases + +| # | Test | File | Type | +|---|------|------|------| +| 1 | nsec login → save → cold boot loads Internal | `AccountManagerColdBootTest.kt` | Unit | +| 2 | bunker login → save → cold boot loads Remote with URI | `AccountManagerColdBootTest.kt` | Unit | +| 3 | bunker login → switch to nsec → cold boot loads Internal | `AccountManagerColdBootTest.kt` | Unit | +| 4 | Two bunker accounts → switch → each uses own URI | `AccountManagerSwitchTest.kt` | Unit | +| 5 | NWC set for account A → switch to B → back to A → NWC restored | `AccountManagerNwcTest.kt` | Unit | +| 6 | NWC secret stored in keychain, NOT in any file | `AccountManagerNwcTest.kt` | Unit | +| 7 | logout(deleteKey=true) removes from metadata + keychain | `AccountManagerLogoutTest.kt` | Unit | +| 8 | Corrupted accounts.json.enc → .bak created, empty returned | `DesktopAccountStorageCorruptionTest.kt` | Unit | +| 9 | Legacy files deleted on first cold boot | `AccountManagerMigrationTest.kt` | Unit | +| 10 | Fresh install → "No saved account" | `AccountManagerColdBootTest.kt` | Unit | + +## Acceptance Criteria + +- [ ] `nwc_connection.txt` never written; deleted on startup if exists +- [ ] `last_account.txt` never written; deleted on startup if exists +- [ ] `bunker_uri.txt` never written; deleted on startup if exists +- [ ] NWC secret stored via `secureStorage.savePrivateKey("nwc_", secret)` +- [ ] `loadSavedAccount()` routes by `SignerType` from `accounts.json.enc` +- [ ] `logout(deleteKey=true)` removes account from `accounts.json.enc` +- [ ] Corrupted `accounts.json.enc` → `.bak` backup + warning dialog +- [ ] `switchAccount()` loads correct NWC per-account +- [ ] All 10 test cases pass +- [ ] `./gradlew spotlessCheck` passes +- [ ] No plaintext secrets in `~/.amethyst/` directory + +## Risk Analysis + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Existing users lose NWC connection | Low | Acceptable — reconnect wallet once (brainstorm decision) | +| Existing users lose account on cold boot | Medium | accounts.json.enc already stores everything; only users without it are affected (new installs) | +| SecureKeyStorage fallback password prompt for NWC | Low | Same pattern as nsec storage — well-tested | +| Jackson backward compat for new AccountInfoDto fields | Low | Nullable fields with defaults — Jackson handles gracefully | + +## Sources + +- **Origin brainstorm:** docs/brainstorms/2026-05-14-fix-account-security-hardening-brainstorm.md +- `AccountManager.kt` — cold boot (233-247), save (460-492), logout (561-589), NWC (788-844), legacy files (846-872) +- `DesktopAccountStorage.kt` — corruption (114-126), AccountInfoDto (223-258) +- `Main.kt` — startup (831-853) +- `SecureKeyStorage.kt` — savePrivateKey/getPrivateKey/deletePrivateKey (commons/jvmMain) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index 298dcdfda7..da0b54c108 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -41,7 +41,7 @@ data class RelayInfo( const val NAME = "geode" const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library." const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/geode" - const val VERSION = "1.08.0" + const val VERSION = "1.09.2" /** * NIPs this relay implements out of the box. Single source of diff --git a/gradle.properties b/gradle.properties index 424c63bbb4..050eafd47c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,8 @@ org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +org.gradle.parallel=true +org.gradle.caching=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn @@ -23,7 +24,7 @@ kotlin.code.style=official android.nonTransitiveRClass=true android.enableR8.fullMode=true -kotlin.daemon.jvmargs=-Xmx12g -XX:MaxMetaspaceSize=3g +kotlin.daemon.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=2g # because we use a custom jvmAndroid target kotlin.mpp.applyDefaultHierarchyTemplate=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cdd55d2ed..8bf31c3827 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,10 +2,10 @@ # Amethyst app version — single source of truth consumed by both Android (amethyst/) # and Desktop (desktopApp/). Android versionCode is bumped independently in # amethyst/build.gradle because it must be a monotonic integer. -app = "1.08.0" +app = "1.09.2" accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" -composeMultiplatform = "1.10.3" +composeMultiplatform = "1.11.0" activityCompose = "1.13.0" agp = "9.2.1" android-compileSdk = "37" @@ -28,14 +28,13 @@ fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.3" javaKeyring = "1.0.4" -jtorctl = "0.4.5.7" kmpTorRuntime = "2.6.0" kmpTorResource = "409.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.3.21" kotlinxCollectionsImmutable = "0.4.0" -kotlinxCoroutinesCore = "1.10.2" +kotlinxCoroutinesCore = "1.11.0" kotlinxSerialization = "1.11.0" genaiProofreading = "1.0.0-beta1" genaiPrompt = "1.0.0-beta2" @@ -46,10 +45,9 @@ lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "2.2.1" markdown = "f92ef49c9d" material3 = "1.9.0" -materialIconsExtended = "1.7.3" media3 = "1.10.1" mockk = "1.14.9" -kotlinx-coroutines-test = "1.10.2" +kotlinx-coroutines-test = "1.11.0" negentropyKmp = "v1.0.2" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.8" @@ -58,12 +56,12 @@ runner = "1.7.0" secp256k1KmpJniAndroid = "0.23.0" schnorr256k1Kmp = "1.0.5" securityCryptoKtx = "1.1.0" -slf4j = "2.0.17" +slf4j = "2.0.18" spotless = "8.4.0" streamWebrtcAndroid = "1.3.10" tarsosdsp = "2.5" translate = "17.0.3" -jetbrainsCompose = "1.10.3" +jetbrainsCompose = "1.11.0" unifiedpush = "3.0.10" playServicesCast = "22.3.1" vico-charts-compose = "3.1.0" @@ -112,7 +110,6 @@ androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version. androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } -androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3-windowSize = { group = "androidx.compose.material3", name = "material3-window-size-class" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } @@ -144,7 +141,6 @@ firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.r firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" } jetbrains-compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "jetbrainsCompose" } -jetbrains-compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } jetbrains-compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrainsCompose" } jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" } @@ -158,7 +154,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } -jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } kmp-tor-runtime = { group = "io.matthewnelson.kmp-tor", name = "runtime", version.ref = "kmpTorRuntime" } kmp-tor-resource-exec-tor = { group = "io.matthewnelson.kmp-tor", name = "resource-exec-tor", version.ref = "kmpTorResource" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 1c1e8b52b2..3cb05ae465 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -164,6 +164,10 @@ val interopInstallMoqRelay by tasks.registering(Exec::class) { "--root", hangInteropCacheDir.asFile.absolutePath, "--locked", ) + // See `interopBuildSidecars` — a transitive `*-sys` crate (rustls' + // aws-lc-sys) builds a C library via CMake whose CMakeLists.txt + // predates CMake 4's minimum-version floor. + environment("CMAKE_POLICY_VERSION_MINIMUM", "3.5") val installed = hangInteropCacheDir.dir("bin").file( if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-relay.exe" else "moq-relay", @@ -184,6 +188,9 @@ val interopInstallMoqTokenCli by tasks.registering(Exec::class) { "--root", hangInteropCacheDir.asFile.absolutePath, "--locked", ) + // See `interopBuildSidecars` — CMake 4 vs. a stale bundled + // CMakeLists.txt in a transitive `*-sys` crate. + environment("CMAKE_POLICY_VERSION_MINIMUM", "3.5") val installed = hangInteropCacheDir.dir("bin").file( if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-token-cli.exe" else "moq-token-cli", @@ -199,6 +206,13 @@ val interopBuildSidecars by tasks.registering(Exec::class) { group = "interop" workingDir = hangInteropDir.asFile commandLine("cargo", "build", "--release") + // `audiopus_sys` bundles libopus, whose CMakeLists.txt declares + // `cmake_minimum_required(VERSION <3.5)`. CMake 4.x removed + // compatibility with that, so the build script panics. CMake reads + // this env var (added in 3.31) as the floor policy version, which + // lets the stale libopus config run unchanged. Harmless on older + // CMake that predates CMake 4's removal. + environment("CMAKE_POLICY_VERSION_MINIMUM", "3.5") // Track only manifests + sources; the `target/` subtree is the // output, including it as an input would mark the task always // out-of-date. @@ -227,6 +241,21 @@ tasks.withType().configureEach { val cargoBin = hangInteropCacheDir.dir("bin").asFile systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath) systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath) + // `club.minnced:opus-java` 1.1.1 ships only an x86_64 darwin dylib, + // so its in-jar loader reports unsupported on Apple Silicon. Hand + // JNA an extra search directory so a Homebrew-installed arm64 + // libopus (`brew install opus`, /opt/homebrew/opt/opus/lib) wins + // over the bundled binary. Append rather than replace to preserve + // any caller-supplied path; the existing system search still covers + // other platforms. Silently skipped if Homebrew isn't installed — + // tests on Intel mac / linux take the in-jar path unchanged. + val brewOpusLib = file("/opt/homebrew/opt/opus/lib") + if (brewOpusLib.exists()) { + val existing = System.getProperty("jna.library.path") + val combined = + if (existing.isNullOrEmpty()) brewOpusLib.absolutePath else "$existing:${brewOpusLib.absolutePath}" + systemProperty("jna.library.path", combined) + } // Per-method moq-relay trace log dir for the routing-race // investigation (plan 2026-05-07-moq-relay-routing-investigation.md). // Off by default; opt in via -DnestsHangInteropTraceRelay=true so a diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt index e32824ca5b..28b1622929 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt @@ -152,16 +152,21 @@ class AudioTrackPlayer( } // Target ~250 ms of audio: enough headroom so the decode loop can // miss its 20 ms cadence by an order of magnitude before the device - // underruns. Take the larger of `minBuffer * 16` and an explicit - // 250 ms-equivalent so devices that report a small minBuffer still - // get the same wall-clock slack. Stereo doubles the byte count - // per sample (interleaved L,R 16-bit shorts); a higher sample - // rate scales the per-second byte count linearly — both factor - // into the target so the wall-clock 250 ms target is preserved - // regardless of channel layout / sample rate. + // underruns. Use `minBuffer` as the floor — most devices report a + // floor well under 250 ms, so the 250 ms target wins and we cap + // at the intended wall-clock slack. The earlier `minBuffer * 16` + // multiplier silently inflated the ring to ~600 ms – 1 s on + // common handsets (Pixel `getMinBufferSize` ≈ 40 ms × 16 = 640 ms), + // so audio sat in the ring for that long before the speaker + // played it — visible as the speaking-now ring lighting up ~1 s + // before the listener heard the speaker. Stereo doubles the + // byte count per sample (interleaved L,R 16-bit shorts); a + // higher sample rate scales the per-second byte count linearly + // — both factor into the target so the wall-clock 250 ms target + // is preserved regardless of channel layout / sample rate. val targetBytes250Ms = (sampleRate / 4) * AudioFormat.BYTES_PER_SAMPLE * channelCount - val bufferBytes = maxOf(minBuffer * 16, targetBytes250Ms) + val bufferBytes = maxOf(minBuffer, targetBytes250Ms) val newTrack = try { diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt index 1a895d2384..7d7eb06263 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt @@ -56,6 +56,27 @@ internal interface HotSwappablePublisherSource { startSequence: Long = 0L, ): MoqLitePublisherHandle + /** + * Flip the speaker's outward state to [NestsSpeakerState.Broadcasting]. + * + * The hot-swap pump owns its own long-lived broadcaster and opens + * publishers via [openPublisherForHotSwap] instead of going through + * [MoqLiteNestsSpeaker.startBroadcasting], so the speaker's state + * machine never sees the Connected → Broadcasting transition that + * `startBroadcasting` performs. Without this call the reconnect + * wrapper — which mirrors the underlying speaker's state — stays + * stuck on Connected forever and callers waiting on Broadcasting + * (the production VM, interop tests) time out. + * + * Called once per session iteration after the audio publisher is + * installed, so a freshly-swapped session re-enters Broadcasting + * too. No-op when the speaker is already terminal. + * + * @param isMuted the user's current mute intent, replayed onto the + * new state so the swap doesn't reset the mute indicator. + */ + fun reportBroadcasting(isMuted: Boolean) + /** * Surface a broadcast-pipeline terminal failure (e.g. sustained * `publisher.send` errors past diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt index 74c0766af7..132bbccab5 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt @@ -229,6 +229,37 @@ class MoqLiteNestsSpeaker internal constructor( } } + /** + * [HotSwappablePublisherSource] implementation. The hot-swap pump + * drives publishing through [openPublisherForHotSwap] rather than + * [startBroadcasting], so it has to flip the state machine into + * Broadcasting itself. Connected → Broadcasting on first call; + * re-applies the mute intent if already Broadcasting (a session + * swap re-enters this with the new session still in Connected, so + * the common case is the Connected branch). No-op once terminal. + */ + override fun reportBroadcasting(isMuted: Boolean) { + val current = mutableState.value + mutableState.value = + when (current) { + is NestsSpeakerState.Connected -> { + NestsSpeakerState.Broadcasting( + room = current.room, + negotiatedMoqVersion = current.negotiatedMoqVersion, + isMuted = isMuted, + ) + } + + is NestsSpeakerState.Broadcasting -> { + current.copy(isMuted = isMuted) + } + + else -> { + return + } + } + } + /** * Called from the broadcaster's `onTerminalFailure` callback (off * the speaker's coroutine). Transitions the speaker to `Failed` so diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt index 6ec082b75a..a2e894fd39 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference /** @@ -334,6 +335,29 @@ private class ReconnectingHandle( ) val liveHandleRef = AtomicReference(null) + // Cross-subscription replay guard. moq-lite publishers carry + // their group lineage forward monotonically across their own + // session recycles (ReconnectingNestsSpeaker.runHotSwapIteration + // seeds each new publisher with the prior one's nextSequence), + // and on every re-subscribe — listener-side reconnect OR the + // inner publisher-cycle loop — the relay re-serves its cached + // latest group from the first frame. Without a drop filter that + // stale group is replayed straight into the decoder; in a + // fully-muted room, where no newer group is ever produced, it's + // the same ~1 s clip looping once per reconnect. Mirrors + // kixelated/hang's `Container.Consumer.#run`, which drops any + // group not strictly newer than what's already been consumed. + // + // Watermark = highest group sequence delivered by a PRIOR + // underlying subscription. It only advances when a subscription + // ends (the `finally` below), so frames of the *current* group + // are never dropped mid-stream — a live group is always newer + // than the frozen watermark. Cost: a reconnect mid-group drops + // the tail of that one in-progress group (≤ ~1 s of audio), + // which is the right trade for live audio and strictly better + // than replaying already-played speech. + val priorGroupWatermark = AtomicLong(-1L) + // Re-subscribe pump. Two re-issue triggers, layered: // // 1. Listener session swap (outer collectLatest) — fires @@ -432,15 +456,56 @@ private class ReconnectingHandle( liveHandleRef.set(handle) Log.d("NestRx") { "wrapper: handle attached id=${handle.subscribeId}, starting collect" } var emitted = 0L + var droppedStale = 0L + var handleHighestGroup = -1L + var firstObjectOnHandle = true try { - handle.objects.collect { + handle.objects.collect { obj -> + if (firstObjectOnHandle) { + firstObjectOnHandle = false + // Publisher-side session restart detection. + // The watermark assumes monotonic group + // lineage across publisher sessions, which + // holds for ReconnectingNestsSpeaker (it + // seeds each new publisher with the prior + // nextSequence). External publishers — the + // kixelated/hang-publish reference, any + // moq-lite publisher that mints a fresh + // state per reconnect — restart at 0. If + // the first group on a fresh subscription + // arrives well below the watermark, the + // publisher restarted, NOT the relay + // replaying its cache (a replay carries + // exactly the prior max, not a value + // many groups below). Reset the watermark + // so the entire post-restart stream isn't + // dropped as stale. + val watermark = priorGroupWatermark.get() + if (watermark > 0L && obj.groupId < watermark - 1L) { + priorGroupWatermark.set(-1L) + } + } + if (obj.groupId <= priorGroupWatermark.get()) { + // Stale group re-served from the relay + // cache after a re-subscribe — drop so + // it never reaches the decoder. + droppedStale += 1 + return@collect + } + if (obj.groupId > handleHighestGroup) handleHighestGroup = obj.groupId emitted += 1 - frames.emit(it) + frames.emit(obj) } } finally { if (liveHandleRef.get() === handle) liveHandleRef.set(null) + // Advance the watermark only now the subscription + // is done, so the next re-subscribe drops every + // group up to and including the last one we saw. + if (handleHighestGroup > priorGroupWatermark.get()) { + priorGroupWatermark.set(handleHighestGroup) + } } - Log.w("NestRx") { "wrapper: handle objects flow ENDED id=${handle.subscribeId} emitted=$emitted — re-issuing after ${RESUBSCRIBE_BACKOFF_MS}ms" } + Log.w("NestRx") { "wrapper: handle objects flow ENDED id=${handle.subscribeId} emitted=$emitted droppedStale=$droppedStale — re-issuing after ${RESUBSCRIBE_BACKOFF_MS}ms" } // Brief backoff so a permanently-gone // publisher doesn't tight-loop the relay // with re-subscribes. 100 ms stays well diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt index 8cfd576652..df0679e276 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt @@ -598,6 +598,14 @@ private class ReissuingBroadcastHandle( if (old != null) runCatching { old.close() } } + // The audio publisher is now live on this session. Flip the + // underlying speaker's state machine into Broadcasting — the + // hot-swap path bypasses `startBroadcasting`, so without this + // the speaker (and the reconnect wrapper that mirrors it) would + // sit on Connected forever. Done every iteration so a swapped + // session — which arrives in Connected — re-enters Broadcasting. + hotSwap.reportBroadcasting(desiredMuted) + // Catalog track on the new session. Keeps the broadcast // discoverable to standards-aligned moq-lite watchers (the // kixelated/moq browser reference) across JWT refresh — without diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListenerTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListenerTest.kt index 3d4249c161..0147f87357 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListenerTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListenerTest.kt @@ -137,10 +137,13 @@ class ReconnectingNestsListenerTest { } } - private fun frame(payload: ByteArray): MoqObject = + private fun frame( + payload: ByteArray, + groupId: Long = 0L, + ): MoqObject = MoqObject( trackAlias = 1L, - groupId = 0L, + groupId = groupId, objectId = 0L, publisherPriority = 0, payload = payload, @@ -232,7 +235,7 @@ class ReconnectingNestsListenerTest { first.frames.subscriptionCount.first { it > 0 } } - first.frames.emit(frame(byteArrayOf(0x01))) + first.frames.emit(frame(byteArrayOf(0x01), groupId = 0L)) // Wait for FRAME1 to traverse pump → wrapper.frames → // consumer collector. Without this sync the next @@ -261,7 +264,11 @@ class ReconnectingNestsListenerTest { second.frames.subscriptionCount.first { it > 0 } } - second.frames.emit(frame(byteArrayOf(0x02))) + // Post-reconnect audio lands in a newer group — moq-lite + // publishers carry their group lineage forward + // monotonically across recycles, so the second session's + // first real frame is group 1, not a replay of group 0. + second.frames.emit(frame(byteArrayOf(0x02), groupId = 1L)) val result = collected.await() assertEquals(2, result.size) @@ -283,6 +290,96 @@ class ReconnectingNestsListenerTest { } } + /** + * Regression: after a reconnect the relay re-serves its cached + * latest group from the first frame. moq-lite publishers carry + * group sequences forward monotonically across their own recycles, + * so a re-served group has a sequence we've already consumed — the + * wrapper must drop it instead of replaying already-played audio + * into the decoder. In a fully-muted room (no newer group ever + * produced) the un-dropped replay loops the same clip once per + * reconnect. + */ + @Test + fun reconnect_does_not_replay_already_consumed_group() = + runBlocking { + val scope = CoroutineScope(SupervisorJob()) + try { + val first = ScriptedListener(room) + val second = ScriptedListener(room) + val listenersInOrder = mutableListOf(first, second) + + val reconnecting = + connectReconnectingNestsListener( + httpClient = httpClient, + transport = transport, + scope = scope, + room = room, + signer = signer, + policy = NestsReconnectPolicy(initialDelayMs = 1L), + connector = { listenersInOrder.removeAt(0) }, + ) + + withTimeout(5_000L) { + reconnecting.state.first { it is NestsListenerState.Connected } + } + + val handle = reconnecting.subscribeSpeaker("speaker-pubkey") + + val consumerSubscribed = CompletableDeferred() + val consumerProgress = MutableStateFlow(0) + + @Suppress("UNCHECKED_CAST") + val objectsAsShared = handle.objects as SharedFlow + // Expect exactly two frames: the original group-0 frame + // and the genuinely-new group-1 frame. The group-0 frame + // re-served by the second session must NOT appear. + val collected = + scope.async { + withTimeout(5_000L) { + objectsAsShared + .onSubscription { consumerSubscribed.complete(Unit) } + .take(2) + .onEach { consumerProgress.value += 1 } + .toList() + } + } + + withTimeout(5_000L) { consumerSubscribed.await() } + withTimeout(5_000L) { + first.frames.subscriptionCount.first { it > 0 } + } + + first.frames.emit(frame(byteArrayOf(0x01), groupId = 0L)) + withTimeout(5_000L) { + consumerProgress.first { it >= 1 } + } + + first.fail("scripted-disconnect") + withTimeout(5_000L) { + second.frames.subscriptionCount.first { it > 0 } + } + + // The relay re-serves the cached latest group (0) from + // the start — this is the stale replay that must be + // dropped. + second.frames.emit(frame(byteArrayOf(0x02), groupId = 0L)) + // ...followed by genuinely new audio in the next group. + second.frames.emit(frame(byteArrayOf(0x03), groupId = 1L)) + + val result = collected.await() + assertEquals(2, result.size) + assertEquals(0x01.toByte(), result[0].payload[0]) + // 0x02 (re-served group 0) dropped; 0x03 (group 1) kept. + assertEquals(0x03.toByte(), result[1].payload[0]) + + handle.unsubscribe() + reconnecting.close() + } finally { + scope.cancel() + } + } + @Test fun unsubscribe_before_session_swap_releases_handle() = runBlocking { diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index 9af9955c81..eddd12a805 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -112,7 +112,18 @@ class QuicWebTransportFactory( bearerToken: String?, ): WebTransportSession { val (host, port) = splitAuthority(authority) - val socket = UdpSocket.connect(host, port) + // Decouple the socket target from the TLS SNI name. The socket must + // reach a routable address — `localhost` commonly resolves to ::1 + // first, and IPv6 loopback is not always routable (e.g. Docker's + // IPv6 UDP port-forwarding silently blackholes), so prefer an IPv4 + // address for the actual connection. SNI, however, must stay the + // original hostname so it (a) matches the server's certificate and + // (b) is a legal RFC 6066 host_name rather than an IP literal. + val socketHost = resolvePreferIpv4(host) + // One-line wire target so a stalled/failed handshake is self-diagnosing + // (did we connect over IPv4 or fall into the ::1 blackhole?). + val target = "socket=$socketHost:$port sni=$host" + val socket = UdpSocket.connect(socketHost, port) val conn = QuicConnection( serverName = host, @@ -122,14 +133,39 @@ class QuicWebTransportFactory( val driver = QuicConnectionDriver(conn, socket, parentScope) driver.start() - try { - conn.awaitHandshake() - } catch (t: Throwable) { + val handshakeCompleted = + try { + // Bound the handshake — a server-side TLS failure (e.g. a relay + // that rejects an IP-literal SNI and can't select a cert) leaves + // the handshake stalled with no alert, so without this ceiling + // connect() hangs indefinitely instead of failing fast. + kotlinx.coroutines.withTimeoutOrNull(connectTimeoutMillis) { + conn.awaitHandshake() + true + } + } catch (ce: kotlinx.coroutines.CancellationException) { + // A parent-scope cancellation propagates out of withTimeoutOrNull + // (it only swallows its OWN TimeoutCancellationException). Wrapping + // it in HandshakeFailed would break structured concurrency — close + // the driver but re-throw the cancellation untouched. Mirrors the + // CancellationException handling on the post-CONNECT path below. + driver.close() + throw ce + } catch (t: Throwable) { + driver.close() + throw WebTransportException( + kind = WebTransportException.Kind.HandshakeFailed, + message = "QUIC handshake failed [$target]: ${t.message}", + cause = t, + ) + } + if (handshakeCompleted == null) { driver.close() throw WebTransportException( kind = WebTransportException.Kind.HandshakeFailed, - message = "QUIC handshake failed: ${t.message}", - cause = t, + message = + "QUIC handshake stalled — no completion within ${connectTimeoutMillis}ms [$target] " + + "(a server-side TLS failure such as a rejected IP-literal SNI hangs here with no alert)", ) } if (conn.status != QuicConnection.Status.CONNECTED) { @@ -180,17 +216,27 @@ class QuicWebTransportFactory( readConnectResponse(requestStream) } if (response == null) { + val connDiag = describeConnectionState(conn, requestStream) driver.close() throw WebTransportException( kind = WebTransportException.Kind.HandshakeFailed, - message = "WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms", + message = + "WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms — $connDiag", ) } if (response.status !in 200..299) { + // Capture connection-level state BEFORE driver.close() — if the + // relay tore the connection down, closeReason/closeErrorCode + // are already set and tell us whether this was a CONNECTION_CLOSE + // (e.g. H3_SETTINGS_ERROR from a rejected SETTINGS frame) or the + // relay simply FIN'd the request bidi without an H3 response. + val connDiag = describeConnectionState(conn, requestStream) driver.close() throw WebTransportException( kind = WebTransportException.Kind.ConnectRejected, - message = "WebTransport CONNECT returned :status=${response.status}", + message = + "WebTransport CONNECT returned :status=${response.status} — " + + "${response.diagnostic}; $connDiag", ) } @@ -230,11 +276,19 @@ class QuicWebTransportFactory( private suspend fun readConnectResponse(requestStream: QuicStream): ConnectResponse { val reader = Http3FrameReader() val incoming = requestStream.incoming + // Diagnostic accounting — when the CONNECT fails, :status=0 alone + // can't tell us whether the relay never answered, FIN'd us with + // garbage, or sent a HEADERS frame that lacked :status. Track + // enough to disambiguate in the thrown exception message. + var bytesSeen = 0 + val h3FramesSeen = mutableListOf() try { incoming.collect { chunk -> + bytesSeen += chunk.size reader.push(chunk) while (true) { val frame = reader.next() ?: break + h3FramesSeen += frame::class.simpleName ?: "?" if (frame is Http3Frame.Headers) { val pairs = QpackDecoder().decodeFieldSection(frame.qpackPayload) val status = pairs.firstOrNull { it.first == ":status" }?.second?.toIntOrNull() ?: 0 @@ -243,24 +297,63 @@ class QuicWebTransportFactory( .firstOrNull { it.first.equals("wt-protocol", ignoreCase = true) } ?.second ?.let { parseFirstSfString(it) } - throw HeadersReceived(status, subProtocol) + val diag = + if (status == 0) { + "HEADERS frame carried no parseable :status — fields=[${pairs.joinToString { it.first }}]" + } else { + "HEADERS received, status=$status" + } + throw HeadersReceived(status, subProtocol, diag) } } } } catch (e: HeadersReceived) { - return ConnectResponse(e.status, e.subProtocol) + return ConnectResponse(e.status, e.subProtocol, e.diagnostic) } - return ConnectResponse(0, null) + // The incoming flow completed WITHOUT a HEADERS frame — the relay + // ended (FIN'd) the request bidi without an H3 response. Note this + // is a *clean* close: a RESET_STREAM would have thrown out of + // `incoming.collect` and been wrapped as HandshakeFailed instead. + return ConnectResponse( + status = 0, + subProtocol = null, + diagnostic = + "request stream ended with no HEADERS frame " + + "(bytesSeen=$bytesSeen, h3FramesSeen=$h3FramesSeen, requestStreamClosed=${requestStream.isClosed})", + ) } + /** + * One-line snapshot of the QUIC connection + request-stream state for + * a failed CONNECT. Distinguishes "the relay sent a CONNECTION_CLOSE" + * (status != CONNECTED, closeErrorCode/closeReason populated — e.g. + * H3_SETTINGS_ERROR if it rejected our SETTINGS frame) from "the relay + * left the connection up but closed just the request stream". + */ + private fun describeConnectionState( + conn: QuicConnection, + requestStream: QuicStream, + ): String = + buildString { + append("conn.status=").append(conn.status) + conn.closeReason?.let { append(", closeReason='").append(it).append('\'') } + if (conn.closeErrorCode != 0L) { + append(", closeErrorCode=0x").append(conn.closeErrorCode.toString(16)) + } + append(", requestStreamClosed=").append(requestStream.isClosed) + } + private data class ConnectResponse( val status: Int, val subProtocol: String?, + /** Human-readable account of how the response was (or wasn't) received. */ + val diagnostic: String, ) private class HeadersReceived( val status: Int, val subProtocol: String?, + val diagnostic: String, ) : RuntimeException() /** @@ -299,6 +392,25 @@ class QuicWebTransportFactory( return items.joinToString(", ") { "\"$it\"" } } + /** + * Resolve [host] to a concrete address string, preferring IPv4. Returns + * an IPv4 literal when one exists, otherwise the first resolved address, + * otherwise [host] unchanged (already an IP literal, or resolution + * failed — let the socket layer surface the error). The returned IP + * literal is only used as the UDP socket target; the caller still passes + * the original [host] as the TLS SNI server name. + */ + private fun resolvePreferIpv4(host: String): String = + try { + val addrs = java.net.InetAddress.getAllByName(host) + ( + addrs.firstOrNull { it is java.net.Inet4Address } + ?: addrs.firstOrNull() + )?.hostAddress ?: host + } catch (_: java.net.UnknownHostException) { + host + } + private fun splitAuthority(authority: String): Pair { val idx = authority.lastIndexOf(':') if (idx <= 0) return authority to 443 diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt index 2f6554b932..ed49fcadca 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt @@ -99,10 +99,40 @@ class JvmOpusEncoder( if (nativesLoaded) return synchronized(loadLock) { if (nativesLoaded) return - check(OpusLibrary.isSupportedPlatform()) { - "club.minnced:opus-java natives not available for this platform" + if (OpusLibrary.isSupportedPlatform()) { + check(OpusLibrary.loadFromJar()) { "OpusLibrary.loadFromJar() returned false" } + } else { + // opus-java 1.1.1's bundled `natives/darwin/libopus.dylib` + // is x86_64-only, so `isSupportedPlatform()` returns + // false on Apple Silicon (and on any host the jar + // doesn't ship a binary for). Fall back to a + // system-installed libopus: probe a small set of + // canonical locations and `System.load` it directly + // so its symbols are in the process. JNA's lazy + // [Opus.INSTANCE] init falls back to RTLD_DEFAULT + // for symbol lookup when `jna.library.path` doesn't + // resolve the bare name, so preloading by absolute + // path is the most reliable cross-host approach + // (`brew install opus` on macOS, `apt install libopus0` + // / `yum install opus` on linux). After the load, touch + // [Opus.INSTANCE] so any remaining linkage issue + // surfaces here rather than at the first encode call. + val candidates = + listOf( + "/opt/homebrew/opt/opus/lib/libopus.dylib", + "/usr/local/opt/opus/lib/libopus.dylib", + "/usr/lib/x86_64-linux-gnu/libopus.so.0", + "/usr/lib/aarch64-linux-gnu/libopus.so.0", + "/usr/lib64/libopus.so.0", + ) + val found = candidates.firstOrNull { java.io.File(it).exists() } + checkNotNull(found) { + "club.minnced:opus-java natives not available for this platform " + + "and no system libopus found at any of: $candidates" + } + System.load(found) + Opus.INSTANCE } - check(OpusLibrary.loadFromJar()) { "OpusLibrary.loadFromJar() returned false" } nativesLoaded = true } } diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt index 6cd356ce15..2029b2c143 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -86,9 +86,17 @@ class NostrNestsHarness private constructor( private fun isExternal(): Boolean = System.getProperty(EXTERNAL_PROPERTY) == "true" /** - * Pin the nostrnests revision to a known-good SHA. Bump deliberately - * — drift on `main` should not silently change interop expectations. - * Override at runtime via `-DnestsInteropRev=`. + * The `nostrnests/nests` revision the harness checks out — this is + * the repo that supplies the `moq-auth` token-minting sidecar. + * + * NOT currently pinned: it tracks `main`. That's a known drift risk + * — `moq-auth`'s JWT format must stay compatible with the + * `moq-relay` version pinned in [DEFAULT_MOQ_REVISION] + * (`moq-relay-v0.10.10`). If nostrnests advances `moq-auth` past + * what 0.10.10 accepts, the pair desyncs again and the relay + * rejects every connection with `failed to decode the token`. Pin + * this to a SHA known-compatible with 0.10.10 once one is + * identified. Override at runtime via `-DnestsInteropRev=`. */ const val DEFAULT_REVISION = "main" @@ -99,12 +107,23 @@ class NostrNestsHarness private constructor( private const val MOQ_REPO_URL = "https://github.com/kixelated/moq.git" /** - * Pin the upstream `kixelated/moq` revision so test runs are - * reproducible even if upstream main changes a wire-level detail - * we depend on (e.g. moq-lite framing). Override at runtime via - * `-DnestsInteropMoqRev=`. + * Pin the upstream `kixelated/moq` revision the harness builds + * `moq-relay` from. This MUST match the version nostrnests's + * `moq-auth` sidecar mints tokens for — otherwise the relay + * rejects every connection with `failed to decode the token` + * (the JWKS / JWT-signature handling drifted across releases). + * + * nostrnests pins `moq-relay --version 0.10.10` in their own + * `nests-relay/Dockerfile.relay`, so 0.10.10 is the version their + * `moq-auth` is written against. The `docker-compose-moq.yml` this + * harness uses builds `moq-relay` from `./moq` source instead of + * `cargo install`ing it, so we have to check out the matching tag + * ourselves. Plain `main` builds whatever is latest (0.10.25+), + * which moq-auth's tokens no longer satisfy. + * + * Override at runtime via `-DnestsInteropMoqRev=`. */ - const val DEFAULT_MOQ_REVISION = "main" + const val DEFAULT_MOQ_REVISION = "moq-relay-v0.10.10" private const val COMPOSE_FILE = "docker-compose-moq.yml" private const val PORT_READY_TIMEOUT_MS = 90_000L @@ -176,7 +195,6 @@ class NostrNestsHarness private constructor( runDocker(workDir, "up", "-d") try { waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS) - waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS) // moq-auth's Node runtime opens the listen socket // before its handlers are wired, so the first POST that // arrives in the gap can RST-on-write. Wait for /health @@ -184,6 +202,19 @@ class NostrNestsHarness private constructor( // pipeline is live and avoids the SocketException that // otherwise hits the first test of the second class. waitForHealth("http://127.0.0.1:$AUTH_HOST_PORT/health", PORT_READY_TIMEOUT_MS) + // moq-relay fetches its JWK set from moq-auth at startup + // and exits — with no retry — if that GET is refused. + // The compose file declares no `depends_on`, so on a cold + // `up -d` moq-relay races moq-auth's Node boot, loses, + // logs "failed to GET JWKS … Connection refused", and + // dies; every later QUIC handshake then fails with + // "read loop exited (socket closed or peer closed)". + // Now that moq-auth is confirmed healthy, (re)start + // moq-relay: this is a no-op if it survived the race and + // a clean restart if it didn't, so it always comes up + // against a live auth sidecar. + runDocker(workDir, "up", "-d", "moq-relay") + waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS) } catch (t: Throwable) { // Capture container state + recent logs BEFORE tearing // down so the failure message is actually actionable. @@ -205,7 +236,18 @@ class NostrNestsHarness private constructor( // The path under this base is the namespace literal (per // moq-rs `claims.root` matching); the `:nestsClient` connect // helpers append `/?jwt=` themselves. - moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/", + // + // Host MUST be `localhost`, not `127.0.0.1`: the QUIC client + // sends the host as TLS SNI, RFC 6066 §3 forbids IP literals + // there, and a strict relay (rustls) rejects an IP-literal SNI + // and then has no SNI to select its dev cert on — the handshake + // stalls. `localhost` is a valid hostname and matches the dev + // cert moq-relay generates. The UDP socket itself still goes to + // IPv4 loopback: QuicWebTransportFactory resolves the host with + // an IPv4 preference (`localhost` often resolves to ::1 first, + // and Docker's IPv6 UDP forwarding blackholes), while keeping + // `localhost` as the SNI name. + moqEndpoint = "https://localhost:$MOQ_HOST_PORT/", ) } @@ -242,7 +284,9 @@ class NostrNestsHarness private constructor( return NostrNestsHarness( workDir = null, authBaseUrl = "http://127.0.0.1:$AUTH_HOST_PORT", - moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/", + // `localhost`, not `127.0.0.1` — see the doStart() return for + // why an IP-literal host breaks the TLS SNI / cert selection. + moqEndpoint = "https://localhost:$MOQ_HOST_PORT/", ) } @@ -305,8 +349,10 @@ class NostrNestsHarness private constructor( if (!moqDir.exists()) { runProcess(nestsRepo, "git", "clone", MOQ_REPO_URL, "moq") } - // Reproducible runs: pin to the requested revision. - runProcess(moqDir, "git", "fetch", "origin", "--quiet") + // Reproducible runs: pin to the requested revision. `--tags` so a + // release-tag pin like `moq-relay-v0.10.10` is checkout-able even + // on a repo first cloned before that tag existed. + runProcess(moqDir, "git", "fetch", "origin", "--tags", "--quiet") runProcess(moqDir, "git", "checkout", "--quiet", rev) } diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsSustainedSendOutcomesInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsSustainedSendOutcomesInteropTest.kt index 4a81cc3f8f..266de6e6a0 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsSustainedSendOutcomesInteropTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsSustainedSendOutcomesInteropTest.kt @@ -300,6 +300,13 @@ class NostrNestsSustainedSendOutcomesInteropTest { Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 5), ) + @Test + fun harness_sweep_frames_per_group_10() = + runHarnessScenarioOrSkip( + "h-fpg10", + Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 10), + ) + @Test fun harness_sweep_frames_per_group_20() = runHarnessScenarioOrSkip( @@ -307,6 +314,15 @@ class NostrNestsSustainedSendOutcomesInteropTest { Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 20), ) + // Production default — see the matching note in + // NostrnestsProdAudioTransmissionTest.sweep_frames_per_group_50. + @Test + fun harness_sweep_frames_per_group_50_production_default() = + runHarnessScenarioOrSkip( + "h-fpg50", + Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 50), + ) + @Test fun harness_sweep_frames_per_group_all() = runHarnessScenarioOrSkip( diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrnestsProdAudioTransmissionTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrnestsProdAudioTransmissionTest.kt index 67feda78e0..61c3841c01 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrnestsProdAudioTransmissionTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrnestsProdAudioTransmissionTest.kt @@ -842,6 +842,13 @@ class NostrnestsProdAudioTransmissionTest { Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 5), ) + @Test + fun sweep_frames_per_group_10() = + runProdScenarioOrSkip( + "sweep-fpg10", + Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 10), + ) + @Test fun sweep_frames_per_group_20() = runProdScenarioOrSkip( @@ -849,6 +856,19 @@ class NostrnestsProdAudioTransmissionTest { Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 20), ) + // The production default. With a 20 ms cadence this batches a full + // second of audio into one uni stream — so the e2e-latency line for + // this scenario is the direct measurement of the send delay the + // production app exhibits today. Compare its timeToFirstFrame / + // p50 against sweep_frames_per_group_5 and the framesPerGroup=1 + // baseline to read the batching-vs-latency tradeoff curve. + @Test + fun sweep_frames_per_group_50_production_default() = + runProdScenarioOrSkip( + "sweep-fpg50", + Scenario(frameCount = 100, cadenceMs = 20L, framesPerGroup = 50), + ) + @Test fun sweep_frames_per_group_all() = runProdScenarioOrSkip( diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/SendTraceScenario.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/SendTraceScenario.kt index e3008a2b56..b9ffbea6e6 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/SendTraceScenario.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/SendTraceScenario.kt @@ -130,6 +130,13 @@ data class ScenarioResult( val scenario: Scenario, val sendOutcomes: BooleanArray, val sendDurationsMicros: LongArray, + /** + * Wall-clock time each frame was handed to [MoqLitePublisherHandle.send], + * relative to [collectStartedAtMs] (so it's on the same clock as + * [FrameArrival.wallMs]). Indexed by send order. `-1` for frames + * that were never sent (e.g. the run aborted early). + */ + val sendWallMsByIndex: LongArray, val endGroupErrors: Array, val arrivalsPerSubscriber: List>, val pumpStartedAtMs: Long, @@ -139,6 +146,36 @@ data class ScenarioResult( val sendTrueCount: Int get() = sendOutcomes.count { it } val firstFalseSendIndex: Int get() = sendOutcomes.indexOfFirst { !it } + /** + * Sorted end-to-end send→arrival latencies (ms) for every frame + * that reached [subscriberIndex]. This is the number that + * quantifies the "audio feels delayed" symptom: with + * `framesPerGroup > 1` the first frame of a group is sent + * immediately but the relay only forwards it once the group's uni + * stream is closed at the group boundary, so that frame's latency + * carries the whole batching window (≈ `framesPerGroup * cadenceMs`). + */ + fun e2eLatenciesMsFor(subscriberIndex: Int): LongArray = + arrivalIndexPerSubscriber[subscriberIndex] + .values + .mapNotNull { a -> + val sentAt = sendWallMsByIndex.getOrElse(a.objectId.toInt()) { -1L } + if (sentAt < 0) null else a.wallMs - sentAt + }.sorted() + .toLongArray() + + /** + * Latency (ms) of the first frame to arrive at [subscriberIndex] — + * the "time to first audio" a listener perceives when the speaker + * starts talking. `-1` if nothing arrived or its send time is + * unknown. + */ + fun timeToFirstFrameMsFor(subscriberIndex: Int): Long { + val first = arrivalsPerSubscriber[subscriberIndex].minByOrNull { it.wallMs } ?: return -1L + val sentAt = sendWallMsByIndex.getOrElse(first.objectId.toInt()) { -1L } + return if (sentAt < 0) -1L else first.wallMs - sentAt + } + /** * Per subscriber: `{objectId -> arrival}`. We index by objectId * (the per-session monotonic counter from @@ -232,6 +269,10 @@ object SendTraceScenario { val sendOutcomes = BooleanArray(scenario.frameCount) val sendDurationsMicros = LongArray(scenario.frameCount) + // Wall-clock send time per frame, on the same clock as + // FrameArrival.wallMs, so reportAndAssert can derive the true + // end-to-end send→arrival latency the listener perceives. + val sendWallMsByIndex = LongArray(scenario.frameCount) { -1L } val endGroupErrors = arrayOfNulls(scenario.frameCount) // Pre-sized synchronized ArrayList per subscriber. // CopyOnWriteArrayList (the previous choice) is O(N) per add — @@ -299,6 +340,7 @@ object SendTraceScenario { } val payload = payloadPrefix + byteArrayOf(i.toByte()) + sendWallMsByIndex[i] = System.currentTimeMillis() - collectStart val sendStartedNs = System.nanoTime() sendOutcomes[i] = publisher.send(payload) val sendElapsedNs = System.nanoTime() - sendStartedNs @@ -362,6 +404,7 @@ object SendTraceScenario { scenario = scenario, sendOutcomes = sendOutcomes, sendDurationsMicros = sendDurationsMicros, + sendWallMsByIndex = sendWallMsByIndex, endGroupErrors = endGroupErrors, // Snapshot under lock — synchronizedList iteration must // be guarded explicitly per the JDK contract. @@ -493,6 +536,24 @@ object SendTraceScenario { "firstSentButLost=$firstSentButLost " + "missing=${runs(missing)}", ) + + // End-to-end send→arrival latency. This is the line that + // answers "does audio feel delayed?" — with framesPerGroup + // > 1 the per-frame latency floor rises by roughly the + // batching window (framesPerGroup * cadenceMs) because the + // relay only forwards a group once its uni stream closes. + val latencies = result.e2eLatenciesMsFor(subscriberIndex) + if (latencies.isNotEmpty()) { + val p50 = latencies[latencies.size / 2] + val p99 = latencies[(latencies.size * 99 / 100).coerceAtMost(latencies.size - 1)] + InteropDebug.checkpoint( + scope, + "sub[$subscriberIndex] e2e latency (send→arrival): " + + "timeToFirstFrame=${result.timeToFirstFrameMsFor(subscriberIndex)}ms " + + "p50=${p50}ms p99=${p99}ms min=${latencies.first()}ms max=${latencies.last()}ms " + + "(framesPerGroup=${s.framesPerGroup}, cadence=${s.cadenceMs}ms)", + ) + } } // Average / min / max / p99 send latency, plus count of sends diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/AudioLatencyComparisonTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/AudioLatencyComparisonTest.kt new file mode 100644 index 0000000000..f6492dffbe --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/AudioLatencyComparisonTest.kt @@ -0,0 +1,753 @@ +/* + * 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.nestsclient.interop.native + +import com.vitorpamplona.nestsclient.NestsClient +import com.vitorpamplona.nestsclient.NestsListenerState +import com.vitorpamplona.nestsclient.NestsRoomConfig +import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder +import com.vitorpamplona.nestsclient.audio.OpusEncoder +import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture +import com.vitorpamplona.nestsclient.connectNestsSpeaker +import com.vitorpamplona.nestsclient.connectReconnectingNestsListener +import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Test +import java.net.DatagramSocket +import java.time.Instant +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.BeforeTest +import kotlin.test.assertTrue + +/** + * Side-by-side **pacing + one-way latency** measurement for two + * moq-lite audio publishers feeding the same relay through the same + * listener: + * + * - the Amethyst Kotlin speaker (`connectNestsSpeaker` → + * `MoqLiteNestsSpeaker` → `NestMoqLiteBroadcaster` → moq-lite uni + * streams), + * - the upstream reference `hang-publish` Rust binary + * (`nestsClient/tests/hang-interop/hang-publish`), invoked with + * `--log-send-times` so each frame stamps a wall-clock send time + * onto stdout immediately before entering the moq-lite group + * producer. + * + * Both publish identical-shape tracks (440 Hz mono Opus, 32 kbps, + * 20 ms frames, **5 frames per group on both sides** — Kotlin's + * default is 50, overridden here so the per-group QUIC uni-stream + * open/close cost is matched between the two stacks). The single + * Kotlin listener subscribes to BOTH speaker pubkeys so the receive + * path is identical for both sides — the variable under test is the + * **publisher stack's pacing fidelity** AND, with the matched + * send-time stamps, **one-way latency** from "frame entered the + * publisher's outbound buffer" to "frame surfaced through the + * listener's `objects` flow". + * + * Send-time capture, both sides: + * - Kotlin: a [TimestampingOpusEncoder] wrapping [JvmOpusEncoder] + * records `Instant.now().toMicros()` per `encode(...)` call, + * keyed by an internal monotonic frame counter. `encode` is + * called once per PCM frame, immediately before the encoded + * bytes are queued onto the publisher's outbound channel — that + * is the same "moment the frame entered the outbound buffer" + * anchor the Rust side uses. + * - Rust: `--log-send-times` makes hang-publish print one + * `SEND frame= send_t_us=` line per frame to stdout, + * captured with `SystemTime::now()` immediately before + * `frame.encode(group)`. Both stamps come from + * `clock_gettime(CLOCK_REALTIME)` on linux/macOS, so the two + * processes share a clock without a sync handshake. + * + * Frame-to-frame pairing uses `MoqObject.groupId` / + * `MoqObject.objectId` to derive the publisher's absolute frame + * index (`groupId * FRAMES_PER_GROUP + objectId`), so out-of-order + * group delivery — a real possibility under packet loss / parallel + * uni streams — doesn't break the matching. + * + * What we measure (per side): + * - `frames` — delivered objects in the [DURATION_MS] window. + * QUIC streams are reliable, so frame *count* should stay + * close to the theoretical max even under loss; loss instead + * surfaces as a fat **latency tail** from retransmission. + * - Inter-arrival jitter at the listener (`p50` / `p95` / `p99` + * / `max`) — pacing fidelity, independent of the publisher's + * wall clock. + * - **One-way latency** (`p50` / `p95` / `p99` / `max`) — + * `arrival_t - send_t` per matched frame, in ms. This is the + * real-time-audio number; "is our stack as fast as the Rust + * reference" answered directly. + * - `timeToFirstFrameMs` — wallclock from publisher startup to + * first frame at the listener. Informative, includes one-time + * handshake / ANNOUNCE / SUBSCRIBE costs; not directly + * comparable across stacks (Rust pays a process-spawn the JVM + * side doesn't). + * + * Two scenarios: + * - `clean_path_*` — direct UDP between publisher and relay, + * localhost link, no loss. + * - `with_5pct_loss_*` — publisher → `udp-loss-shim` → relay. + * 5 % bidirectional packet loss. The listener stays on the + * direct path so any latency growth must come from the + * publisher-side retransmit / ack feedback loop the loss + * induces. + * + * Asserts (loose, host-tolerant): + * - each side delivers ≥ 80 % of expected frames on the clean + * path, ≥ 60 % under 5 % loss (catches a stalled publisher + * without flapping on a loaded CI host), + * - each side's median inter-arrival sits in `[15, 35] ms` on + * the clean path (catches a no-pacing burst-mode publisher). + * + * Tail percentiles + one-way latency are printed for human + * inspection. The whole point of the test is the COMPARISON: as + * long as the Kotlin numbers stay close to the Rust numbers in + * every column, the publisher stack is competitive. A regression + * — Kotlin's p99 ballooning to 3× Rust's, say — would be + * immediately visible in the printed output. + * + * Gated by `-DnestsHangInterop=true`; needs the cargo-built + * sidecars (`hang-publish`, `udp-loss-shim`) the + * `interopBuildSidecars` task produces. + */ +class AudioLatencyComparisonTest { + /** + * Skip cleanly when run as part of the default `./gradlew test` + * suite — every test in this class drives the cargo-built + * `hang-publish` sidecar and the local moq-relay harness, both + * of which only exist with `-DnestsHangInterop=true`. Without + * this, the pre-push hook (which runs the whole suite) fails on + * `NativeMoqRelayHarness.shared()` with an `IllegalStateException`. + * Mirrors the pattern in `HangInteropTest`. + */ + @BeforeTest + fun assumeHangInteropEnabled() { + NativeMoqRelayHarness.assumeHangInterop() + } + + @Test + fun clean_path_pacing_and_one_way_latency() = runComparison(scenario = "clean", lossRate = 0.0f) + + @Test + fun under_5pct_packet_loss_pacing_and_one_way_latency() = runComparison(scenario = "loss-5pct", lossRate = 0.05f) + + @Test + fun under_10pct_packet_loss_pacing_and_one_way_latency() = runComparison(scenario = "loss-10pct", lossRate = 0.10f) + + @Test + fun under_20pct_packet_loss_pacing_and_one_way_latency() = runComparison(scenario = "loss-20pct", lossRate = 0.20f) + + @Test + fun under_30pct_packet_loss_pacing_and_one_way_latency() = runComparison(scenario = "loss-30pct", lossRate = 0.30f) + + /** + * Single-scenario driver. Both `@Test` entry points are thin + * wrappers so JUnit reports them as separate cases (and the + * NativeMoqRelayHarness shutdown hook + sidecar reuse stay + * shared across them). + */ + private fun runComparison( + scenario: String, + lossRate: Float, + ) = runBlocking { + val harness = NativeMoqRelayHarness.shared() + + val hostSigner: NostrSigner = NostrSignerInternal(KeyPair()) + val kotlinSpeakerSigner: NostrSigner = NostrSignerInternal(KeyPair()) + val rustSpeakerSigner: NostrSigner = NostrSignerInternal(KeyPair()) + val kotlinPubkey = kotlinSpeakerSigner.pubKey + val rustPubkey = rustSpeakerSigner.pubKey + + val room = + NestsRoomConfig( + authBaseUrl = "", + endpoint = harness.relayUrl, + hostPubkey = hostSigner.pubKey, + roomId = "lat-$scenario-${UUID.randomUUID()}", + ) + + val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val activeShims = mutableListOf() + var publishProc: Process? = null + var rustLogScraper: Job? = null + var kotlinSpeaker: com.vitorpamplona.nestsclient.NestsSpeaker? = null + + try { + val transport = + QuicWebTransportFactory( + parentScope = pumpScope, + certificateValidator = PermissiveCertificateValidator(), + ) + + // ---- Loss path setup. When [lossRate] > 0, stand up two + // udp-loss-shim instances — one per publisher — each + // forwarding 1:1 to the moq-relay's UDP port modulo the + // configured drop fraction. Two shims (not one) because + // the shim latches a single client on first datagram, so a + // shared shim would silently swallow whichever publisher + // spoke second. The listener stays on the direct relay + // path: we want loss between publisher and relay only. + val relayUdpPort = parseUdpPortFrom(harness.relayUrl) + val (kotlinEndpoint, rustEndpoint) = + if (lossRate > 0f) { + val (kotlinShim, kotlinShimPort) = + spawnLossShim(harness, relayUdpPort, lossRate, "kotlin") + val (rustShim, rustShimPort) = + spawnLossShim(harness, relayUdpPort, lossRate, "rust") + activeShims += kotlinShim + activeShims += rustShim + "https://127.0.0.1:$kotlinShimPort" to "https://127.0.0.1:$rustShimPort" + } else { + harness.relayUrl to harness.relayUrl + } + + // ---- Listener first. Use the RECONNECTING wrapper so its + // inner re-issuance pump retries SUBSCRIBE with exponential + // backoff (100 → 200 → 400 → 800 → 1 000 ms) — without it, + // a SUBSCRIBE against an unannounced broadcast hits + // "subscribe stream FIN before reply" and never recovers. + // With it, subscribing BEFORE either publisher is up is + // safe; the retry catches whichever ANNOUNCE arrives first. + val listener = + connectReconnectingNestsListener( + httpClient = LatencyTestNestsClient, + transport = transport, + scope = pumpScope, + room = room, + signer = hostSigner, + tokenRefreshAfterMs = 0L, + ) + withTimeoutOrNull(5_000L) { + listener.state.first { it is NestsListenerState.Connected } + } ?: error("listener never reached Connected within 5 s") + val kotlinSub = listener.subscribeSpeaker(kotlinPubkey) + val rustSub = listener.subscribeSpeaker(rustPubkey) + + val kotlinArrivals = ConcurrentHashMap() + val rustArrivals = ConcurrentHashMap() + // `MoqLiteNestsListener` synthesises [MoqObject.objectId] as + // a per-SUBSCRIPTION monotonic counter (not group-relative), + // so it already maps directly to the publisher's absolute + // frame index — no `groupId * FRAMES_PER_GROUP` arithmetic + // needed, and doing it anyway would skip every frame past + // the first group on each side and produce nonsensical + // negative one-way latencies from misaligned matching. + val kotlinCollect = + pumpScope.launch { + kotlinSub.objects.collect { obj -> + kotlinArrivals[obj.objectId] = FrameArrival(System.nanoTime(), nowEpochMicros()) + } + } + val rustCollect = + pumpScope.launch { + rustSub.objects.collect { obj -> + rustArrivals[obj.objectId] = FrameArrival(System.nanoTime(), nowEpochMicros()) + } + } + + // Start Rust process FIRST: spawn is the slow setup, and + // its --log-send-times stdout drives a background scraper + // that we need running before frames begin to flow. + val rustT0 = System.nanoTime() + val rustSendTimesUs = ConcurrentHashMap() + publishProc = + ProcessBuilder( + harness.hangPublishBin().toString(), + "--relay-url", + "$rustEndpoint/${room.moqNamespace()}", + "--broadcast", + rustPubkey, + "--track-name", + "audio/data", + "--duration", + (DURATION_MS / 1_000L).toString(), + "--freq-hz", + "440", + "--log-send-times", + ).also { it.environment()["RUST_LOG"] = "info" } + // Stdout is the SEND-line channel; keep stderr + // separate so RUST_LOG messages don't interleave + // with the parser's per-frame state. + .redirectErrorStream(false) + .start() + rustLogScraper = + pumpScope.launch(Dispatchers.IO) { + publishProc!!.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + val match = SEND_LINE_REGEX.matchEntire(line) ?: return@forEach + val frame = match.groupValues[1].toLong() + val sendUs = match.groupValues[2].toLong() + rustSendTimesUs[frame] = sendUs + } + } + } + + // Kotlin speaker. Override framesPerGroup to match the Rust + // sidecar's default (5) — the test is about publisher-stack + // fidelity, not about which stack ships which default group + // size, so matching the knob explicitly makes the + // comparison apples-to-apples. + val kotlinT0 = System.nanoTime() + val kotlinSendTimesUs = ConcurrentHashMap() + val frameCounter = AtomicLong(0L) + val kotlinPublishRoom = room.copy(endpoint = kotlinEndpoint) + kotlinSpeaker = + connectNestsSpeaker( + httpClient = LatencyTestNestsClient, + transport = transport, + scope = pumpScope, + room = kotlinPublishRoom, + signer = kotlinSpeakerSigner, + speakerPubkeyHex = kotlinPubkey, + captureFactory = { SineWaveAudioCapture(freqHz = 440) }, + encoderFactory = { TimestampingOpusEncoder(JvmOpusEncoder(), frameCounter, kotlinSendTimesUs) }, + framesPerGroup = FRAMES_PER_GROUP.toInt(), + ) + kotlinSpeaker.startBroadcasting() + + delay(DURATION_MS + COLLECTION_HEADROOM_MS) + + // Tear down publishers BEFORE collectors so late frames + // (sitting in QUIC stream buffers — they arrive a few ms + // after speaker.close) still land in `kotlinArrivals` / + // `rustArrivals`. Cancelling the collectors first would + // truncate the tail of the sample. + runCatching { kotlinSpeaker.close() } + runCatching { publishProc.destroy() } + delay(300) + kotlinCollect.cancel() + rustCollect.cancel() + rustLogScraper.cancel() + + val kotlinStats = + FrameStats.compute( + label = "Kotlin speaker", + startNanos = kotlinT0, + arrivals = kotlinArrivals, + sendTimesUs = kotlinSendTimesUs, + ) + val rustStats = + FrameStats.compute( + label = "Rust hang-publish", + startNanos = rustT0, + arrivals = rustArrivals, + sendTimesUs = rustSendTimesUs, + ) + + // Print so a CI run captures the comparison even when the + // test passes — that's the whole point of the test (it's + // diagnostic, not regression-gating). + println("===== Audio publisher comparison: $scenario (loss=${"%.0f".format(lossRate * 100)}%) =====") + println(kotlinStats) + println(rustStats) + println("=".repeat(70)) + + // Frame-count floor scales with loss. QUIC streams are + // reliable so loss surfaces mainly as latency, but at high + // loss rates the retransmit + congestion-control feedback + // loop can eat into the test window, so the floor relaxes. + // The intent of the assertion is to catch a CATASTROPHIC + // failure (publisher never started, transport collapsed, + // listener subscribe never landed) — NOT to enforce a per- + // loss-rate delivery SLO. Reporting in the printed stats + // is the source of truth for delivery quality. + val frameFloor = + if (lossRate <= 0f) { + MIN_FRAMES_CLEAN_PATH + } else { + maxOf(MIN_FRAMES_HARD_FLOOR, ((1f - 3f * lossRate) * EXPECTED_FRAMES).toInt()) + } + assertTrue( + kotlinStats.frames >= frameFloor, + "Kotlin speaker delivered only ${kotlinStats.frames} frames in the " + + "$scenario scenario, expected ≥ $frameFloor of $EXPECTED_FRAMES — " + + "publisher stalled or the relay dropped the audio track. " + + "Stats:\n$kotlinStats", + ) + assertTrue( + rustStats.frames >= frameFloor, + "Rust hang-publish delivered only ${rustStats.frames} frames in the " + + "$scenario scenario, expected ≥ $frameFloor of $EXPECTED_FRAMES — " + + "sidecar process never made it to steady state. Stats:\n$rustStats", + ) + // Inter-arrival pacing is only meaningful on the clean + // path. Under packet loss, the sender's pacing intent is + // preserved but retransmissions land in bursts, so the + // listener sees clusters even though the publisher paced + // perfectly. Assert pacing on clean only; report it for + // loss but don't gate on it. + if (lossRate == 0f) { + assertTrue( + kotlinStats.medianInterArrivalMs in PACING_MEDIAN_OK_MS, + "Kotlin speaker median inter-arrival ${kotlinStats.medianInterArrivalMs} ms " + + "outside the sane window $PACING_MEDIAN_OK_MS — the publisher's " + + "not pacing at all (just bursting frames into the relay).", + ) + assertTrue( + rustStats.medianInterArrivalMs in PACING_MEDIAN_OK_MS, + "Rust hang-publish median inter-arrival ${rustStats.medianInterArrivalMs} ms " + + "outside the sane window $PACING_MEDIAN_OK_MS — sidecar's pacing " + + "loop is broken (or the relay is queueing).", + ) + } + } finally { + // Always best-effort tear down sidecars + scope, even when + // the test bails partway (assertion / listener-never- + // Connected / sidecar failed to spawn). pumpScope cancel + // cascades to the collectors and the rust-log scraper. + runCatching { kotlinSpeaker?.close() } + runCatching { publishProc?.destroy() } + pumpScope.coroutineContext[Job]?.cancel() + activeShims.forEach { runCatching { it.destroy() } } + } + } + + /** + * Find a free UDP port by binding ephemeral and immediately + * releasing; the kernel briefly reserves the same port for our + * caller via [SO_REUSEADDR]-style handoff. Brief race window + * before the shim binds it back — fine for tests, would NOT be + * fine for production. + */ + private fun pickFreeUdpPort(): Int = DatagramSocket(0).use { it.localPort } + + /** + * Spawn a `udp-loss-shim` instance binding an ephemeral local + * UDP port, forwarding to [upstreamPort] (moq-relay's UDP + * listener), with [lossRate] applied independently to each + * direction. Returns the process AND the bound listen port so + * the caller can construct the publisher's HTTPS URL. + * + * Blocks briefly for the shim to bind; without a delay, the + * publisher's first packet would race the shim's listen socket + * and land in the kernel's drop counter. + */ + private fun spawnLossShim( + harness: NativeMoqRelayHarness, + upstreamPort: Int, + lossRate: Float, + tag: String, + ): Pair { + val listenPort = pickFreeUdpPort() + val proc = + ProcessBuilder( + harness.udpLossShimBin().toString(), + "--listen", + "127.0.0.1:$listenPort", + "--upstream", + "127.0.0.1:$upstreamPort", + "--loss-rate", + lossRate.toString(), + ).redirectErrorStream(true) + .also { it.environment()["RUST_LOG"] = "info" } + .start() + // Give the shim ~150 ms to bind. The shim logs "udp-loss-shim ready" + // at INFO once it's listening; tailing for that line would be + // more deterministic, but adds a reader thread per shim for a + // one-shot signal — a fixed sleep is fine for tests. + Thread.sleep(150) + check(proc.isAlive) { + "udp-loss-shim ($tag) exited immediately on listen=$listenPort upstream=$upstreamPort " + + "loss=$lossRate; stdout=${proc.inputStream.bufferedReader().readText()}" + } + return proc to listenPort + } + + /** + * Pull the UDP port out of the harness's `https://127.0.0.1:` + * URL. The harness's moq-relay listens on both TCP (HTTP/WebTransport + * handshake — irrelevant here) and UDP (QUIC) on the same port, so + * the URL's port suffix is what udp-loss-shim's `--upstream` needs. + */ + private fun parseUdpPortFrom(relayUrl: String): Int { + val match = Regex("https://[^:/]+:(\\d+)").find(relayUrl) + return checkNotNull(match) { "could not parse port from relay URL '$relayUrl'" } + .groupValues[1] + .toInt() + } + + /** + * Wall-clock microseconds since `UNIX_EPOCH`, matching what the + * Rust sidecar's `SystemTime::now().duration_since(UNIX_EPOCH)` + * emits. `Instant.now()` on the JVM reads `CLOCK_REALTIME`, + * which is the same source — the two values can be compared + * directly when both processes run on the same host. + */ + private fun nowEpochMicros(): Long { + val i = Instant.now() + return i.epochSecond * 1_000_000L + i.nano / 1_000L + } + + /** + * Captured arrival point for a single frame. `nanoTime` is the + * JVM-monotonic anchor used for inter-arrival deltas (it doesn't + * jump on NTP step). `epochMicros` is wall-clock, used to pair + * with the publisher's send time and compute one-way latency. + */ + private data class FrameArrival( + val nanoTime: Long, + val epochMicros: Long, + ) + + /** + * Per-publisher pacing + latency summary. Inter-arrival deltas + * are computed BETWEEN consecutive received frames in + * publisher-frame order (so out-of-order delivery doesn't skew + * the result); one-way latency is `arrival_us - send_us` per + * matched frame, in ms. + */ + private data class FrameStats( + val label: String, + val frames: Int, + val matchedFrames: Int, + val timeToFirstFrameMs: Double, + val medianInterArrivalMs: Double, + val p95InterArrivalMs: Double, + val p99InterArrivalMs: Double, + val maxInterArrivalMs: Double, + val medianLatencyMs: Double, + val p95LatencyMs: Double, + val p99LatencyMs: Double, + val maxLatencyMs: Double, + ) { + override fun toString(): String = + ( + "%-18s frames=%4d ttf=%6.1f ms" + + " inter-arrival p50/95/99/max=%5.2f/%5.2f/%5.2f/%6.2f ms" + + " one-way p50/95/99/max=%6.2f/%6.2f/%6.2f/%7.2f ms (n=%d)" + ).format( + label, + frames, + timeToFirstFrameMs, + medianInterArrivalMs, + p95InterArrivalMs, + p99InterArrivalMs, + maxInterArrivalMs, + medianLatencyMs, + p95LatencyMs, + p99LatencyMs, + maxLatencyMs, + matchedFrames, + ) + + companion object { + fun compute( + label: String, + startNanos: Long, + arrivals: Map, + sendTimesUs: Map, + ): FrameStats { + if (arrivals.isEmpty()) { + return FrameStats( + label = label, + frames = 0, + matchedFrames = 0, + timeToFirstFrameMs = Double.NaN, + medianInterArrivalMs = Double.NaN, + p95InterArrivalMs = Double.NaN, + p99InterArrivalMs = Double.NaN, + maxInterArrivalMs = Double.NaN, + medianLatencyMs = Double.NaN, + p95LatencyMs = Double.NaN, + p99LatencyMs = Double.NaN, + maxLatencyMs = Double.NaN, + ) + } + // Inter-arrival deltas — sort by publisher frame index + // so out-of-order delivery (parallel uni streams, loss + // retransmits) doesn't distort the histogram. + val sortedArrivals = + arrivals.entries.sortedBy { it.key }.map { it.value } + val ttfMs = (sortedArrivals.first().nanoTime - startNanos) / 1_000_000.0 + val deltasMs = + if (sortedArrivals.size < 2) { + DoubleArray(0) + } else { + DoubleArray(sortedArrivals.size - 1).also { d -> + for (i in 1 until sortedArrivals.size) { + d[i - 1] = (sortedArrivals[i].nanoTime - sortedArrivals[i - 1].nanoTime) / 1_000_000.0 + } + } + } + deltasMs.sort() + // One-way latency — only computable for frames with + // both a send_t AND an arrival_t. The intersection + // size is the `matchedFrames` count we report. + val latenciesMs = + arrivals.entries + .mapNotNull { (frame, arr) -> + val sendUs = sendTimesUs[frame] ?: return@mapNotNull null + (arr.epochMicros - sendUs) / 1_000.0 + }.toDoubleArray() + latenciesMs.sort() + return FrameStats( + label = label, + frames = arrivals.size, + matchedFrames = latenciesMs.size, + timeToFirstFrameMs = ttfMs, + medianInterArrivalMs = percentile(deltasMs, 0.50), + p95InterArrivalMs = percentile(deltasMs, 0.95), + p99InterArrivalMs = percentile(deltasMs, 0.99), + maxInterArrivalMs = if (deltasMs.isEmpty()) Double.NaN else deltasMs.last(), + medianLatencyMs = percentile(latenciesMs, 0.50), + p95LatencyMs = percentile(latenciesMs, 0.95), + p99LatencyMs = percentile(latenciesMs, 0.99), + maxLatencyMs = if (latenciesMs.isEmpty()) Double.NaN else latenciesMs.last(), + ) + } + + /** + * Linear-interpolated percentile (R-7 / "type 7" — the + * default in NumPy, R, and Excel's `PERCENTILE`). Input + * MUST already be sorted ascending; we sort the array + * before this is called. Returns `NaN` on empty input + * rather than throwing, so the [FrameStats] dump for a + * fully-failed publisher still renders. + */ + private fun percentile( + sorted: DoubleArray, + q: Double, + ): Double { + if (sorted.isEmpty()) return Double.NaN + if (sorted.size == 1) return sorted[0] + val rank = q * (sorted.size - 1) + val lo = rank.toInt() + val frac = rank - lo + val hi = (lo + 1).coerceAtMost(sorted.size - 1) + return sorted[lo] + frac * (sorted[hi] - sorted[lo]) + } + } + } + + /** + * Delegating [OpusEncoder] that records `Instant.now()` per + * `encode` call into [sendTimesUs], keyed by an internal + * monotonic frame counter shared across the broadcaster's + * lifetime. `encode` is invoked once per PCM frame, immediately + * before the encoded Opus bytes are queued onto the publisher's + * outbound channel by `NestMoqLiteBroadcaster` — so the + * captured timestamp lines up with the "moment the frame + * entered the publisher's outbound buffer" anchor the Rust + * sidecar uses. + */ + private class TimestampingOpusEncoder( + private val inner: OpusEncoder, + private val frameCounter: AtomicLong, + private val sendTimesUs: MutableMap, + ) : OpusEncoder { + override fun encode(pcm: ShortArray): ByteArray { + val frame = frameCounter.getAndIncrement() + sendTimesUs[frame] = nowEpochMicrosStatic() + return inner.encode(pcm) + } + + override fun release() = inner.release() + + companion object { + // Static-scope copy so the encoder doesn't pull the + // enclosing test class in (constructed BY the + // broadcaster, lifetime independent of the @Test method). + private fun nowEpochMicrosStatic(): Long { + val i = Instant.now() + return i.epochSecond * 1_000_000L + i.nano / 1_000L + } + } + } + + /** + * Bypass the NIP-98 auth handshake — the harness's moq-relay + * runs with `--auth-public ""`, which grants any path without a + * JWT. Same shape as the existing `ReverseStaticTokenNestsClient` + * in the other native-relay tests; they're file-private so we + * can't share. + */ + private object LatencyTestNestsClient : NestsClient { + override suspend fun mintToken( + room: NestsRoomConfig, + publish: Boolean, + signer: NostrSigner, + ): String = "" + } + + companion object { + /** Matches Rust `hang-publish`'s `--log-send-times` output line. */ + private val SEND_LINE_REGEX = Regex("""^SEND frame=(\d+) send_t_us=(\d+)$""") + + /** moq-lite / Opus frame cadence; matches `hang-publish`'s `FRAME_DURATION_US`. */ + private const val FRAME_MS = 20L + + /** + * Frames per moq-lite group, matched on both sides. + * `hang-publish` hard-codes 5; we override Kotlin's default + * (50) to 5 here so each side opens/closes a uni stream at + * the same cadence — the per-group transport cost is a + * publisher-stack property and matching it makes the rest + * of the comparison meaningful. + * + * `Long` so the `groupId * FRAMES_PER_GROUP + objectId` + * arithmetic in the collector lambdas matches + * `MoqObject.groupId`'s `Long` type without casts. + */ + private const val FRAMES_PER_GROUP = 5L + + /** Measurement window. 10 s = 500 frames per side is plenty for stable p95/p99. */ + private const val DURATION_MS = 10_000L + + /** Extra wall-clock after publishers stop, so the listener drains in-flight uni streams. */ + private const val COLLECTION_HEADROOM_MS = 1_500L + + private const val EXPECTED_FRAMES = (DURATION_MS / FRAME_MS).toInt() + + /** Clean-path delivery floor: 80 % of expected. */ + private const val MIN_FRAMES_CLEAN_PATH = (EXPECTED_FRAMES * 0.8).toInt() + + /** + * Absolute floor on delivered frames regardless of loss + * rate — anything under this is a catastrophic failure + * (publisher never started, transport collapsed). 50 frames + * ≈ 1 s of audio, which we should ALWAYS clear in a 10 s + * test even with aggressive loss. + */ + private const val MIN_FRAMES_HARD_FLOOR = 50 + + /** + * Median inter-arrival should sit very close to [FRAME_MS] = 20 ms + * on the clean path. The window catches a publisher that's + * just bursting frames (median would collapse) without being + * so tight it false-fails on a loaded CI host. + */ + private val PACING_MEDIAN_OK_MS = 15.0..35.0 + } +} diff --git a/nestsClient/tests/hang-interop/hang-publish/src/main.rs b/nestsClient/tests/hang-interop/hang-publish/src/main.rs index 2fda89cb5a..4316bd9e91 100644 --- a/nestsClient/tests/hang-interop/hang-publish/src/main.rs +++ b/nestsClient/tests/hang-interop/hang-publish/src/main.rs @@ -7,7 +7,8 @@ //! the Rust → Amethyst direction. See //! `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`. -use std::time::Duration; +use std::io::Write; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, anyhow}; use bytes::Bytes; @@ -87,6 +88,25 @@ struct Args { /// exercise the Kotlin listener's publisher-cycle handling. #[arg(long, default_value_t = 0)] reconnect_after_ms: u64, + + /// If set, emit one line per frame to stdout, immediately before + /// the frame is handed to the moq-lite group producer: + /// + /// `SEND frame= send_t_us=` + /// + /// Used by `AudioLatencyComparisonTest` to pair publisher send + /// time with the listener's arrival time and compute one-way + /// latency. `send_t_us` is the wall-clock microseconds since + /// `UNIX_EPOCH` (CLOCK_REALTIME on linux / macOS), which matches + /// what the JVM's `Instant.now()` reads on the listener side — + /// the two clocks share the same source when both processes run + /// on the same host. + /// + /// Disabled by default so existing interop scenarios (I7, etc.) + /// keep their plain stderr-only RUST_LOG output and don't have + /// to filter through 50 frame-tag lines per second on stdout. + #[arg(long, default_value_t = false)] + log_send_times: bool, } #[tokio::main] @@ -364,6 +384,37 @@ async fn publish_cycle( } let g = group.as_mut().expect("group always Some after init"); + if args.log_send_times { + // Read SystemTime AS LATE AS POSSIBLE before the moq-lite + // group write so the captured `send_t_us` reflects "moment + // the frame entered the publisher's outbound buffer". The + // group write is non-blocking (the per-stream send queue + // does the actual QUIC work asynchronously) so this is the + // closest deterministic anchor we have to "send time" from + // application code. Unwrap on the duration_since: the + // system clock running before UNIX_EPOCH would be a + // catastrophic environment failure, not a test condition. + let send_t_us = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock predates UNIX_EPOCH") + .as_micros(); + // Bypass tracing — RUST_LOG goes to stderr by config and + // the test parses stdout. Lock + flush per line because + // Rust's `Stdout` is fully-buffered when not a TTY (which + // is exactly our case here — `redirectErrorStream(true)` + // on the JVM side makes stdout a pipe), so a passive + // `writeln!` would batch frames into ~8 KB chunks and the + // parser would see them in bursts instead of as a live + // stream. Per-line flush is cheap (≤ 50 lines/s/publisher). + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = writeln!( + handle, + "SEND frame={} send_t_us={}", + *frame_no, send_t_us + ); + let _ = handle.flush(); + } frame.encode(g).context("encode hang frame into group")?; frames_in_group += 1; if frames_in_group == FRAMES_PER_GROUP { diff --git a/nestsClient/tests/hang-interop/linux-bench/Dockerfile b/nestsClient/tests/hang-interop/linux-bench/Dockerfile new file mode 100644 index 0000000000..b4117815e6 --- /dev/null +++ b/nestsClient/tests/hang-interop/linux-bench/Dockerfile @@ -0,0 +1,36 @@ +# Linux audio-latency benchmark image for AudioLatencyComparisonTest. +# Mirrors the macOS host toolchain (JDK 21, rustc 1.95, cmake 3) so the +# Kotlin vs Rust hang-publish comparison runs end-to-end against the +# same set of sidecars, just on a different OS. + +FROM eclipse-temurin:21-jdk-jammy + +# rustup pulls 1.95.0 explicitly. cmake/build-essential/pkg-config/libssl-dev +# are needed by transitive *-sys crates (audiopus_sys bundles libopus and +# builds it via cmake; rustls→aws-lc-sys also uses cmake). +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates git \ + build-essential pkg-config cmake \ + libssl-dev \ + bash \ + && rm -rf /var/lib/apt/lists/* + +ENV RUSTUP_HOME=/opt/rustup \ + CARGO_HOME=/opt/cargo \ + PATH=/opt/cargo/bin:$PATH + +# Pin rustc 1.95.0 to match the host. moq-relay v0.10.25's transitive +# Cargo.lock requires rustc >= 1.95 (constant_time_eq@0.4.3). +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh \ + && sh /tmp/rustup.sh -y --default-toolchain 1.95.0 --profile minimal \ + && rm /tmp/rustup.sh \ + && rustc --version && cargo --version + +# CMake 4.x stripped support for very old `cmake_minimum_required` floors, +# which audiopus_sys's bundled libopus CMakeLists trips over. Jammy ships +# cmake 3.22 (still affected by some old crate `*-sys` requirements but +# tolerates the policy). Set the env var defensively to match the +# gradle Exec tasks. +ENV CMAKE_POLICY_VERSION_MINIMUM=3.5 + +WORKDIR /repo diff --git a/nestsClient/tests/hang-interop/linux-bench/run.sh b/nestsClient/tests/hang-interop/linux-bench/run.sh new file mode 100755 index 0000000000..a06fda8f8c --- /dev/null +++ b/nestsClient/tests/hang-interop/linux-bench/run.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Run AudioLatencyComparisonTest inside a Linux container so the numbers +# match what a Linux production host would see — different OS, same JDK, +# same rustc, same sidecar binaries. The mac host's `nestsClient/build` +# and cargo `target/` are NOT touched: a fresh tarball of the repo is +# unpacked into the container's filesystem, with two symlinks pointing +# the gradle and cargo output dirs at named volumes so successive runs +# don't pay full rebuild cost. +# +# Prereqs: +# - docker desktop (or any docker engine) on the host +# - the test itself is gated by -DnestsHangInterop=true; this script +# passes that flag. +# +# Usage from repo root: +# ./nestsClient/tests/hang-interop/linux-bench/run.sh +# +# Output: stats table on stdout. JUnit XML lands in +# nestsClient/tests/hang-interop/linux-bench/out/. +# +# First-run cost: ~7-15 min (image build + full cargo + moq-relay +# install + gradle compile). Cached runs: ~3-5 min. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd) +PLATFORM="${PLATFORM:-linux/arm64}" +IMAGE_TAG="amethyst-linux-bench:$(echo "$PLATFORM" | tr / -)" +OUT_DIR="$SCRIPT_DIR/out" + +# Cache volumes — named per-platform so arm64 and amd64 don't share +# binaries that wouldn't run. Stored under the user cache dir so they +# survive across repo clones / git clean. +CACHE_ROOT="${LINUX_BENCH_CACHE:-$HOME/.cache/amethyst-linux-bench}" +CARGO_REGISTRY_VOL="$CACHE_ROOT/$PLATFORM/cargo-registry" +CARGO_TARGET_VOL="$CACHE_ROOT/$PLATFORM/cargo-target" +GRADLE_BUILD_VOL="$CACHE_ROOT/$PLATFORM/gradle-build" +GRADLE_CACHE_VOL="$CACHE_ROOT/$PLATFORM/gradle-cache" +mkdir -p "$CARGO_REGISTRY_VOL" "$CARGO_TARGET_VOL" "$GRADLE_BUILD_VOL" "$GRADLE_CACHE_VOL" "$OUT_DIR" + +echo ">>> building image $IMAGE_TAG ($PLATFORM)" +docker build --platform "$PLATFORM" -t "$IMAGE_TAG" "$SCRIPT_DIR" + +echo ">>> running benchmark" +docker run --rm --platform "$PLATFORM" \ + -v "$REPO_ROOT":/src:ro \ + -v "$OUT_DIR":/out \ + -v "$CARGO_REGISTRY_VOL":/opt/cargo/registry \ + -v "$CARGO_TARGET_VOL":/persistent-cargo-target \ + -v "$GRADLE_BUILD_VOL":/persistent-gradle-build \ + -v "$GRADLE_CACHE_VOL":/root/.gradle \ + -e GRADLE_OPTS="-Xmx4g" \ + "$IMAGE_TAG" \ + bash -c ' + set -e + rm -rf /repo; mkdir /repo + # Filter out build / cache dirs at copy time so the macOS-host + # state never lands in the Linux container. + tar -C /src --exclude=./.git --exclude="*/build" --exclude="*/.gradle" --exclude="*/target" --exclude="*/node_modules" -cf - . \ + | tar -C /repo -xf - + # Redirect both gradle build/ and cargo target/ at named + # volumes so repeated runs reuse compiled artefacts. The + # default repo-relative paths still resolve via the symlinks, + # so the existing NativeMoqRelayHarness sidecar lookups stay + # unchanged. + rm -rf /repo/nestsClient/tests/hang-interop/target + ln -s /persistent-cargo-target /repo/nestsClient/tests/hang-interop/target + rm -rf /repo/nestsClient/build + ln -s /persistent-gradle-build /repo/nestsClient/build + cd /repo + echo "=== toolchain ===" + rustc --version; cmake --version | head -1; java -version 2>&1 | head -1 + ./gradlew :nestsClient:jvmTest -DnestsHangInterop=true \ + --tests "com.vitorpamplona.nestsclient.interop.native.AudioLatencyComparisonTest" \ + 2>&1 | tail -10 + # Surface the test report XML to the host so we can grep + # printed stats without rebuilding the image. + rm -f /out/*.xml + cp /repo/nestsClient/build/test-results/jvmTest/TEST-*AudioLatencyComparisonTest*.xml /out/ + ' + +echo "" +echo ">>> stats" +for f in "$OUT_DIR"/TEST-*.xml; do + # Pull the CDATA-wrapped stdout block out without needing python on + # the host — sed range, then strip the CDATA wrappers. + sed -n '/<\/system-out>/p' "$f" \ + | sed -e 's/^<\/system-out>$//' +done diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index f1b7f6721d..e2388b8310 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -370,7 +370,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.08.0", + version = "1.09.2", ) // Configure publishing to Maven Central diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt index b48102b532..2f6108fd09 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt @@ -26,6 +26,8 @@ fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach) fun Event.countHashtags() = tags.countHashtags() +fun Event.hasMoreHashtagsThan(limit: Int) = tags.hasMoreHashtagsThan(limit) + fun Event.hasHashtags() = tags.hasHashtags() fun Event.hashtags() = tags.hashtags() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt index 6ad153af7c..f77b15e7b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt @@ -46,6 +46,13 @@ class HashtagTag { return tag[1] } + fun parseLowercase(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].lowercase() + } + fun assemble(name: String) = arrayOf(TAG_NAME, name) fun assembleDualCase(name: String): List> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt index 3d07717a52..8d9a7b458a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt @@ -38,8 +38,29 @@ fun TagArray.hashtags() = this.mapNotNull(HashtagTag::parse) fun TagArray.countHashtags() = this.count(HashtagTag::isTagged) +fun TagArray.hasMoreHashtagsThan(limit: Int): Boolean { + val count = this.count(HashtagTag::isTagged) + return count > limit && this.countUnique(count, HashtagTag::parseLowercase) > limit +} + fun TagArray.isTaggedHash(hashtag: String) = this.isTagged(HashtagTag.TAG_NAME, hashtag, true) fun TagArray.isTaggedHashes(hashtags: Set) = this.isAnyLowercaseTagged(HashtagTag.TAG_NAME, hashtags) fun TagArray.firstIsTaggedHashes(hashtags: Set) = this.firstAnyLowercaseTaggedValue(HashtagTag.TAG_NAME, hashtags) + +public inline fun Array.countUnique( + size: Int, + transform: (T) -> U?, +): Int { + // Pre-allocate hash set to avoid frequent resizing for larger arrays + val seen = HashSet(size) + var count = 0 + for (element in this) { + val value = transform(element) + if (value != null && seen.add(value)) { + ++count + } + } + return count +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt index 537210e8f7..09027f1736 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt @@ -47,14 +47,26 @@ data class Nip05Id( fun toDomainUrl(): String = domainUrl(domain) companion object { + // NIP-05 localpart: dot-separated atoms of [a-z0-9_-]. Forbids leading, + // trailing, and consecutive dots (NIP-05 + RFC 5321 local-part rules). + private val LOCAL_PART_REGEX = Regex("^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$") + + // Hostname: dot-separated labels of [a-z0-9-], no leading/trailing hyphen, + // require at least one dot so single-label garbage (e.g. "s!ayer") is rejected. + private val DOMAIN_REGEX = + Regex("^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$") + fun parse(nip05address: String): Nip05Id? { val parts = nip05address.trim().lowercase().split("@") - - return when (parts.size) { - 2 -> Nip05Id(parts[0], parts[1]) - 1 -> Nip05Id(parts[0], "_") - else -> null - } + if (parts.size != 2) return null + val name = parts[0] + val domain = parts[1] + if (!LOCAL_PART_REGEX.matches(name)) return null + if (!DOMAIN_REGEX.matches(domain)) return null + // Reject IP literals — NIP-05 expects a hostname, and a digits-only + // TLD is the cheapest way to tell them apart from real domains. + if (domain.substringAfterLast('.').all { it.isDigit() }) return null + return Nip05Id(name, domain) } fun assemble( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 17b51a2ab1..8bd085449d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -181,6 +181,9 @@ class CommentEvent( fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) } + /** True when the comment points at an external identifier (`I` tag), e.g. a hashtag, geohash or url. */ + fun hasRootScopeIdentifier() = tags.any { RootIdentifierTag.match(it) } + fun hasRootScopeKind(kind: String) = tags.any(RootKindTag::isKind, kind) fun hasReplyScopeKind(kind: String) = tags.any(ReplyKindTag::isKind, kind) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index b72358f75a..2f9e17c34b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -94,6 +94,66 @@ class Nip05Test { assertNull(parsedNip05) } + @Test + fun `parse rejects empty localpart`() { + assertNull(Nip05Id.parse("@example.com")) + } + + @Test + fun `parse rejects empty domain`() { + assertNull(Nip05Id.parse("alice@")) + } + + @Test + fun `parse rejects domain without a dot`() { + assertNull(Nip05Id.parse("alice@localhost")) + } + + @Test + fun `parse rejects domain with illegal character`() { + assertNull(Nip05Id.parse("_@s!ayer")) + assertNull(Nip05Id.parse("@s!ayer")) + } + + @Test + fun `parse rejects localpart with illegal character`() { + assertNull(Nip05Id.parse("al!ce@example.com")) + assertNull(Nip05Id.parse("alice space@example.com")) + } + + @Test + fun `parse rejects bare string without at-sign`() { + assertNull(Nip05Id.parse("alice")) + assertNull(Nip05Id.parse("example.com")) + } + + @Test + fun `parse rejects domain label with leading or trailing hyphen`() { + assertNull(Nip05Id.parse("alice@-example.com")) + assertNull(Nip05Id.parse("alice@example-.com")) + } + + @Test + fun `parse rejects localpart with leading trailing or consecutive dots`() { + assertNull(Nip05Id.parse(".alice@example.com")) + assertNull(Nip05Id.parse("alice.@example.com")) + assertNull(Nip05Id.parse("alice..bob@example.com")) + } + + @Test + fun `parse rejects IPv4 literal as domain`() { + assertNull(Nip05Id.parse("alice@192.168.1.1")) + assertNull(Nip05Id.parse("alice@8.8.8.8")) + } + + @Test + fun `parse accepts wildcard underscore localpart`() { + val nip05 = Nip05Id.parse("_@example.com") + assertNotNull(nip05) + assertEquals("_", nip05.name) + assertEquals("example.com", nip05.domain) + } + @Test fun `execute assemble url with valid value returns nip05 url`() { // given diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt index eec79ca775..67b01886fa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.nip06KeyDerivation import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals @@ -57,11 +56,4 @@ class Nip06CommonTest { val privateKeyHex21 = nip06.privateKeyFromMnemonic(menemonic1, 42).toHexKey() assertEquals("ad993054383da74e955f8b86346365b5ffd6575992e1de3738dda9f94407052b", privateKeyHex21) } - - @Test - @Ignore() - fun fromSeedNip06FromSnort() { - val privateKeyNsec = nip06.privateKeyFromMnemonic(snortTest).toHexKey() - assertEquals("nsec1ppw9ltr2x9qwg9a2qnmgv98tfruy2ejnja7me76mwmsreu3s8u2sscj5nt", privateKeyNsec) - } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt index 35d7e7e305..b7153320ae 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt @@ -107,16 +107,21 @@ fun buildQuicClientHello( ), ): TlsClientHello { val exts = - listOf( - TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)), - TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient()), - TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519()), - TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()), - TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)), - TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()), - TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns)), - TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams), - ) + buildList { + // RFC 6066 §3: omit the SNI extension entirely for IP-literal + // targets — a literal address is not a valid host_name and a + // strict server rejects the extension, then fails cert selection. + if (!isIpLiteralHostname(serverName)) { + add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName))) + } + add(TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient())) + add(TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519())) + add(TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms())) + add(TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey))) + add(TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe())) + add(TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns))) + add(TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams)) + } return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts) } @@ -162,7 +167,10 @@ fun buildResumptionClientHelloBytes( ): ByteArray { val exts = buildList { - add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName))) + // RFC 6066 §3: no SNI for IP-literal targets — see buildQuicClientHello. + if (!isIpLiteralHostname(serverName)) { + add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName))) + } add(TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient())) add(TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519())) add(TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms())) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt index 71c76fb2cd..89496d2e17 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt @@ -85,6 +85,32 @@ fun encodeServerNameExtension(hostName: String): ByteArray { return w.toByteArray() } +/** + * RFC 6066 §3: the SNI `host_name` field MUST carry a DNS hostname — literal + * IPv4 and IPv6 addresses are explicitly NOT permitted. Strict servers reject + * an IP-literal SNI outright (e.g. `rustls`: "Illegal SNI extension: ignoring + * IP address presented as hostname"), which leaves the server with no SNI to + * key certificate selection on and stalls the handshake. Callers that build a + * ClientHello for an IP-literal target must therefore omit the `server_name` + * extension entirely; this predicate is the gate for that. + * + * Returns true for dotted-quad IPv4 (`127.0.0.1`) and for anything containing + * a `:` or wrapped in `[...]` (every IPv6 literal; no DNS hostname can contain + * a colon). Anything else — including a bare `localhost` — is treated as a + * hostname and gets a normal SNI. + */ +fun isIpLiteralHostname(host: String): Boolean { + if (host.isEmpty()) return false + // IPv6 literals always contain ':' (optionally bracketed); DNS names never do. + if (host.startsWith('[') || host.contains(':')) return true + // IPv4 dotted-quad: exactly four decimal octets in 0..255. + val parts = host.split('.') + if (parts.size != 4) return false + return parts.all { p -> + p.isNotEmpty() && p.length <= 3 && p.all { it in '0'..'9' } && p.toInt() in 0..255 + } +} + /** Build the `supported_versions` extension carrying just TLS 1.3. */ fun encodeSupportedVersionsExtensionClient(): ByteArray { val w = QuicWriter() diff --git a/settings.gradle b/settings.gradle index b6eb94ee6a..d70eb627d3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,7 +36,6 @@ include ':benchmark' include ':quartz' include ':geode' include ':commons' -include ':ammolite' include ':quic' include ':nestsClient' include ':desktopApp'