diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md index 1c99e335a9..57a9e9a7d0 100644 --- a/.claude/skills/event-store-semantics/SKILL.md +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason). kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning `created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01 lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored -row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract: -exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked. +row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED` +(`duplicate: a newer version of this replaceable event is already stored`), which the relay +session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same +reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken +by lowest id; older re-inserts blocked but acknowledged as already covered. **STORE-W02 — addressable supersession.** Same as W01 with unique index `(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93016404e6..8ce5e4b4ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -268,9 +268,11 @@ front: sequentially: `for peer in aioquic picoquic quic-go quinn; do quic/interop/run-matrix.sh -s $peer; done`. Plan at `quic/interop/plans/2026-05-06-interop-runner.md`. -- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless - variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive - Marmot variant prompts a human to drive the Android UI. +- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every + relay-backed suite boots the embedded `amy serve` relay (geode) — no + external relay binary; only the Marmot suites additionally need `cargo` + for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to + drive the Android UI. If a change is documentation-only, UI-only, build-script-only, or otherwise cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 87d83bc8b6..0ea720bc54 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -399,9 +399,9 @@ dependencies { // Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source: // adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \ // -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver - debugImplementation("androidx.compose.runtime:runtime-tracing") - debugImplementation("androidx.tracing:tracing-perfetto:1.0.1") - debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1") + debugImplementation(libs.androidx.compose.runtime.tracing) + debugImplementation(libs.androidx.tracing.perfetto) + debugImplementation(libs.androidx.tracing.perfetto.binary) implementation(project(":quartz")) implementation(project(":commons")) @@ -599,6 +599,15 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) + // In-process Nostr relay (geode) so unit tests that drive a real + // NostrClient talk to an embedded relay instead of a public one. Same + // wiring quartz uses for its jvmAndroidTest source set: the engine, its + // testFixtures (RelayClientTest base, preload/publish helpers) and the + // JVM SQLite driver the in-memory EventStore needs on a host JVM. + testImplementation(project(":geode")) + testImplementation(testFixtures(project(":geode"))) + testImplementation(libs.androidx.sqlite.bundled.jvm) + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit.ktx) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt index 5ea16ce0b6..6c2e86b123 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getUnclippedBoundsInRoot -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -62,7 +62,8 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class PlaybackErrorOverlayFitTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt index 564715b2f9..c9f738a88b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.service.playback.composable.audioSquare @@ -50,7 +50,8 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class AudioPlayerBoxOverflowTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() private class Bounds { var top = 0f diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt index 54a1cf4624..b6ef4068fd 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/insets/ComposeImeInsetWedgeTest.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.WindowInsetsAnimationCompat @@ -70,7 +70,8 @@ import org.junit.Test * fallback would silently start reading a dead value too. */ class ComposeImeInsetWedgeTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() private val keyboardHeight = 957 diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt index 12b6892a0f..a2cbb273e7 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt deleted file mode 100644 index f4ed293a27..0000000000 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.commons.defaults.Constants -import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger -import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking -import okhttp3.OkHttpClient -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class EventSyncTest { - companion object { - val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl() - val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - - val rootClient = - OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) - .build() - val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } - } - - @Test - fun testSync() = - runBlocking { - val sync = - EventSync( - accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - relayDb = { - listOf(Constants.mom, Constants.nos) - }, - outboxTargets = { setOf(vitor) }, - inboxTargets = { setOf(vitor) }, - dmTargets = { setOf(vitor) }, - clientBuilder = { - NostrClient(socketBuilder, appScope) - }, - scope = appScope, - ) - - sync.runSync() - } - - @Test - fun testFiatjafSync() = - runBlocking { - val sync = - EventSync( - accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - relayDb = { listOf(fiatjaf) }, - outboxTargets = { setOf(vitor) }, - inboxTargets = { setOf(vitor) }, - dmTargets = { setOf(vitor) }, - clientBuilder = { - val newClient = NostrClient(socketBuilder, appScope) - val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false) - - val signer = NostrSignerInternal(KeyPair()) - - // Authenticates with relays. - val auth = - RelayAuthenticator( - newClient, - appScope, - signWithAllLoggedInUsers = { _, authTemplate, _ -> - listOf(signer.sign(authTemplate)) - }, - ) - - newClient - }, - scope = appScope, - ) - - sync.runSync() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index dcabff9b11..9735216a02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.LogLevel import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes import kotlin.time.DurationUnit @@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot { private const val STATE_DUMP_TAG = "STATE DUMP" fun debugState(context: Context) { + // Everything below is logged at DEBUG, and every argument is built eagerly (the + // eager Log.d overload, not the lambda one). Gate on the level that would drop + // those lines, because the arguments are the expensive part: nine materialising + // LargeCache.filter scans over notes/addressables/users/channels, plus three + // passes calling Event.countMemory() — which walks every tag of every cached + // event. MainActivity.onPause() calls this unconditionally, so without the gate + // a release build (minLevel WARN) did all of that on every backgrounding and + // threw the result away. Benchmark builds sit at INFO and paid it too. + if (Log.minLevel > LogLevel.DEBUG) return + val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024) val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024) val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt index f8123a60fd..63ccacf2a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException @Stable data class VanishEventItem( @@ -124,7 +125,10 @@ class VanishRequestsState( } ) } - } catch (_: Exception) { + } catch (e: Exception) { + // A cancelled check has no result. Reporting ERROR would show the relay as + // having answered badly when it was never asked. + if (e is CancellationException) throw e item.complianceResults.update { it + (relay to ComplianceStatus.ERROR) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 3f37ce3066..0844905ab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -166,7 +166,8 @@ class NamecoinSharedPreferences( } else { emptyList() } - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index 162cc22c9c..32ca910f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -60,6 +60,7 @@ import java.net.URLDecoder import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException /** * Fetches a resource URL on an applet's behalf — the applet has no direct network @@ -162,7 +163,10 @@ class NappletResourceFetcher( return failure(ERROR_BLOCKED, e.message) } catch (_: InterruptedIOException) { return failure(ERROR_TIMEOUT) - } catch (_: Exception) { + } catch (e: Exception) { + // Cancellation is not an upstream failure — do not report it to the + // napplet as one, and do not keep the request alive past it. + if (e is CancellationException) throw e return failure(ERROR_NETWORK) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index a7ac9a4d4c..f28d53b3a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -201,6 +201,9 @@ class EventNotificationConsumer( .onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } } .getOrNull() + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") private suspend fun dispatchForAccount( event: Event, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 6c66b6dc6b..41c4619596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -112,6 +112,9 @@ class NotificationDispatcher( // recipient account. // `internal` (was `private`) so the notification-kinds contract test // can pin the push-side kind set against the in-app feed's kind set. + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") internal val NOTIFICATION_KINDS: Set = setOf( // Direct-arrival diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt index 1c8b8196b6..086f230ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt @@ -81,6 +81,9 @@ object CodeNotification { event: GitPullRequestUpdateEvent, ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content) + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be rendered. + @Suppress("DEPRECATION") suspend fun notify( context: Context, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt index 37858696cb..e198c246f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt @@ -65,6 +65,7 @@ import org.webrtc.RtpSender import org.webrtc.VideoTrack import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException private const val TAG = "CallSession" private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000 @@ -429,6 +430,7 @@ class CallSession( try { withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e) return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt index ea0f43dffc..f8b68bce78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.sp +import androidx.core.graphics.withTranslation import com.vitorpamplona.amethyst.commons.richtext.MathParser import ru.noties.jlatexmath.JLatexMathDrawable @@ -130,12 +131,11 @@ fun LatexEquation( Canvas(modifier = equationModifier) { drawIntoCanvas { canvas -> val native = canvas.nativeCanvas - val checkpoint = native.save() // Position the icon's baseline on the text baseline within the padded box. - native.translate(0f, drawTopPx) - drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) - drawable.draw(native) - native.restoreToCount(checkpoint) + native.withTranslation(y = drawTopPx) { + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(this) + } } } if (trailing.isNotEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 5d9161d1e6..28a5d99dfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import android.graphics.ColorFilter import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter -import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -36,6 +35,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -159,7 +159,7 @@ fun LocationPreviewMap( remember(pinColor, pinEmoji) { if (pinColor != null && pinEmoji != null) { val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) - BitmapDrawable(context.resources, bitmap) + bitmap.toDrawable(context.resources) } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index ff2b559531..11a03b2d60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -118,6 +118,5 @@ open class UserFeedViewModel( override fun onCleared() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundler.cancel() - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c2cfdae907..d19db54b1e 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 @@ -2647,7 +2647,6 @@ class AccountViewModel( com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge .clear() feedStates.destroy() - super.onCleared() } fun loadMentions( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index ad6c0e879b..771037b0ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() { override fun onCleared() { stopObserving() stopWatching() - super.onCleared() } /** One decrypted observer telemetry frame rendered on the Observer tab. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt index e3f106f595..fe0d4e9ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() { override fun onCleared() { stopWatching() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index dd2284163e..4fd4f3509f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() { override fun onCleared() { liveJob?.cancel() liveJob = null - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt index 62ead90720..1f60812ea3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() { override fun onCleared() { stopWatching() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 4dc32ff52c..10982c18d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() { override fun onCleared() { stopWatching() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index 66ef72ab54..782330a58a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,11 +59,15 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale +import java.util.concurrent.ConcurrentHashMap // Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new // SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling. -private val MonthShortFormatter: DateTimeFormatter = - DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) +// Cached per locale rather than in a single val that captures the locale once: the month +// names have to follow a language the user changes while the app is running. +private val monthShortFormatters = ConcurrentHashMap() + +private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) } @Composable fun CalendarEventListCard( @@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) { Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate() } val day = localDate.dayOfMonth.toString() - val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() } + // LocalLocale rather than Locale.getDefault(): the latter is not observable, so a + // locale change while the app runs would leave the month name in the old language. + val locale = LocalLocale.current.platformLocale + val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() } Column( modifier = Modifier.size(width = 52.dp, height = 60.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt index 84e811a228..be0fdf4c00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt @@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() { override fun onCleared() { liveScanJob?.cancel() - super.onCleared() } fun toggle(address: Address) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt index ad768a3be6..81ba7c19cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -119,7 +119,6 @@ class MarmotFileUploader( // imprecisely. val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename) - val v2Cipher = cipher item.orchestrator.uploadEncrypted( uri = media.uri, @@ -142,21 +141,19 @@ class MarmotFileUploader( // compression and metadata stripping — because that is what the // key was derived from. val reference = - v2Cipher?.let { - EncryptedMediaReferenceV2( - locators = - listOf( - MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url), - ), - ciphertextSha256 = it.ciphertextSha256, - plaintextSha256 = it.plaintextSha256, - nonce = it.nonce, - mediaType = it.mediaType, - filename = filename, - dim = serverResult.fileHeader.dim?.toString(), - thumbhash = serverResult.fileHeader.thumbHash?.thumbhash, - ) - } + EncryptedMediaReferenceV2( + locators = + listOf( + MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url), + ), + ciphertextSha256 = cipher.ciphertextSha256, + plaintextSha256 = cipher.plaintextSha256, + nonce = cipher.nonce, + mediaType = cipher.mediaType, + filename = filename, + dim = serverResult.fileHeader.dim?.toString(), + thumbhash = serverResult.fileHeader.thumbHash?.thumbhash, + ) results.add( Mip04UploadResult( url = serverResult.url, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 4c7e45f109..863e3ff8ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -801,7 +801,6 @@ class ChatNewMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 4b51c95c6e..baab2e6279 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -1027,7 +1027,6 @@ open class ChannelNewMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 8966460217..893c23fb17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -132,7 +132,6 @@ class ChessViewModelNew( fun clearFocusedGame() = logic.clearFocusedGame() override fun onCleared() { - super.onCleared() logic.stopPolling() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 0488d076df..ed5ff94326 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -739,7 +739,6 @@ class LongFormPostViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 3075d4a6a7..32a137507f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -612,7 +612,6 @@ open class NewProductViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index af9a45edd5..cf79d6b90d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1977,7 +1977,6 @@ open class ShortNotePostViewModel : } override fun onCleared() { - super.onCleared() writingAssistant?.close() writingAssistant = null Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 3ba074d1ca..0bf353eea1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -320,7 +320,6 @@ class VoiceReplyViewModel : ViewModel() { override fun onCleared() { cancel() - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt index 3707b774fa..23078cb368 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -102,7 +102,6 @@ class AddToMusicPlaylistViewModel : ViewModel() { override fun onCleared() { liveScanJob?.cancel() - super.onCleared() } private suspend fun rescan() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 86ebb445bb..4eb9691cfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -597,7 +597,6 @@ open class NestNewMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 892c7be5c3..dccad9ac96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -139,6 +139,9 @@ class NotificationFeedFilter( AttestationRequestEvent.KIND, ) + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") val NOTIFICATION_KINDS = // Kinds that RENDER as a row on the Notifications tab. This is a // display gate over whatever is already in LocalCache — it plays no @@ -268,6 +271,9 @@ class NotificationFeedFilter( // Shared with EventNotificationConsumer so push notifications and the // in-app feed apply the same per-kind "is this event for me" rule. + // GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but + // events already on relays still arrive and still have to be routed. + @Suppress("DEPRECATION") fun tagsAnEventByUser( note: Note, authorHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 1fbfff94a6..d573313ee9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -660,7 +660,6 @@ class NewPublicMessageViewModel : } override fun onCleared() { - super.onCleared() Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index ecd7d1542a..cb0e4df73a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -196,6 +196,5 @@ class RelayFeedViewModel : override fun onCleared() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt index 0918f1a13b..0a9066af70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -43,10 +43,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException @@ -90,6 +92,9 @@ class EventSync( /** Maximum number of completed-relay entries kept in the activity log. */ const val MAX_ACTIVITY_LOG = 5000 + + /** Poll interval while waiting for the last forwarded events to be acknowledged. */ + const val OUTBOX_DRAIN_POLL_MS = 100L } // ------------------------------------------------------------------------- @@ -392,6 +397,13 @@ class EventSync( val sourceRelayOfEvent = ConcurrentHashMap() + // (event id, destination) pairs already counted as sent. The outbox is + // at-least-once: it writes an event as soon as the socket is ready and + // resends everything still unacknowledged when the connection finishes + // syncing, so one event can hit the same relay twice before its OK lands. + // The relay dedups the second copy; the counters must too. + val sentPairs = ConcurrentHashMap.newKeySet() + val runningState = SyncState.Running( relaysCompleted = 0, @@ -423,7 +435,12 @@ class EventSync( success: Boolean, ) { super.onSent(relay, cmdStr, cmd, success) - if (cmd is EventCmd) { + // `success` is "written to the socket", not "OK received". A write to a + // destination that is still connecting fails and the outbox resends it + // once the socket opens; counting the failed attempt too made every + // cold destination report one extra event sent. Likewise a successful + // resend of an unacknowledged event is the same send, not a second one. + if (cmd is EventCmd && success && sentPairs.add(cmd.event.id + relay.url.url)) { var hasSent = false if (outboxDedup.contains(cmd.event.id)) { @@ -586,6 +603,13 @@ class EventSync( }, ) + // `publish` is fire-and-forget through the client's outbox, and `use` closes + // the client as soon as this block returns. Without a drain, the events + // forwarded from the last page of the last relay are still waiting for a + // socket or an OK when the outbox is destroyed — the sync reports Done and + // silently never delivers them. Bounded by the same per-relay timeout. + awaitOutboxDrain(client, outboxDedup + inboxDedup + dmDedup) + _syncState.value = SyncState.Done( totalEventsReceived = runningState.eventsReceived.value, @@ -613,4 +637,22 @@ class EventSync( } } } + + /** + * Waits until no forwarded event in [ids] has a relay left in the client's outbox, or + * until [RELAY_TIMEOUT_MS] passes. Ids that drain are dropped from the working set so + * each poll only revisits what is still pending. + */ + private suspend fun awaitOutboxDrain( + client: INostrClient, + ids: Set, + ) { + val pending = ids.toMutableSet() + withTimeoutOrNull(RELAY_TIMEOUT_MS) { + while (pending.isNotEmpty()) { + pending.removeAll { client.pendingPublishRelaysFor(it).isNullOrEmpty() } + if (pending.isNotEmpty()) delay(OUTBOX_DRAIN_POLL_MS) + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index f2e7892a96..ecc970c800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -116,6 +116,5 @@ open class StringFeedViewModel( override fun onCleared() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundler.cancel() - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index f5115a3feb..aed382fcf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -180,7 +180,6 @@ open class NewHlsVideoViewModel : ViewModel() { } override fun onCleared() { - super.onCleared() currentJob?.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 532b8c925a..8a30b81dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -459,7 +459,6 @@ class ReloadMintViewModel : ViewModel() { // The pipeline runs on the AccountViewModel scope, not this VM's, so it would // outlive the screen — cancel it when the screen goes away. job?.cancel() - super.onCleared() } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index 6fde099208..f6dc022003 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -267,7 +267,6 @@ class TopUpMintViewModel : ViewModel() { // The pipeline runs on the AccountViewModel scope, not this VM's, so it would // outlive the screen — cancel it when the screen goes away. job?.cancel() - super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt index 2b3724c09b..9051c9a277 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt @@ -293,7 +293,6 @@ class CashuWalletWizardViewModel : ViewModel() { } override fun onCleared() { - super.onCleared() discovery?.cancel() } } diff --git a/amethyst/src/main/res/values/colors.xml b/amethyst/src/main/res/values/colors.xml index 611066da6b..1f20970d04 100644 --- a/amethyst/src/main/res/values/colors.xml +++ b/amethyst/src/main/res/values/colors.xml @@ -1,13 +1,7 @@ - #FFBB86FC #FF6200EE #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - #00FFFFFF diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt index c0bfc3a5c5..865f6c1457 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt @@ -47,6 +47,9 @@ import java.io.File */ class LocalCacheSearchParityTest { companion object { + /** Hoisted out of [loadCorpus]: building a Json format is expensive enough that the compiler warns on it. */ + private val json = Json { ignoreUnknownKeys = true } + private lateinit var corpus: List @BeforeClass @@ -57,7 +60,7 @@ class LocalCacheSearchParityTest { .firstOrNull { it.isFile } ?: error("tools/search-parity/fixture.json is missing; run tools/search-parity/fetch_fixtures.py") - val root = Json { ignoreUnknownKeys = true }.parseToJsonElement(file.readText()).jsonObject + val root = json.parseToJsonElement(file.readText()).jsonObject corpus = root["cases"]!! .jsonArray diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt index f022d7192f..12586ddf8a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt @@ -153,6 +153,7 @@ class RelayAuthPromptBusTest { * answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that * long despite the user having answered. Marking it shown is what makes the answer land now. */ + @OptIn(ExperimentalCoroutinesApi::class) @Test fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt index 369f853b52..bbb2ea53a8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -185,6 +186,7 @@ class RelayAuthSessionGrantsTest { } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() = runTest { @@ -205,6 +207,7 @@ class RelayAuthSessionGrantsTest { assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun neverAllowStopsAuthenticatingBeforeItsWriteLands() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt index 3a86f8de1b..05b567cabe 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/Nip34NotificationCoverageTest.kt @@ -70,6 +70,7 @@ class Nip34NotificationCoverageTest { * A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles * modern comments through its own separate wiring. */ + @Suppress("DEPRECATION") private val nip34ParticipantKinds = setOf( GitPatchEvent.KIND, @@ -131,6 +132,7 @@ class Nip34NotificationCoverageTest { * uppercase `E`. Asserting the wrong half passes the kind list while matching * nothing on the wire. */ + @Suppress("DEPRECATION") @Test fun `status kinds are pulled by the lowercase-e engagement subscription`() { val eAnchoredActivityKinds = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt new file mode 100644 index 0000000000..f729de7e9e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync + +import com.vitorpamplona.geode.InProcessRelays +import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Drives [EventSync] end to end against geode's in-process relays: one + * "source" relay that already holds the account's history and three empty + * destination relays (outbox / inbox / DM). No network, no public relay — + * every relay is a [RelayEngine] inside this JVM, so the assertions are on + * what actually landed in each destination store, not on "it didn't crash". + * + * The second scenario gates the source relay behind NIP-42 ([FullAuthPolicy]) + * to cover the [RelayAuthenticator] wiring the sync screen relies on when a + * user's relay demands AUTH before serving REQs. + */ +class EventSyncTest : RelayClientTest() { + private val account = NostrSignerSync(KeyPair()) + private val other = NostrSignerSync(KeyPair()) + + private val source: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://source.relay/") + private val outbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://outbox.relay/") + private val inbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://inbox.relay/") + private val dm: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://dm.relay/") + + /** Separate hub so only the source relay demands AUTH; destinations stay open. */ + private val authHub = InProcessRelays(defaultPolicy = { FullAuthPolicy(source) }) + + @After + fun tearDownAuthHub() { + authHub.close() + } + + private fun note( + author: NostrSignerSync, + content: String, + tagged: HexKey? = null, + ): Event = author.sign(eventTemplate(1, content) { tagged?.let { pTag(it) } }) + + private fun legacyDm( + author: NostrSignerSync, + recipient: HexKey, + ): Event = author.sign(eventTemplate(4, "ciphertext") { pTag(recipient) }) + + private val mine = List(3) { note(account, "mine $it") } + private val mentions = List(2) { note(other, "hey $it", tagged = account.pubKey) } + private val dmToMe = legacyDm(other, account.pubKey) + private val noise = note(other, "unrelated") + + private fun corpus(): List = mine + mentions + dmToMe + noise + + /** [decorate] runs on every client the sync builds, e.g. to attach an authenticator. */ + private fun eventSync( + builder: WebsocketBuilder, + decorate: (NostrClient) -> Unit = {}, + ): EventSync = + EventSync( + accountPubKey = account.pubKey, + relayDb = { listOf(source) }, + outboxTargets = { setOf(outbox) }, + inboxTargets = { setOf(inbox) }, + dmTargets = { setOf(dm) }, + clientBuilder = { NostrClient(builder, scope).also(decorate) }, + scope = scope, + ) + + /** + * Publishes are fire-and-forget on the client side, so the destination + * store can lag `runSync` returning by a few ticks. Poll instead of + * asserting a snapshot. + */ + private suspend fun RelayEngine.awaitCount( + filter: Filter, + expected: Int, + ): Int = + withTimeoutOrNull(10_000) { + while (store.count(filter) < expected) delay(25) + store.count(filter) + } ?: store.count(filter) + + private suspend fun assertRouted(hubOfTargets: InProcessRelays) { + val outboxRelay = hubOfTargets.getOrCreate(outbox) + val inboxRelay = hubOfTargets.getOrCreate(inbox) + val dmRelay = hubOfTargets.getOrCreate(dm) + + assertEquals( + "every event authored by the account lands on the outbox relay", + mine.size, + outboxRelay.awaitCount(Filter(authors = listOf(account.pubKey)), mine.size), + ) + assertEquals( + "non-DM mentions land on the inbox relay", + mentions.size, + inboxRelay.awaitCount(Filter(tags = mapOf("p" to listOf(account.pubKey))), mentions.size), + ) + assertEquals( + "the kind-4 DM lands on the DM relay", + 1, + dmRelay.awaitCount(Filter(kinds = listOf(4)), 1), + ) + + // Routing is exclusive per rule: nothing leaks across destinations and the + // unrelated note never leaves the source. + assertEquals("outbox holds only the account's events", mine.size, outboxRelay.store.count(Filter())) + assertEquals("inbox holds only the mentions", mentions.size, inboxRelay.store.count(Filter())) + assertEquals("dm relay holds only the DM", 1, dmRelay.store.count(Filter())) + assertEquals("noise stays on the source", 0, outboxRelay.store.count(Filter(ids = listOf(noise.id)))) + } + + @Test + fun syncRoutesEventsFromSourceToOutboxInboxAndDmRelays() = + runBlocking { + hub.getOrCreate(source).preload(corpus()) + + val sync = eventSync(hub) + withTimeout(30_000) { sync.runSync() } + + val done = sync.syncState.value + assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done) + done as EventSync.SyncState.Done + assertEquals( + "mine + mentions + dm match a routing rule; noise does not", + mine.size + mentions.size + 1, + done.totalEventsReceived, + ) + // runSync drains the outbox before closing its client, so by the time + // Done is published every forwarded event has been written to its + // destination socket — not merely queued. + assertEquals( + "every routed event was sent before the client closed", + mine.size + mentions.size + 1, + done.totalEventsSent, + ) + + assertRouted(hub) + } + + @Test + fun syncReadsFromAuthRequiredSourceOnceAuthenticated() = + runBlocking { + authHub.getOrCreate(source).preload(corpus()) + + // Source demands NIP-42 before serving REQs; destinations are the open hub. + val router = + object : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = if (url == source) authHub.build(url, out) else hub.build(url, out) + } + + val authSigner = NostrSignerSync(KeyPair()) + var authenticator: RelayAuthenticator? = null + val sync = + eventSync(router) { client -> + authenticator = + RelayAuthenticator(client = client, scope = scope) { _, template, _ -> + listOf(authSigner.sign(template)) + } + } + + try { + withTimeout(30_000) { sync.runSync() } + } finally { + authenticator?.destroy() + } + + val done = sync.syncState.value + assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done) + assertEquals( + "the auth-gated source still yields every routed event", + mine.size + mentions.size + 1, + (done as EventSync.SyncState.Done).totalEventsReceived, + ) + + assertRouted(hub) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index 3b11aa84df..ee091ac91f 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -136,7 +136,7 @@ class HexBenchmark { /** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */ @Test fun hexIsHex64ThenDecode() { - r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null } + r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) } } @Test diff --git a/build.gradle.kts b/build.gradle.kts index 6b87fbd049..d3ea321ee8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,18 +9,18 @@ import java.util.Properties // compiles this buildscript {} section in an earlier stage that can't see the // file's imports (hence the qualified Properties) or share code with the body, // but it can publish values — the gate is computed once here and read below -// via `by extra`. +// via the project's extra properties. buildscript { val localProperties = File(rootDir, "local.properties") - val sonarProperties by extra( + val sonarProperties = java.util.Properties().apply { if (localProperties.exists()) localProperties.inputStream().use { load(it) } - }, - ) - val sonarEnabled by extra( + } + extra.set("sonarProperties", sonarProperties) + val sonarEnabled = sonarProperties.getProperty("sonar.host.url") != null && - gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }, - ) + gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") } + extra.set("sonarEnabled", sonarEnabled) if (sonarEnabled) { repositories { gradlePluginPortal() @@ -110,9 +110,9 @@ subprojects { // `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the // command line. sonar.projectKey/projectName default to the root project name // ("Amethyst") and only need overriding in local.properties if desired. -val sonarEnabled: Boolean by extra +val sonarEnabled = extra["sonarEnabled"] as Boolean if (sonarEnabled) { - val sonarProperties: Properties by extra + val sonarProperties = extra["sonarProperties"] as Properties apply(plugin = "org.sonarqube") sonarProperties diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index d343eb4fb8..7e96d3f501 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -348,7 +348,7 @@ Amy-specific layer still needs its own coverage: | Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. | | JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. | | File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. | -| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). | +| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up the embedded `amy serve` relay (geode) and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). | The JVM suite drives `runCli` **in-process** through the shared `amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr, diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index ede7f2c648..74645af784 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -180,11 +180,11 @@ move anything, re-audit — you're probably duplicating logic. 9. **Test suite** — largely in place, two layers: - **Shell harnesses** under `cli/tests/` — ten suites: `blossom` (live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`), - `marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`, + `marmot` (vs MDK), `nests` (manual audio-rooms matrix), `pow`, `relaygroup`, `sync`, plus the shared `headless/` helpers. See - `cli/tests/README.md`. - None run in CI yet (the relay-backed ones need Rust + a ~3 min - cold `nostr-rs-relay` build). + `cli/tests/README.md`. Every relay-backed suite runs against the + embedded `amy serve` relay (geode) — no external relay binary. + None run in CI yet (the Marmot ones need Rust for MDK's `wn`). - **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing, exit-code contract, and `--json` shape tests driving `runCli` in-process via the `amy.home` isolation seam. diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index cd33bc9080..52ed74887e 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -110,7 +110,7 @@ application { // JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy // then signs a kind:7 whose `content` is four replacement characters. // Whitenoise rejects it with "Invalid reaction content". -val patchAmyLauncherCharset by tasks.registering { +val patchAmyLauncherCharset = tasks.register("patchAmyLauncherCharset") { val appName = application.applicationName val startScriptsTask = tasks.named("startScripts") dependsOn(startScriptsTask) diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 363484739c..53f48067cc 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -1,6 +1,7 @@ marmot/state/ marmot/state-headless/ dm/state-dm-headless/ +cache/state-cache-headless/ nests/state/ clink/state-clink-headless/ relaygroup/state-relaygroup-headless/ diff --git a/cli/tests/README.md b/cli/tests/README.md index cf2b314a64..caa8f88122 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -1,21 +1,24 @@ # amy CLI test harnesses -Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a -loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers, -or no relay at all, depending on the suite. Eleven directories: +Shell-based end-to-end harnesses that drive the `amy` CLI binary — against an +embedded relay (`amy serve`, i.e. **geode**, the relay this repo ships), live +public servers, or no relay at all, depending on the suite. No suite depends on +an external relay binary or a Rust toolchain for its relay: every relay-backed +harness boots geode from the `amy` binary it already built, so the relay under +test is the same server code that runs in production. Eleven directories: ``` cli/tests/ ├── lib.sh # shared logging, results, assertions ├── headless/ # shared bits used by every harness -│ └── helpers.sh +│ └── helpers.sh # amy wrappers, assertions, embedded relay boot ├── blossom/ # Blossom blob lifecycle vs LIVE public servers │ └── blossom-live.sh ├── cache/ # local-store-as-cache semantics (profile show -│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay +│ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve` ├── clink/ # CLINK pointer decode — local-only, no relay │ └── clink-headless.sh -├── dm/ # NIP-17 DM interop (amy ↔ amy) +├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve` │ ├── dm-interop-headless.sh │ ├── setup.sh # preflight + identities │ └── tests-dm.sh @@ -24,7 +27,7 @@ cli/tests/ ├── marmot/ # Marmot / MLS group-messaging interop │ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI │ ├── marmot-interop-headless.sh # zero-prompt -│ ├── setup.sh # preflight + wn + relay + identities +│ ├── setup.sh # preflight + wn + identities │ ├── tests-create.sh # tests 01–05 │ ├── tests-manage.sh # tests 06–08, 11 │ ├── tests-extras.sh # tests 09, 10, 12, 13 @@ -60,7 +63,7 @@ Suite notes: and mined-nonce round-trips through `pow check`. - **`cache/cache-headless.sh`** proves the local store is the source of truth for reads: `profile show` served from cache vs `--refresh`, and - `store stat` reporting the right histogram, vs a loopback nostr-rs-relay. + `store stat` reporting the right histogram, vs the embedded `amy serve` relay. - **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/ list/browse against an embedded relay (`amy serve`, which boots geode) — no external relay binary. geode doesn't sign 39000-39003, so browse/info @@ -124,10 +127,8 @@ The Marmot harnesses come in two flavours, same scenarios: A third, slimmer harness covers the NIP-17 DM surface: - **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and - Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay. - No MDK required — only `amy` and the relay binary (which - is shared with the Marmot harness's checkout at - `marmot/state-headless/nostr-rs-relay/`). + Identity D) exchange NIP-17 DMs through the embedded `amy serve` relay. + No MDK, no Rust — only `amy`. A harness covers Blossom blob storage (BUD-01/02/04/09) against **live** public servers rather than a loopback relay: @@ -212,15 +213,18 @@ at `desktopApp/src/jvmTest/kotlin/.../service/upload/`. On the machine that runs the harness: -- **Rust 1.90+** — install via https://rustup.rs +- **Rust 1.90+** — install via https://rustup.rs (for MDK's `wn`/`wnd` only; + the relay is `amy serve`, no Rust needed for it) - **git**, **curl**, **jq** — package manager - **~5 GB disk** for the first-run build of `wn` + `wnd` -- Public internet access (for the default relay set and fetching crates) +- Internet access for fetching crates on the first build. Test traffic + stays on the machine unless you pass `--public-relays`. On the Android side: - Amethyst installed on an **emulator** or a **physical device** -- The device must reach the same relays the harness uses (see below) +- The device must reach the harness's embedded relay over the network + (see below), or the public relays when running with `--public-relays` ## Quick start @@ -240,9 +244,11 @@ The script will, in order: 4. Create Nostr identities for B and C, persist their npubs in `state/run.env`. 5. Ask you to paste **your Amethyst account npub** (Identity A). This is cached for subsequent runs. -6. Add the default public relays to both daemons and run a sanity check - (publish a KP from B, fetch it from C). -7. Print an **Amethyst setup checklist** — add the same relays to Amethyst, +6. Boot the embedded relay (`amy serve`, i.e. geode, on `0.0.0.0:8080`), + add it to both daemons and run a sanity check (publish a KP from B, + fetch it from C). With `--public-relays` the default public set is used + instead and the relay is not started. +7. Print an **Amethyst setup checklist** — add the same relay to Amethyst, publish a KP, verify you are logged in with A. 8. Run all 13 tests sequentially. Each test either: - runs `wn` commands fully automatically and asserts on JSON output, **or** @@ -253,9 +259,10 @@ The script will, in order: ## Command-line flags ``` ---local-relays Use ws://localhost:8080 instead of the default public relays. - Required if the public relays reject kinds 444/445/30443. - Run 'just docker-up' inside the mdk checkout first. +--public-relays Use the public relay set below instead of the embedded relay. + The only mode whose test traffic leaves the machine; the + public relays may reject kinds 444/445/30443. +--port N Port for the embedded relay (default 8080). --transponder Run Test 14 (push notifications via the transponder service). --no-build Fail instead of rebuilding wn/wnd. Useful when iterating. -h, --help Show help. @@ -267,31 +274,31 @@ Environment overrides: WN_REPO=/some/path/mdk # use an existing checkout ``` -## Default relays +## Relays + +By default the harness owns the only relay: `amy serve` (geode) bound to +`0.0.0.0:8080`. The `wn` daemons reach it on loopback; Amethyst reaches it +over the network: + +- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays, + Settings → Key Package Relays and Settings → DM Inbox Relays. +- **Physical device on same Wi-Fi:** add `ws://:8080`. + +With `--public-relays` the daemons are bootstrapped on ``` wss://relay.damus.io wss://nos.lol wss://relay.primal.net +wss://nostr.bitcoiner.social +wss://nostr.mom ``` -These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable -events). If the **sanity check fails** — meaning C cannot read the KeyPackage -that B just published — the harness warns you and continues. In that case -re-run with `--local-relays` after starting the Docker stack: - -```bash -cd state/mdk -just docker-up -cd ../.. -./marmot-interop.sh --local-relays -``` - -For Amethyst with `--local-relays`: - -- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and - Settings → Key Package Relays. -- **Physical device on same Wi-Fi:** add `ws://:8080`. +instead and Amethyst is left on its own relay set, so the run surfaces +real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the +public relays drop). If the **sanity check fails** in that mode — meaning C +cannot read the KeyPackage that B just published — the harness warns you and +continues; re-run without `--public-relays` to rule the relays out. ## How human interaction works diff --git a/cli/tests/cache/cache-headless.sh b/cli/tests/cache/cache-headless.sh index b1944678ce..454c457a42 100755 --- a/cli/tests/cache/cache-headless.sh +++ b/cli/tests/cache/cache-headless.sh @@ -3,8 +3,8 @@ # cache-headless.sh — verifies the file-backed event store is the # source of truth for `amy` reads. # -# Two amy identities (A and B) talk to a local nostr-rs-relay. We -# assert that: +# Two amy identities (A and B) talk to a local embedded relay +# (`amy serve`, i.e. geode). We assert that: # # 1. After A runs `amy create`, A's local store contains the bootstrap # events (kind:0 / 3 / 10002 / 10050 / 10051 …). @@ -39,10 +39,13 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -# Reuse the relay binary the marmot harness builds. +# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by +# start_local_relay in headless/helpers.sh. 127.0.0.2 only for parity +# with the DM and Marmot harnesses: Quartz's isLocalHost() now covers all +# of 127.0.0.0/8, so it is stripped from parsed relay lists exactly like +# 127.0.0.1. Nothing here depends on that parse — amy publishes to and +# reads from the relay it was told about. RELAY_HOST="${RELAY_HOST:-127.0.0.2}" -RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}" -RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" RELAY_DATA="$STATE_DIR/relay" RELAY_PORT="${RELAY_PORT:-8092}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" @@ -72,14 +75,11 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" -# shellcheck source=../marmot/setup.sh — provides start_local_relay / stop_local_relay -source "$TESTS_DIR/marmot/setup.sh" -# shellcheck source=../headless/helpers.sh +# shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay source "$TESTS_DIR/headless/helpers.sh" -# Keep the dm setup's preflight (just checks for amy + the relay) but -# define our own identity bootstrap so we don't pull in DM-specific -# wiring. +# Keep the dm setup's preflight (just checks for amy) but define our own +# identity bootstrap so we don't pull in DM-specific wiring. # shellcheck source=../dm/setup.sh source "$TESTS_DIR/dm/setup.sh" diff --git a/cli/tests/dm/dm-interop-headless.sh b/cli/tests/dm/dm-interop-headless.sh index 687b9349cc..5d3ed45ab6 100755 --- a/cli/tests/dm/dm-interop-headless.sh +++ b/cli/tests/dm/dm-interop-headless.sh @@ -3,8 +3,9 @@ # dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness. # # Two `amy` processes (Identity A and Identity D) talk to each other -# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No -# whitenoise-rs, no Marmot, no public internet traffic. +# through a local embedded relay (`amy serve`, i.e. geode) on +# ws://127.0.0.2:$RELAY_PORT. No MDK, no Marmot, no Rust toolchain, no +# public internet traffic. # # Usage: ./dm-interop-headless.sh [--port N] [--no-build] # @@ -26,16 +27,16 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -# Share the nostr-rs-relay checkout with the Marmot harness to avoid -# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full -# isolation between runs. -# Bind the loopback relay to 127.0.0.2 rather than 127.0.0.1 so Quartz's -# `isLocalHost()` filter doesn't silently strip it out of the kind:10050 -# inbox events during recipient-relay resolution. 127.0.0.2 is still pure -# loopback — no network traffic, no config needed. +# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by +# start_local_relay in headless/helpers.sh. Override RELAY_DATA if you +# want full isolation between runs. +# 127.0.0.2 used to dodge Quartz's `isLocalHost()` strip of loopback +# relays in kind:10050 inbox lists. That filter now covers all of +# 127.0.0.0/8, so the strict-inbox sends (dm-01/02/05/06) fail with +# no_dm_relays regardless of which loopback address the relay binds; +# only the fallback-chain tests (dm-03/04) are unaffected. Kept for +# parity with the other harnesses until that routing rule is revisited. RELAY_HOST="${RELAY_HOST:-127.0.0.2}" -RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}" -RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" RELAY_DATA="$STATE_DIR/relay" RELAY_PORT="${RELAY_PORT:-8090}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" @@ -68,12 +69,9 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" -# Reuse start_local_relay / stop_local_relay from the Marmot harness's -# setup.sh — the relay lifecycle is identical. preflight() there also -# builds whitenoise-rs, which we don't need; setup.sh in this dir -# defines a slimmer preflight_dm(). -# shellcheck source=../marmot/setup.sh -source "$TESTS_DIR/marmot/setup.sh" +# setup.sh in this dir defines the slim preflight_dm() (amy only, no +# MDK); the relay lifecycle (start_local_relay / stop_local_relay, the +# embedded `amy serve`) comes from the shared headless helpers. # shellcheck source=setup.sh source "$SCRIPT_DIR/setup.sh" # shellcheck source=../headless/helpers.sh diff --git a/cli/tests/dm/setup.sh b/cli/tests/dm/setup.sh index 5af3b5605d..5fdd65fba5 100644 --- a/cli/tests/dm/setup.sh +++ b/cli/tests/dm/setup.sh @@ -3,20 +3,21 @@ # setup.sh — amy-only preflight + identity bootstrap for the # NIP-17 DM interop harness. Much slimmer than the Marmot setup: # -# - Builds `amy` (same retry-on-503 logic as setup.sh). -# - Builds nostr-rs-relay if missing. +# - Builds `amy` (same retry-on-503 logic as setup.sh). The loopback +# relay is `amy serve` (geode), so amy is the only binary needed. # - Bootstraps two fresh amy identities (A and D), each with its own # `--data-dir`, both pointed at the loopback relay. # - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict # recipient-inbox routing has something to resolve to. # -# The heavy `start_local_relay` / `stop_local_relay` helpers live in -# the Marmot harness's setup.sh and are sourced by the top-level harness. +# The `start_local_relay` / `stop_local_relay` helpers (embedded +# `amy serve`) live in ../headless/helpers.sh and are sourced by the +# top-level harness. -# --- preflight (amy + relay only, no wn / Marmot patches) ------------------- +# --- preflight (amy only, no wn / Marmot patches, no Rust) ------------------- preflight_dm() { banner "Preflight (DM harness)" - for cmd in jq git cargo; do + for cmd in jq git curl; do if ! command -v "$cmd" >/dev/null 2>&1; then fail_msg "missing required tool: $cmd" exit 1 @@ -43,29 +44,6 @@ preflight_dm() { [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } info "amy: $AMY_BIN" - # nostr-rs-relay (same build path as the Marmot harness). - if [[ ! -x "$RELAY_BIN" ]]; then - if [[ "$NO_BUILD" -eq 1 ]]; then - fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1 - fi - if [[ ! -d "$RELAY_REPO/.git" ]]; then - step "cloning nostr-rs-relay into $RELAY_REPO" - git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ - 2>&1 | tee -a "$LOG_FILE" - fi - local attempt max=4 - for attempt in $(seq 1 $max); do - step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)" - ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ - 2>&1 | tee -a "$LOG_FILE" - [[ -x "$RELAY_BIN" ]] && break - [[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying" - done - [[ -x "$RELAY_BIN" ]] || { - fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1 - } - fi - info "relay bin: $RELAY_BIN" } # --- amy identity wrappers --------------------------------------------------- diff --git a/cli/tests/dm/tests-dm.sh b/cli/tests/dm/tests-dm.sh index 4fff53fbe8..fe0f2bb393 100644 --- a/cli/tests/dm/tests-dm.sh +++ b/cli/tests/dm/tests-dm.sh @@ -3,7 +3,7 @@ # tests-dm.sh — NIP-17 DM interop tests for two `amy` clients. # # Identity A (sender) and Identity D (recipient) each live in their own -# --data-dir and share one loopback nostr-rs-relay. Tests cover: +# --data-dir and share one loopback embedded relay (`amy serve` / geode). Tests cover: # # dm-01 text round-trip (both directions) # dm-02 dm list surfaces prior exchange with type:text discriminator diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index e01069bb3f..ea45199223 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -66,5 +66,94 @@ assert_eq() { return 1 } +# --- embedded relay (amy serve → geode) -------------------------------------- +# Every relay-backed harness talks to ONE loopback relay, and that relay is +# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy +# binary the harness already built. No Rust toolchain, no clone, no cargo +# build, no external relay binary: the relay under test is part of the +# product, so a harness run exercises the same server code `amy serve` +# and the standalone geode distribution run in production. +# +# Callers set (before sourcing or at least before calling): +# AMY_BIN amy launcher (built via `./gradlew :cli:installDist`) +# RELAY_HOST host clients connect to. The harnesses use 127.0.0.2 for +# parity with each other; note that Quartz's isLocalHost() +# treats all of 127.0.0.0/8 as loopback, so it does NOT +# survive the NIP-17 / NIP-65 relay-list parsers any better +# than 127.0.0.1 does. +# RELAY_BIND optional bind address; defaults to $RELAY_HOST. Set to +# 0.0.0.0 when a device on the LAN must reach the relay. +# RELAY_PORT listen port +# RELAY_URL ws://$RELAY_HOST:$RELAY_PORT +# RELAY_DATA scratch dir for the relay's own $HOME, pid file and logs +# +# The relay process runs as its own amy account ("relay") inside its own +# $HOME under $RELAY_DATA, so its identity and store never mix with the +# test identities. The store is in-memory (amy serve's default): every run +# starts from an empty relay, matching the state wipe the harnesses do. +start_local_relay() { + banner "Starting embedded relay (amy serve / geode) on $RELAY_URL" + local relay_home="$RELAY_DATA/home" + local bind="${RELAY_BIND:-$RELAY_HOST}" + mkdir -p "$relay_home" "$RELAY_DATA/logs" + + [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; } + + # Abort early if something else is already bound to the port — failing + # with a clear error beats a mysterious-looking daemon stall later. + # bash's /dev/tcp probe needs no `ss`/`lsof`; a refused connect on + # loopback returns immediately. + if (exec 3<>"/dev/tcp/$RELAY_HOST/$RELAY_PORT") 2>/dev/null; then + fail_msg "port $RELAY_PORT already in use on $RELAY_HOST — pass --port N or free it" + exit 1 + fi + + # `amy serve` resolves its admin pubkey from the account, so the relay + # needs an identity of its own. Idempotent across --reuse-state runs. + if [[ ! -d "$relay_home/.amy/relay" ]]; then + HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext --json init \ + >"$RELAY_DATA/logs/init.log" 2>&1 \ + || { fail_msg "amy init failed for the relay account (see $RELAY_DATA/logs/init.log)"; exit 1; } + fi + + nohup env HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext \ + serve --host "$bind" --port "$RELAY_PORT" \ + >"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" & + echo "$!" > "$RELAY_DATA/pid" + step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …" + + # Readiness = the NIP-11 document answers on the same port. geode serves + # it on a plain GET with `Accept: application/nostr+json` (anything else + # gets a 426 hint, which curl -f would treat as failure). + local deadline=$(( $(date +%s) + 60 )) + while [[ $(date +%s) -lt $deadline ]]; do + if curl -sSf -m 1 -H 'Accept: application/nostr+json' \ + "http://$RELAY_HOST:$RELAY_PORT/" >/dev/null 2>&1; then + info "relay up" + return 0 + fi + if ! kill -0 "$(cat "$RELAY_DATA/pid")" 2>/dev/null; then + break + fi + sleep 0.5 + done + fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)" + tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true + exit 1 +} + +stop_local_relay() { + local pid_file="$RELAY_DATA/pid" + [[ -f "$pid_file" ]] || return 0 + local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "") + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + info "stopping relay pid $pid" + kill "$pid" 2>/dev/null || true + sleep 1 + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" +} + # --- wn-side pollers (delegates to lib.sh) ----------------------------------- # Both exist in lib.sh already; this file only adds headless-specific niceties. diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 5aa8a3786d..e1d7e8d2e8 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -3,9 +3,9 @@ # marmot-interop-headless.sh — zero-prompt, zero-internet interop harness. # # Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and -# Identities B/C via MDK's `wn`/`wnd`. Spins up a local -# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the -# machine. Matches the 13 test scenarios in marmot-interop.sh but without +# Identities B/C via MDK's `wn`/`wnd`. Spins up a local embedded relay +# (`amy serve`, i.e. geode) on ws://127.0.0.2:$RELAY_PORT so nothing ever +# leaves the machine. Matches the 13 test scenarios in marmot-interop.sh but without # any human prompts — all checks run to completion and the exit code # reflects pass/fail totals. # @@ -37,9 +37,10 @@ WN_BIN="$WN_REPO/target/release/wn" WND_BIN="$WN_REPO/target/release/wnd" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -# Local relay wiring — cloned + built during preflight, started on -# $RELAY_PORT. The harness never touches the public internet for test -# traffic; wn/wnd/amy all point at this one loopback endpoint. +# Local relay wiring — the embedded `amy serve` (geode), started on +# $RELAY_PORT by start_local_relay (../headless/helpers.sh). The harness +# never touches the public internet for test traffic; wn/wnd/amy all +# point at this one loopback endpoint. # # Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer # strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox @@ -48,8 +49,6 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" # Amethyst's public defaults instead of the loopback. 127.0.0.2 is # still pure loopback (no network traffic) but isn't on the strip list. RELAY_HOST="${RELAY_HOST:-127.0.0.2}" -RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}" -RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" RELAY_DATA="$STATE_DIR/relay" RELAY_PORT="${RELAY_PORT:-8080}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" @@ -73,7 +72,7 @@ BLOSSOM_PID="" NO_BUILD=0 # Every run starts from empty stores. wnd already wipes B's and C's data dirs -# on each start, but A's amy home and the relay's SQLite file used to survive, +# on each start, but A's amy home and the relay's state used to survive, # and the leftovers are not inert: a KeyPackage A published in an earlier run # is still on the relay for B to invite with, an old group's kind:445 events # still arrive and fail to decrypt, and A's cursors still say it has seen them. @@ -119,8 +118,8 @@ while [[ $# -gt 0 ]]; do done if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then - # Keep the relay checkout + its build (minutes to rebuild) and the log and - # results history; drop everything that holds protocol state. + # Keep the log and results history; drop everything that holds protocol + # state (the relay is in-memory, so wiping its dir just drops its identity). # # run.env counts as protocol state: it is where tests hand each other group # ids. Leaving it behind a wipe leaves ids naming groups nobody is in any diff --git a/cli/tests/marmot/marmot-interop.sh b/cli/tests/marmot/marmot-interop.sh index b7b9a44d25..d39481c7c3 100755 --- a/cli/tests/marmot/marmot-interop.sh +++ b/cli/tests/marmot/marmot-interop.sh @@ -5,12 +5,19 @@ # Sequential, all-or-nothing. Script drives the `wn` side automatically and # prompts the human operator at each step that requires Amethyst UI action. # -# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build] +# Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build] +# +# By default the harness boots its own relay — `amy serve`, i.e. geode — bound +# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone +# reaches it over the LAN (ws://:PORT, or ws://10.0.2.2:PORT from an +# emulator). Pass --public-relays to run the old real-world path against the +# public relay set instead; that is the only mode that touches the internet. # set -uo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" STATE_DIR="$SCRIPT_DIR/state" LOG_DIR="$STATE_DIR/logs" B_DIR="$STATE_DIR/B" @@ -27,6 +34,24 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" WN_REPO="${WN_REPO:-$STATE_DIR/mdk}" WN_BIN="" WND_BIN="" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +# Embedded relay (default mode). Bound on every interface so a device on the +# same network can reach it; the daemons connect over $RELAY_HOST. Loopback +# `ws://` relays are only accepted by MDK behind this explicit opt-in. +# +# Known limit, inherited from the old --local-relays mode: the URL wn +# advertises in its kind:10050/10051 lists is $RELAY_URL, and Amethyst's +# parsers drop loopback and RFC1918 relays from those lists, so A→B welcome +# delivery leans on Amethyst's fallback relays. Override RELAY_HOST with an +# address the device can dial (e.g. the laptop's LAN IP) to have wn +# advertise that instead; the daemons then connect to it too. +RELAY_HOST="${RELAY_HOST:-127.0.0.1}" +RELAY_BIND="${RELAY_BIND:-0.0.0.0}" +RELAY_PORT="${RELAY_PORT:-8080}" +RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" +RELAY_DATA="$STATE_DIR/relay" +export WN_ALLOW_LOOPBACK_RELAYS=1 B_NPUB="" B_HEX="" C_NPUB="" @@ -34,6 +59,7 @@ C_HEX="" A_NPUB="" A_HEX="" +# Only used with --public-relays. DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" @@ -41,7 +67,7 @@ DEFAULT_RELAYS=( "wss://nostr.bitcoiner.social" "wss://nostr.mom" ) -USE_LOCAL_RELAYS=0 +USE_PUBLIC_RELAYS=0 ENABLE_TRANSPONDER=0 NO_BUILD=0 @@ -50,7 +76,9 @@ usage() { marmot-interop.sh — Amethyst <-> MDK interop harness Options: - --local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up') + --public-relays Use the public relay set instead of the embedded relay + (amy serve / geode, the default). Only mode that leaves the machine. + --port N Port for the embedded relay (default 8080) --transponder Run Test 14 (MIP-05 push notifications) --no-build Don't rebuild wn/wnd if binaries are missing -h, --help Show this help @@ -62,8 +90,12 @@ EOF while [[ $# -gt 0 ]]; do case "$1" in - --local-relays) USE_LOCAL_RELAYS=1 ;; - --transponder) ENABLE_TRANSPONDER=1 ;; + --public-relays) USE_PUBLIC_RELAYS=1 ;; + --local-relays) printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;; + --port) + [[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for --port\n' >&2; usage; exit 2; } + RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; + --transponder) ENABLE_TRANSPONDER=1 ;; --no-build) NO_BUILD=1 ;; -h|--help) usage; exit 0 ;; *) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;; @@ -77,6 +109,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" +# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve) +source "$TESTS_DIR/headless/helpers.sh" # --- preflight --------------------------------------------------------------- preflight() { @@ -88,6 +122,26 @@ preflight() { printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE" done + # The embedded relay is `amy serve`, so amy has to exist unless the run + # goes to the public relays. Same transient-503 retry as the headless + # harness: one bad jitpack/dl.google.com roll must not abort the run. + if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 + fi + local attempt max_attempts=4 + for attempt in $(seq 1 $max_attempts); do + step "building :cli:installDist (attempt $attempt/$max_attempts)" + if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ + && [[ -x "$AMY_BIN" ]]; then + break + fi + [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying" + done + [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } + printf ' amy: %s\n' "$AMY_BIN" >>"$LOG_FILE" + fi + WN_BIN="$WN_REPO/target/release/wn" WND_BIN="$WN_REPO/target/release/wnd" @@ -285,7 +339,7 @@ discover_a_relays() { if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working" else - warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}." + warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}." warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach." warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)." fi @@ -300,7 +354,7 @@ discover_a_relays() { if ! command -v sqlite3 >/dev/null 2>&1; then warn "sqlite3 not installed — skipping wn user_relays cache probe." - warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays." + warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays." return fi @@ -383,12 +437,7 @@ discover_a_relays() { # --- relays ------------------------------------------------------------------ configure_relays() { banner "Configuring relays" - local relays=() - if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then - relays=( "ws://localhost:8080" ) - else - relays=( "${DEFAULT_RELAYS[@]}" ) - fi + local relays=( "${RELAY_LIST[@]}" ) # Each relay × 3 types × 2 daemons produces a lot of repetitive "ok" # lines — the happy path doesn't need any of it on screen. Quiet the # per-add logging into $LOG_FILE and only surface real failures as @@ -527,27 +576,28 @@ configure_relays() { info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)" else warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)" - warn "Consider rerunning with --local-relays." + warn "Consider rerunning without --public-relays (the embedded relay stores every kind)." fi # best-effort cleanup so re-runs don't accumulate dead sanity groups wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true else warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists" - warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)." + warn "Consider rerunning without --public-relays (the embedded relay stores every kind)." fi fi } instruct_amethyst_setup() { - if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then - # Offline/sandbox path: we own the only relay, so the harness DOES - # need to dictate Amethyst's relay config — nothing is discoverable - # via the public network. - prompt_human "Configure Amethyst to match this --local-relays harness: + if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then + # Offline/sandbox path (default): we own the only relay — the embedded + # `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need + # to dictate Amethyst's relay config; nothing is discoverable via the + # public network. + prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode): 1. Settings -> Relays: add as READ+WRITE - ws://10.0.2.2:8080 (Android emulator) - ws://:8080 (physical device on same Wi-Fi) + ws://10.0.2.2:$RELAY_PORT (Android emulator) + ws://:$RELAY_PORT (physical device on same Wi-Fi) 2. Settings -> Key Package Relays: add the SAME URL 3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL 4. Trigger key-package publish (toggle KP relay on/off if needed) @@ -555,7 +605,7 @@ instruct_amethyst_setup() { return fi - # Public-relay path: the harness should behave like any real Nostr + # --public-relays path: the harness should behave like any real Nostr # client — discover A's advertised relays via kind:10002 / 10050 / # 10051 and publish there, rather than forcing A to adopt the # harness's own relay set. That lets the tests surface real-world @@ -1301,6 +1351,7 @@ main() { local rc=$? trap - EXIT INT TERM HUP stop_daemons + stop_local_relay print_summary exit "$rc" } @@ -1311,6 +1362,12 @@ main() { banner "Amethyst <-> MDK interop harness ($RUN_TS)" preflight + if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then + RELAY_LIST=( "${DEFAULT_RELAYS[@]}" ) + else + RELAY_LIST=( "$RELAY_URL" ) + start_local_relay + fi start_daemon B "$B_DIR" "$B_SOCKET" start_daemon C "$C_DIR" "$C_SOCKET" ensure_identity B @@ -1327,7 +1384,7 @@ main() { # plane, then summarise what wn sees. Surfaces up front the kind of # failure (A's 10050 unreachable from wn, missing KP list, etc.) that # would otherwise bite as a silent Test 03 timeout. - if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then + if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then discover_a_relays fi diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 1bf5e32db4..3b1ceabafb 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -147,29 +147,8 @@ preflight() { info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))" info "wnd: $WND_BIN" - # Clone/build nostr-rs-relay — the harness's single loopback relay. - if [[ ! -x "$RELAY_BIN" ]]; then - if [[ "$NO_BUILD" -eq 1 ]]; then - fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1 - fi - if [[ ! -d "$RELAY_REPO/.git" ]]; then - step "cloning nostr-rs-relay into $RELAY_REPO" - git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ - 2>&1 | tee -a "$LOG_FILE" - fi - local attempt max=4 - for attempt in $(seq 1 $max); do - step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)" - ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ - 2>&1 | tee -a "$LOG_FILE" - [[ -x "$RELAY_BIN" ]] && break - [[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed (likely transient 503 from crates.io) — retrying" - done - [[ -x "$RELAY_BIN" ]] || { - fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1 - } - fi - info "relay bin: $RELAY_BIN" + # The loopback relay is `amy serve` (geode) — see start_local_relay in + # ../headless/helpers.sh. Nothing to clone or build beyond amy itself. } # --- local QUIC broker ------------------------------------------------------- @@ -262,75 +241,8 @@ stop_quic_broker() { } # --- local relay ------------------------------------------------------------- -# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test -# runs against this one loopback endpoint — no external network traffic. -start_local_relay() { - banner "Starting local nostr-rs-relay on $RELAY_URL" - mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs" - - # Render a minimal config file each run so port/limits come from the - # harness rather than whatever was left on disk from a previous session. - cat >"$RELAY_DATA/config.toml" </dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then - fail_msg "port $RELAY_PORT already in use — pass --port N or free it" - exit 1 - fi - - nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \ - >"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" & - echo "$!" > "$RELAY_DATA/pid" - step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …" - - local deadline=$(( $(date +%s) + 20 )) - while [[ $(date +%s) -lt $deadline ]]; do - if curl -sSf -m 1 "http://${RELAY_HOST:-127.0.0.1}:$RELAY_PORT/" >/dev/null 2>&1; then - info "relay up" - return 0 - fi - sleep 0.5 - done - fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)" - tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true - exit 1 -} - -stop_local_relay() { - local pid_file="$RELAY_DATA/pid" - [[ -f "$pid_file" ]] || return 0 - local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "") - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - info "stopping relay pid $pid" - kill "$pid" 2>/dev/null || true - sleep 1 - kill -9 "$pid" 2>/dev/null || true - fi - rm -f "$pid_file" -} +# start_local_relay / stop_local_relay live in ../headless/helpers.sh: the +# relay is the embedded `amy serve` (geode), shared by every harness. # --- daemons ----------------------------------------------------------------- start_daemon() { @@ -480,9 +392,9 @@ configure_relays() { step "publishing A's KeyPackage" amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed" - # Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443 + # Give the relay a breath to ingest the kind:10002 / 10050 / 30443 # writes and push them out on the discovery subscription so that the # first `wn keys check` that follows actually sees them instead of - # racing the relay's WAL flush. + # racing the relay's ingest queue. sleep 2 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt index 3b4a1e1381..0444058aad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcher.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException /** * What a front end renders for one live agent text stream. @@ -203,6 +204,9 @@ class MarmotAgentStreamWatcher( try { quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray()) } catch (e: Exception) { + // Without this a cancelled watcher keeps dialling the remaining + // candidates instead of stopping. + if (e is CancellationException) throw e Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" } continue } @@ -222,6 +226,7 @@ class MarmotAgentStreamWatcher( ) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" } } finally { runCatching { stream.close() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt index 244c302bf7..65292e8702 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt @@ -68,6 +68,5 @@ abstract class FeedViewModel( override fun onCleared() { Log.d("Init") { "OnCleared: ${this::class.simpleName}" } - super.onCleared() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 0dc37c687d..9c698f36d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.utils.toLongValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -164,7 +165,7 @@ class LiveStreamTopZappersViewModel( when (val ev = note.event) { is LnZapEvent -> { val request = ev.zapRequest ?: return null - val sats = ev.amount()?.toLong() ?: return null + val sats = ev.amount()?.toLongValue() ?: return null ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) } is Bolt12ZapEvent -> { @@ -180,7 +181,7 @@ class LiveStreamTopZappersViewModel( ): ZapContribution? { val receiptEv = receiptNote?.event as? LnZapEvent ?: return null val request = zapRequestNote.event as? LnZapRequestEvent ?: return null - val sats = receiptEv.amount()?.toLong() ?: return null + val sats = receiptEv.amount()?.toLongValue() ?: return null return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 8568f793f4..ce0267fb9e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.utils.toLongValue /** * One in-flight kind-9735 zap to render as a floating overlay on the @@ -59,7 +60,7 @@ data class RoomZap( eventId = event.id, sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey, targetPubkey = event.zappedAuthor().firstOrNull(), - amountSats = event.amount?.toLong(), + amountSats = event.amount?.toLongValue(), createdAtSec = event.createdAt, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt index acaca77d15..c631249285 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/PartialTokensTest.kt @@ -38,7 +38,7 @@ class PartialTokensTest { fun aHalfWrittenFromOpensThePeoplePicker() { val picker = pickerAtEnd("zaps from:ali") assertTrue(picker is ActivePicker.People) - assertEquals(KeyField.FROM, (picker as ActivePicker.People).keyField) + assertEquals(KeyField.FROM, picker.keyField) assertEquals("ali", picker.token.partial) assertEquals(5, picker.token.start) } @@ -65,7 +65,7 @@ class PartialTokensTest { fun aHalfWrittenDateOpensTheCalendar() { val picker = pickerAtEnd("since:2026-0") assertTrue(picker is ActivePicker.Calendar) - assertEquals(DateField.SINCE, (picker as ActivePicker.Calendar).dateField) + assertEquals(DateField.SINCE, picker.dateField) } @Test diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 6fb1a8088a..80ad79160f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -838,7 +838,6 @@ class NestViewModel( closed = true teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true) teardown(targetState = ConnectionUiState.Closed, finalCleanup = true) - super.onCleared() } private fun observeSpeakerState(s: NestsSpeaker) { diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt index d84c56ca69..c63f506200 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt @@ -461,9 +461,9 @@ class MarmotPublishBeforeApplyTest { override suspend fun saveRetainedEpochs( nostrGroupId: String, - epochs: List, + retainedSecrets: List, ) { - retained[nostrGroupId] = epochs + retained[nostrGroupId] = retainedSecrets } override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId] ?: emptyList() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt index e85afc40ca..e06891794c 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt @@ -105,9 +105,9 @@ class MarmotPublishDurabilityTest { override suspend fun saveRetainedEpochs( nostrGroupId: String, - epochs: List, + retainedSecrets: List, ) { - retained[nostrGroupId] = epochs + retained[nostrGroupId] = retainedSecrets } override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId].orEmpty() diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 49f0fe025b..4c6d6523f1 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -255,7 +255,7 @@ compose.desktop { // The arch is selected at task-execution time from the host JVM's os.arch, so // the same task builds the correct AppImage on both x86_64 and aarch64 hosts. // BUILDING.md documents local-dev fetch. -val createReleaseAppImage by tasks.registering(Exec::class) { +val createReleaseAppImage = tasks.register("createReleaseAppImage") { group = "compose desktop" description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool." dependsOn("createReleaseDistributable") @@ -331,7 +331,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) { // proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is // a regression guard, not a workaround. It's wired onto every release task so // it fails the build immediately if the .so disappears. -val verifyJkeychainNativeSurvivesProguard by tasks.registering { +val verifyJkeychainNativeSurvivesProguard = tasks.register("verifyJkeychainNativeSurvivesProguard") { description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)" group = "verification" dependsOn("proguardReleaseJars") @@ -396,7 +396,7 @@ listOf( // into the .app) so the subsequent bundle signing seals already-signed code. // Runs only on macOS with the Developer ID identity exported — a no-op on every // other leg and on unsigned local/PR builds. -val signMacJarNatives by tasks.registering { +val signMacJarNatives = tasks.register("signMacJarNatives") { description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized" group = "build" dependsOn("proguardReleaseJars") diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt index 2490177cfe..53013a900f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.FlowRowOverflow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -146,7 +145,6 @@ fun LivesSection( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), maxLines = 2, - overflow = FlowRowOverflow.Clip, modifier = Modifier.fillMaxWidth(), ) { ranked.forEach { channel -> diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 2c8efa600f..6870826151 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -22,7 +22,7 @@ kotlin { // Generate a BuildConfig.kt carrying the app version from the catalog so // RelayInfo.VERSION (reported over NIP-11) tracks releases automatically // instead of being a hand-bumped literal. -val generateVersionFile by tasks.registering { +val generateVersionFile = tasks.register("generateVersionFile") { val versionValue = libs.versions.app.get() val outDir = layout.buildDirectory.dir("generated/version/kotlin") inputs.property("version", versionValue) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index b79f81bf51..ea1a1352bd 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -311,14 +311,14 @@ class KtorRelayTest { ) assertEquals(true, ok, "successful insert must round-trip OK true on the wire") - // Duplicate insert returns OK false; this also exercises the - // "non-empty message" branch of the serializer. + // Duplicate insert returns OK true with a `duplicate:` message (NIP-01); + // this also exercises the "non-empty message" branch of the serializer. val ok2 = client.publishAndConfirm( event = event, relayList = setOf(server.url.normalizeRelayUrl()), ) - assertEquals(false, ok2, "duplicate insert must round-trip OK false") + assertEquals(true, ok2, "duplicate insert must round-trip OK true (NIP-01 duplicate:)") } /** diff --git a/gradle/kmp-purity.gradle.kts b/gradle/kmp-purity.gradle.kts index eb5caafa3e..f7c92aa15c 100644 --- a/gradle/kmp-purity.gradle.kts +++ b/gradle/kmp-purity.gradle.kts @@ -10,7 +10,7 @@ // the authoritative one. Keeping the pattern table in one file means a new // forbidden API lands in every module at once instead of drifting per copy. -val verifyKmpPurity by tasks.registering { +val verifyKmpPurity = tasks.register("verifyKmpPurity") { group = "verification" description = "Fails if iOS-targeted source sets import JVM-only deps." // Every source set that feeds an Apple target. Modules that don't have a diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b02920047..266e447dd3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ uiautomator = "2.4.0" biometricKtx = "1.4.0-alpha02" coil = "3.6.2" composeBom = "2026.09.00" +tracingPerfetto = "1.0.1" composeRuntimeAnnotation = "1.12.1" coreKtx = "1.19.0" datastore = "1.2.1" @@ -136,6 +137,9 @@ androidx-camera-extensions = { module = "androidx.camera:camera-extensions", ver androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing" } +androidx-tracing-perfetto = { group = "androidx.tracing", name = "tracing-perfetto", version.ref = "tracingPerfetto" } +androidx-tracing-perfetto-binary = { group = "androidx.tracing", name = "tracing-perfetto-binary", version.ref = "tracingPerfetto" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt index bf5f68f7fe..7a030c58d4 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt @@ -24,12 +24,10 @@ import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.runBlocking @@ -170,7 +168,7 @@ fun benchJoinWelcome(): BenchResult = } }, ) { (bob, wrap) -> - runBlocking { bob.manager.ingest(wrap as GiftWrapEvent) } + runBlocking { bob.manager.ingest(wrap) } } /** @@ -223,7 +221,7 @@ fun benchIngestAppMessage(members: Int): BenchResult = } }, ) { (bob, event) -> - runBlocking { bob.manager.ingest(event as GroupEvent) } + runBlocking { bob.manager.ingest(event) } } private const val BENCH_RELAY = "wss://bench.invalid" diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 15e463a301..f14d312308 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -154,7 +154,7 @@ val hangInteropCacheDir = val moqRelayVersion = "0.10.25" val moqTokenCliVersion = "0.5.23" -val interopInstallMoqRelay by tasks.registering(Exec::class) { +val interopInstallMoqRelay = tasks.register("interopInstallMoqRelay") { description = "cargo install moq-relay $moqRelayVersion (interop)" group = "interop" commandLine( @@ -178,7 +178,7 @@ val interopInstallMoqRelay by tasks.registering(Exec::class) { doFirst { hangInteropCacheDir.asFile.mkdirs() } } -val interopInstallMoqTokenCli by tasks.registering(Exec::class) { +val interopInstallMoqTokenCli = tasks.register("interopInstallMoqTokenCli") { description = "cargo install moq-token-cli $moqTokenCliVersion (interop)" group = "interop" commandLine( @@ -201,7 +201,7 @@ val interopInstallMoqTokenCli by tasks.registering(Exec::class) { doFirst { hangInteropCacheDir.asFile.mkdirs() } } -val interopBuildSidecars by tasks.registering(Exec::class) { +val interopBuildSidecars = tasks.register("interopBuildSidecars") { description = "cargo build --release for nestsClient/tests/hang-interop sidecars" group = "interop" workingDir = hangInteropDir.asFile @@ -226,7 +226,7 @@ val interopBuildSidecars by tasks.registering(Exec::class) { outputs.dir(hangInteropDir.dir("target/release")) } -val interopBuildHangSidecars by tasks.registering { +val interopBuildHangSidecars = tasks.register("interopBuildHangSidecars") { description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)." group = "interop" dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli) @@ -305,7 +305,7 @@ fun resolveBunBinary(): String { fun resolveNpxBinary(): String = System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx" -val interopBuildBrowserHarness by tasks.registering(Exec::class) { +val interopBuildBrowserHarness = tasks.register("interopBuildBrowserHarness") { description = "bun install && bun build for the browser interop harness" group = "interop" workingDir = browserInteropDir.asFile @@ -325,7 +325,7 @@ val interopBuildBrowserHarness by tasks.registering(Exec::class) { outputs.dir(browserInteropDir.dir("dist")) } -val interopInstallPlaywrightChromium by tasks.registering(Exec::class) { +val interopInstallPlaywrightChromium = tasks.register("interopInstallPlaywrightChromium") { description = "Install Playwright Chromium + dependencies for the browser interop harness" group = "interop" workingDir = browserInteropDir.asFile diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 33adaa8851..57c0aa4b8b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -56,6 +56,10 @@ internal class Nip46Test { sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", ) + // The round trip is only type-safe by construction: the caller hands in a T, + // the message is encoded and decoded, and the decoder returns the same + // BunkerMessage subtype. The runtime has no way to check that. + @Suppress("UNCHECKED_CAST") suspend fun encodeDecodeEvent(req: T): T { val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson() diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.apple.kt new file mode 100644 index 0000000000..4bf7fc3821 --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.apple.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +actual fun BigDecimal.toLongValue(): Long = toLong() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 698be085ec..b1cb1f8f79 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -760,7 +760,7 @@ class MarmotInboundProcessor( val author = payloadAuthor(candidate.content.decodeToString()) val sender = candidate.senderAccount val valid = author != null && sender != null && author == sender - if (valid && sender != null) { + if (valid) { convergence.recordWitness(groupId, candidate.stateId, sender) } return GroupEventResult.AppMessageOnCandidateBranch( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt index f1ed9c8d75..91b0760946 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt @@ -561,7 +561,7 @@ class MarmotConvergenceEngine( return mutex.withLock { val ctx = contexts[groupId] ?: return@withLock null - if (rewound && selectedTipId != null) { + if (rewound) { adoptBranch(ctx, graph, selectedTipId, inputs.baseId) } // Keep the states of branches that LOST but stay eligible: losing @@ -576,7 +576,7 @@ class MarmotConvergenceEngine( .forEach { tipId -> var cursor: String? = tipId while (cursor != null && cursor !in canonical) { - graph.statesById[cursor]?.let { ctx.candidateStates[cursor!!] = it } + graph.statesById[cursor]?.let { ctx.candidateStates[cursor] = it } cursor = graph.parentOf[cursor] } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 9520dd9848..33a01e0e92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd @@ -212,7 +213,13 @@ class RelaySession( } is IEventStore.InsertOutcome.Rejected -> { - send(OkMessage(cmd.event.id, false, outcome.reason)) + // NIP-01: an event the relay already holds is acknowledged with + // `OK true` and the `duplicate:` prefix. Every real client + // (amethyst's outbox included) resends an event whose OK has not + // landed yet, and treats OK false as a rejection to surface — + // so answering false here turns a routine resend into an error. + val duplicate = outcome.reason.startsWith(RejectionReason.PREFIX_DUPLICATE) + send(OkMessage(cmd.event.id, duplicate, outcome.reason)) } is IEventStore.InsertOutcome.Failed -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt index 4e14cb2550..00d6ccc7dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -44,8 +44,21 @@ object RejectionReason { */ const val PREFIX_REPLACED = "replaced:" + /** NIP-01 prefix for "already have this event" — answered with `OK true`, not false. */ + const val PREFIX_DUPLICATE = "duplicate:" + // The standard store reasons. const val DUPLICATE = "duplicate: already have this event" + + /** + * A replaceable or addressable event that a stored version already supersedes + * (newer `created_at`, or the same `created_at` and a lower id). Nothing is + * written, and — like [DUPLICATE] — the relay answers `OK true`: NIP-01 keeps + * `duplicate:` as the machine-readable prefix for "already covered", and that + * is what nostr-rs-relay sends here too, so clients that retry on anything + * else (MDK's `wn`) settle instead of re-offering the same event forever. + */ + const val SUPERSEDED = "duplicate: a newer version of this replaceable event is already stored" const val EXPIRED = "blocked: Cannot insert an expired event" const val DELETED = "blocked: a deletion event exists" const val VANISHED = "blocked: a request to vanish event exists" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 1b3643543c..a56fb390f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -62,6 +62,14 @@ class SQLiteEventStore( val extraPragmas: List = emptyList(), ) { companion object { + /** SQLite's message for the unique index on `event_headers (id)`. */ + private const val DUPLICATE_ID_CONSTRAINT = "UNIQUE constraint failed: event_headers.id" + + /** + * Common prefix of SQLite's messages for `replaceable_idx` (`kind, pubkey`) and + * `addressable_idx` (`kind, pubkey, d_tag`) — both start with these two columns. + */ + private const val SUPERSEDED_CONSTRAINT = "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey" const val DATABASE_VERSION = 5 } @@ -526,6 +534,20 @@ class SQLiteEventStore( */ private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome { val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED + // A second copy of an event the store already holds trips the unique index on + // event_headers.id. That is not a refusal of the event but a statement that it + // is already here, and NIP-01 has a dedicated answer for it (`OK true` with the + // `duplicate:` prefix) — so name it, instead of leaking SQLite's constraint text + // for the session to turn into a rejection the client then retries or reports. + if (message.contains(DUPLICATE_ID_CONSTRAINT)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) + } + // The replaceable / addressable unique indexes fire only when the supersession + // trigger found nothing older to delete, i.e. the stored version already wins + // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. + if (message.contains(SUPERSEDED_CONSTRAINT)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) + } val refusal = message.contains("blocked:") || message.contains("duplicate:") || diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt index 571f812db1..be65f1dac7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt @@ -23,3 +23,15 @@ package com.vitorpamplona.quartz.utils operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other) operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other) + +/** + * Truncate to a Long, the way Number.toLong() does on every platform. + * + * It has to be an expect *function* rather than a member of `expect class + * BigDecimal`: every actual is already a Number and so already has toLong(), + * but java.math.BigDecimal leaves toByte()/toShort() abstract, which makes + * `expect class BigDecimal : Number` impossible to actualize with the JVM + * typealias. Without this, `amount.toLong()` in shared code resolves only in + * the platform compilations and breaks `compileCommonMainKotlinMetadata`. + */ +expect fun BigDecimal.toLongValue(): Long diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt index 192b013387..af50790016 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/AuthOutcomeTest.kt @@ -61,7 +61,7 @@ class AuthOutcomeTest { phase: RelayAuthSnapshot.Phase, successCount: Int = 0, ) { - state.value = state.value.put(relay, RelayAuthSnapshot(phase, null, successCount)) + state.value = state.value.putting(relay, RelayAuthSnapshot(phase, null, successCount)) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 7171a9e4a0..bde070947f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -120,8 +120,13 @@ class NostrServerTest { server.close() } + /** + * NIP-01: `["OK", , true, "duplicate: already have this event"]`. A client + * resends any event whose OK has not landed, so a duplicate must read as + * success — OK false would make every such resend look like a rejection. + */ @Test - fun duplicateEventReturnsOkFalse() = + fun duplicateEventReturnsOkTrueWithDuplicatePrefix() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) @@ -138,7 +143,72 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) assertTrue(okMessages[0].contains(",true,")) - assertTrue(okMessages[1].contains(",false,")) + assertTrue(okMessages[1].contains(",true,")) + assertTrue(okMessages[1].contains("duplicate:")) + + server.close() + } + + /** + * STORE-W01: a replaceable event older than the stored version is not written, + * and the relay acknowledges it the way nostr-rs-relay does — `OK true` with the + * NIP-01 `duplicate:` prefix — rather than leaking the unique-index text as a + * rejection the client would keep retrying. + */ + @Test + fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val newer = testEvent(id = hexId(2), kind = 0, createdAt = 2000L) + val older = testEvent(id = hexId(3), kind = 0, createdAt = 1000L) + c1.insert(newer) + c1.insert(older) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",true,"), "older version must be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains("duplicate:"), "older version must carry the duplicate: prefix") + + val stored = store.query(Filter(kinds = listOf(0))) + assertEquals(listOf(newer.id), stored.map { it.id }, "the newer version stays the only stored one") + + server.close() + } + + /** + * STORE-W02 tie: two addressable events with the same `d` tag and the same + * `created_at` — the lower id wins, the other is acknowledged as superseded. + * This is the exact shape MDK's `wn keys publish` produces when it mints a + * second KeyPackage within the same second as the first. + */ + @Test + fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val dTag = arrayOf(arrayOf("d", "kp")) + val lowerId = testEvent(id = hexId(4), kind = 30443, createdAt = 5000L, tags = dTag) + val higherId = testEvent(id = hexId(5), kind = 30443, createdAt = 5000L, tags = dTag) + c1.insert(lowerId) + c1.insert(higherId) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains(",true,"), "tie loser must be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains("duplicate:")) + + val stored = store.query(Filter(kinds = listOf(30443))) + assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie") server.close() } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.jvmAndroid.kt new file mode 100644 index 0000000000..4bf7fc3821 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.jvmAndroid.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +actual fun BigDecimal.toLongValue(): Long = toLong() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt index b9feeeca5a..403aa6e217 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt @@ -245,8 +245,8 @@ class LastResortKeyPackageReuseTest { override suspend fun load(): ByteArray? = bytes - override suspend fun save(data: ByteArray) { - bytes = data + override suspend fun save(snapshot: ByteArray) { + bytes = snapshot } override suspend fun delete() { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt index 9095a17bcf..7a2992d0ce 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/prodbench/InternTradeoffBenchmark.kt @@ -110,10 +110,16 @@ class InternTradeoffBenchmark { } } + /** + * Holds the last measured corpus so the JIT cannot drop the allocations we + * just paid for. A field rather than a local: a local's assignments are + * visible to the compiler's data flow, which then folds the `check` below + * into a constant. + */ + private var sink: Any? = null + @Test fun internCostAndBenefit() { - var sink: Any? = null - println("\n=== corpus: $EVENTS events, $AUTHORS authors, $RELAYS relays ===") // ---- memory ---- diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt index 8f44b686cb..8cfa6fef3c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt @@ -131,7 +131,7 @@ class IndexableContentGoldenTest { } } val rejoined = visited.joinToString(event.indexableSeparator()) - if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)!!} content=${event.indexableContent().take(120)}" + if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)} content=${event.indexableContent().take(120)}" } assertEquals("visitor and indexed content disagree", emptyList(), disagreements) } diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.linux.kt new file mode 100644 index 0000000000..4bf7fc3821 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.linux.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +actual fun BigDecimal.toLongValue(): Long = toLong() diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt index c1a20bed55..929b2ef6c4 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt @@ -203,9 +203,7 @@ class Http3GetClient( body += frame.body } - else -> { - Unit - } + else -> {} } } }