Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5

# Conflicts:
#	commons/build.gradle.kts
#	quartz/build.gradle.kts
This commit is contained in:
Claude
2026-09-12 23:11:15 +00:00
99 changed files with 914 additions and 476 deletions
@@ -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
+5 -3
View File
@@ -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
+12 -3
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
}
}
@@ -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)
@@ -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)
}
@@ -166,7 +166,8 @@ class NamecoinSharedPreferences(
} else {
emptyList()
}
} catch (_: Exception) {
} catch (e: Exception) {
if (e is CancellationException) throw e
emptyList()
}
@@ -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)
}
}
@@ -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,
@@ -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<Int> =
setOf(
// Direct-arrival
@@ -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,
@@ -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
}
@@ -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()) {
@@ -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
}
@@ -118,6 +118,5 @@ open class UserFeedViewModel(
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
bundler.cancel()
super.onCleared()
}
}
@@ -2647,7 +2647,6 @@ class AccountViewModel(
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
.clear()
feedStates.destroy()
super.onCleared()
}
fun loadMentions(
@@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() {
override fun onCleared() {
stopObserving()
stopWatching()
super.onCleared()
}
/** One decrypted observer telemetry frame rendered on the Observer tab. */
@@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() {
override fun onCleared() {
stopWatching()
super.onCleared()
}
companion object {
@@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() {
override fun onCleared() {
liveJob?.cancel()
liveJob = null
super.onCleared()
}
}
@@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() {
override fun onCleared() {
stopWatching()
super.onCleared()
}
companion object {
@@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() {
override fun onCleared() {
stopWatching()
super.onCleared()
}
companion object {
@@ -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<Locale, DateTimeFormatter>()
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),
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
override fun onCleared() {
liveScanJob?.cancel()
super.onCleared()
}
fun toggle(address: Address) {
@@ -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,
@@ -801,7 +801,6 @@ class ChatNewMessageViewModel :
}
override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -1027,7 +1027,6 @@ open class ChannelNewMessageViewModel :
}
override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -132,7 +132,6 @@ class ChessViewModelNew(
fun clearFocusedGame() = logic.clearFocusedGame()
override fun onCleared() {
super.onCleared()
logic.stopPolling()
}
@@ -739,7 +739,6 @@ class LongFormPostViewModel :
}
override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -612,7 +612,6 @@ open class NewProductViewModel :
}
override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -1977,7 +1977,6 @@ open class ShortNotePostViewModel :
}
override fun onCleared() {
super.onCleared()
writingAssistant?.close()
writingAssistant = null
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
@@ -320,7 +320,6 @@ class VoiceReplyViewModel : ViewModel() {
override fun onCleared() {
cancel()
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
}
@@ -102,7 +102,6 @@ class AddToMusicPlaylistViewModel : ViewModel() {
override fun onCleared() {
liveScanJob?.cancel()
super.onCleared()
}
private suspend fun rescan() {
@@ -597,7 +597,6 @@ open class NestNewMessageViewModel :
}
override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -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,
@@ -660,7 +660,6 @@ class NewPublicMessageViewModel :
}
override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -196,6 +196,5 @@ class RelayFeedViewModel :
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
super.onCleared()
}
}
@@ -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<HexKey, NormalizedRelayUrl>()
// (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<String>()
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<HexKey>,
) {
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)
}
}
}
}
@@ -116,6 +116,5 @@ open class StringFeedViewModel(
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
bundler.cancel()
super.onCleared()
}
}
@@ -180,7 +180,6 @@ open class NewHlsVideoViewModel : ViewModel() {
}
override fun onCleared() {
super.onCleared()
currentJob?.cancel()
}
@@ -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 {
@@ -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()
}
}
@@ -293,7 +293,6 @@ class CashuWalletWizardViewModel : ViewModel() {
}
override fun onCleared() {
super.onCleared()
discovery?.cancel()
}
}
-6
View File
@@ -1,13 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="transparent">#00FFFFFF</color>
<!-- Launch splash / window background. Tracks MaterialTheme's background so the
first composed frame does not step to a different colour. See values-night. -->
@@ -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<Event>
@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
@@ -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 {
@@ -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 {
@@ -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 =
@@ -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<Event>(1, content) { tagged?.let { pTag(it) } })
private fun legacyDm(
author: NostrSignerSync,
recipient: HexKey,
): Event = author.sign(eventTemplate<Event>(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<Event> = 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)
}
}
@@ -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
+9 -9
View File
@@ -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
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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.
+1 -1
View File
@@ -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)
+1
View File
@@ -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/
+46 -39
View File
@@ -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 0105
│ ├── tests-manage.sh # tests 0608, 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://<laptop-LAN-ip>: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://<laptop-LAN-ip>: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
+11 -11
View File
@@ -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"
+15 -17
View File
@@ -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
+7 -29
View File
@@ -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 ---------------------------------------------------
+1 -1
View File
@@ -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
+89
View File
@@ -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.
+10 -11
View File
@@ -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
+81 -24
View File
@@ -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://<laptop-ip>: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://<your-LAN-ip>:8080 (physical device on same Wi-Fi)
ws://10.0.2.2:$RELAY_PORT (Android emulator)
ws://<your-LAN-ip>:$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
+6 -94
View File
@@ -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" <<EOF
[info]
relay_url = "$RELAY_URL"
name = "amethyst-headless-harness"
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
[database]
data_directory = "$RELAY_DATA"
[network]
address = "${RELAY_BIND:-${RELAY_HOST:-127.0.0.1}}"
port = $RELAY_PORT
[options]
reject_future_seconds = 3600
[limits]
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
# exercising Marmot traffic the public relays reject.
max_event_bytes = 524288
max_ws_message_bytes = 1048576
max_ws_frame_bytes = 1048576
EOF
# Abort early if something else is already bound to the port — failing
# with a clear error beats a mysterious-looking daemon stall later.
if ss -ltn 2>/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
}
@@ -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() }
@@ -68,6 +68,5 @@ abstract class FeedViewModel(
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
super.onCleared()
}
}
@@ -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)
}
}
@@ -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,
)
@@ -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
@@ -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) {
@@ -461,9 +461,9 @@ class MarmotPublishBeforeApplyTest {
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
epochs: List<ByteArray>,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = epochs
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
@@ -105,9 +105,9 @@ class MarmotPublishDurabilityTest {
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
epochs: List<ByteArray>,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = epochs
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId].orEmpty()
+3 -3
View File
@@ -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<Exec>("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")
@@ -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 ->
+1 -1
View File
@@ -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)
@@ -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:)")
}
/**
+1 -1
View File
@@ -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
+4
View File
@@ -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" }
@@ -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"
+6 -6
View File
@@ -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<Exec>("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<Exec>("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<Exec>("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<Exec>("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<Exec>("interopInstallPlaywrightChromium") {
description = "Install Playwright Chromium + dependencies for the browser interop harness"
group = "interop"
workingDir = browserInteropDir.asFile
@@ -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 <T : BunkerMessage> encodeDecodeEvent(req: T): T {
val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson()
@@ -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()
@@ -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(
@@ -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]
}
}
@@ -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 -> {
@@ -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"
@@ -62,6 +62,14 @@ class SQLiteEventStore(
val extraPragmas: List<String> = 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:") ||
@@ -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
@@ -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))
}
}
@@ -120,8 +120,13 @@ class NostrServerTest {
server.close()
}
/**
* NIP-01: `["OK", <id>, 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<Event>(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<Event>(Filter(kinds = listOf(30443)))
assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie")
server.close()
}
@@ -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()
@@ -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() {
@@ -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 ----
@@ -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<String>(), disagreements)
}
@@ -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()
@@ -203,9 +203,7 @@ class Http3GetClient(
body += frame.body
}
else -> {
Unit
}
else -> {}
}
}
}