mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge origin/main (notify block-relay button, payment-target dialog)
Conflicts: - DisplayPaymentTargets.kt: main's model.User import superseded by the commons User import; setText import dropped (main's rewrite no longer uses it). - strings.xml: kept only main's genuinely new notify_block_relay key; thread_title and send_the_seller_a_message already migrated to commons. Also re-added the R import in NotifyRequestDialog.kt for the new R.string.notify_block_relay usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
This commit is contained in:
@@ -194,6 +194,64 @@ jobs:
|
||||
name: geode Test Reports
|
||||
path: geode/build/reports
|
||||
|
||||
# Until this job existed nothing ran the linuxX64 target at all — it was compiled by
|
||||
# no CI leg. That is how a copy-on-write LargeCache with O(n) writes and a non-atomic
|
||||
# read-copy-write (concurrent writers silently dropped entries) sat in the tree
|
||||
# unnoticed, and how TestResourceLoader stayed a TODO() that failed every vector-driven
|
||||
# suite on the target.
|
||||
#
|
||||
# Runs the whole :quartz suite on a Linux Native frontend, which also catches a
|
||||
# commonMain or commonTest source reaching for a JVM-only API on a target that, unlike
|
||||
# Apple, has no Foundation to fall back on.
|
||||
test-quartz-linux-native:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
# The Kotlin/Native toolchain (compiler distribution + LLVM + the sysroot) lands
|
||||
# in ~/.konan, which setup-gradle does not cache. Without this the job re-downloads
|
||||
# well over a gigabyte on every run. Keyed on the version catalog so a Kotlin bump
|
||||
# re-populates it.
|
||||
- name: Cache Kotlin/Native toolchain
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.konan
|
||||
key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}
|
||||
restore-keys: konan-${{ runner.os }}-
|
||||
|
||||
- name: Test Quartz on Linux Native
|
||||
run: ./gradlew :quartz:linuxX64Test
|
||||
|
||||
- name: Linux Native Test Report
|
||||
uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
|
||||
if: always()
|
||||
with:
|
||||
report_paths: 'quartz/build/test-results/linuxX64Test/TEST-*.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
fail_on_failure: true
|
||||
|
||||
- name: Upload Linux Native Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: Quartz Linux Native Test Reports
|
||||
path: quartz/build/reports
|
||||
|
||||
test-quartz-ios:
|
||||
# Phase 1 of the iOS support plan
|
||||
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The feed's animated elements defer building their `Transition` until a value actually changes,
|
||||
* because first composition has nothing to animate and building one per card per scroll is pure
|
||||
* waste (measured: roughly half the composition cost of every reaction-row button).
|
||||
*
|
||||
* The whole point of deferring rather than removing is that the animation must still play. These
|
||||
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
|
||||
* happens right after the transition is lazily created — still shows outgoing and incoming content
|
||||
* simultaneously, which only a running animation does. A regression that turned the deferral into a
|
||||
* plain snap would show exactly one of them and fail here.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DeferredAnimationTest {
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun deferredCrossfadeStillAnimatesTheFirstChange() {
|
||||
val state = mutableStateOf("A")
|
||||
rule.mainClock.autoAdvance = false
|
||||
|
||||
rule.setContent {
|
||||
DeferredCrossfade(
|
||||
targetState = state.value,
|
||||
modifier = Modifier,
|
||||
contentAlignment = Alignment.TopStart,
|
||||
animationSpec = tween(DURATION_MS),
|
||||
label = "test",
|
||||
) { value ->
|
||||
Text(value, modifier = Modifier.testTag("text_$value"))
|
||||
}
|
||||
}
|
||||
|
||||
// Before any change the transition has not been built, and only the current value renders.
|
||||
rule.onNodeWithTag("text_A").assertIsDisplayed()
|
||||
rule.onNodeWithTag("text_B").assertDoesNotExist()
|
||||
|
||||
state.value = "B"
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
|
||||
|
||||
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
|
||||
rule.onNodeWithTag("text_A").assertExists()
|
||||
rule.onNodeWithTag("text_B").assertExists()
|
||||
|
||||
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
|
||||
rule.onNodeWithTag("text_B").assertIsDisplayed()
|
||||
rule.onNodeWithTag("text_A").assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DURATION_MS = 300
|
||||
}
|
||||
}
|
||||
@@ -3543,6 +3543,15 @@ class Account(
|
||||
|
||||
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
|
||||
|
||||
/**
|
||||
* Blocks a single relay, leaving the rest of the kind-10006 list alone.
|
||||
*
|
||||
* Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient]
|
||||
* strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as
|
||||
* the subscriptions that wanted it are recomputed.
|
||||
*/
|
||||
suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay))
|
||||
|
||||
/**
|
||||
* Returns all known signed replaceable events that configure this account
|
||||
* (profile, contact list, relay lists, mute list, bookmarks, etc.). Events
|
||||
|
||||
+14
@@ -73,6 +73,20 @@ class BlockedRelayListState(
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Adds [relay] to the kind-10006 list, keeping whatever is already there.
|
||||
*
|
||||
* Callers that only want to block one relay (the NOTIFY prompt's "Block Relay" button, for
|
||||
* instance) must not rebuild the list from a snapshot they captured earlier: the list is
|
||||
* shared across clients and may have grown since. Reading the current note here keeps the
|
||||
* add additive.
|
||||
*/
|
||||
suspend fun addRelay(relay: NormalizedRelayUrl): BlockedRelayListEvent {
|
||||
val current = normalizeBlockedRelayListWithBackup(blockedListNote).toMutableList()
|
||||
if (relay !in current) current.add(relay)
|
||||
return saveRelayList(current)
|
||||
}
|
||||
|
||||
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
|
||||
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
|
||||
val relayListForBlocked = getBlockedRelayList()
|
||||
|
||||
+20
@@ -57,6 +57,26 @@ fun DisplayNotifyMessages(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onDismiss = { requests.dismissPaymentRequest(request) },
|
||||
onBlockRelay =
|
||||
if (accountViewModel.isWriteable()) {
|
||||
{
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.blockRelay(request.relayUrl)
|
||||
|
||||
// Reached only once the block is signed and published, because
|
||||
// reportSignerErrors swallows a refused or timed-out signature without
|
||||
// a toast: dismissing up front would close the dialog on a relay that
|
||||
// is still unblocked and leave the user no sign anything failed. The
|
||||
// prompt staying up is the feedback.
|
||||
//
|
||||
// Every queued prompt from the relay goes at once, not just the one on
|
||||
// screen — a paid relay files one NOTIFY per rejected AUTH.
|
||||
requests.dismissAllFrom(request.relayUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -30,11 +30,13 @@ import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
@@ -56,6 +58,11 @@ fun NotifyRequestDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
/**
|
||||
* Adds the relay that sent this message to the NIP-51 kind:10006 blocked list. Null hides the
|
||||
* button — there is nothing to block for a read-only account, which cannot sign the list.
|
||||
*/
|
||||
onBlockRelay: (() -> Unit)? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -95,5 +102,29 @@ fun NotifyRequestDialog(
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton =
|
||||
onBlockRelay?.let {
|
||||
{
|
||||
TextButton(
|
||||
onClick = it,
|
||||
contentPadding = PaddingValues(horizontal = Size16dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Block,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.notify_block_relay),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+9
-2
@@ -102,8 +102,15 @@ class NotifyCoordinator(
|
||||
// Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution.
|
||||
// An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than
|
||||
// risk surfacing it under the wrong account.
|
||||
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey)
|
||||
account?.relayNotifications?.addPaymentRequestIfNew(message, relay)
|
||||
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) ?: return
|
||||
|
||||
// A relay the user has already blocked doesn't get to keep prompting. The pool drops the
|
||||
// socket once the subscriptions that wanted this relay are recomputed, but frames already
|
||||
// in flight can still arrive in that window — and the whole point of the dialog's "Block
|
||||
// Relay" button is that the dialog stops coming back.
|
||||
if (relay in account.blockedRelayList.flow.value) return
|
||||
|
||||
account.relayNotifications.addPaymentRequestIfNew(message, relay)
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
@@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import androidx.collection.mutableScatterMapOf
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -54,7 +56,53 @@ fun <T> CrossfadeIfEnabled(
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Latches the first time a crossfade's target moves off the value it was composed with. */
|
||||
private class ChangeLatch {
|
||||
var changed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
|
||||
* is something to animate.
|
||||
*
|
||||
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
|
||||
* composition*, even though first composition has nothing to cross-fade — target and initial state
|
||||
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
|
||||
* animated element, and during a scroll essentially none of them run, because the underlying counts
|
||||
* and icons do not change in the second a card is on screen.
|
||||
*
|
||||
* So the plain content renders until the target actually moves. At that point the transition is
|
||||
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
|
||||
* the new one, so the first real change still animates exactly as before; every later change
|
||||
* animates through the now-live transition normally.
|
||||
*/
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun <T> DeferredCrossfade(
|
||||
targetState: T,
|
||||
modifier: Modifier,
|
||||
contentAlignment: Alignment,
|
||||
animationSpec: FiniteAnimationSpec<Float>,
|
||||
label: String,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val initial = remember { targetState }
|
||||
val latch = remember { ChangeLatch() }
|
||||
if (targetState != initial) latch.changed = true
|
||||
|
||||
if (!latch.changed) {
|
||||
Box(modifier, contentAlignment) {
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
val transitionState = remember { MutableTransitionState(initial) }
|
||||
transitionState.targetState = targetState
|
||||
val transition = rememberTransition(transitionState, label)
|
||||
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,6 @@ private tailrec fun Context.getActivityWindow(): Window? =
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getActivity(): Activity = LocalContext.current.getActivity()
|
||||
|
||||
tailrec fun Context.getActivity(): ComponentActivity =
|
||||
when (this) {
|
||||
is ComponentActivity -> this
|
||||
|
||||
@@ -26,8 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
@@ -35,8 +35,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
|
||||
/** How the app shell presents its top-level navigation for the current window size. */
|
||||
enum class NavigationStyle {
|
||||
@@ -44,12 +44,13 @@ enum class NavigationStyle {
|
||||
BOTTOM_BAR,
|
||||
|
||||
/**
|
||||
* Medium windows (portrait tablets, unfolded foldables): a left navigation rail
|
||||
* replaces the bottom bar; the drawer stays modal behind the rail's avatar button.
|
||||
* Every non-Compact window that does not dock — portrait tablets and unfolded foldables at
|
||||
* any width, plus short landscape windows: a left navigation rail replaces the bottom bar
|
||||
* and the drawer stays modal behind the rail's avatar button.
|
||||
*/
|
||||
NAV_RAIL,
|
||||
|
||||
/** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */
|
||||
/** Wide, landscape, tall windows (landscape tablets, desktop): the drawer docks permanently on the left. */
|
||||
PERMANENT_DRAWER,
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ enum class NavigationStyle {
|
||||
@Immutable
|
||||
data class ScreenLayoutSpec(
|
||||
val navigationStyle: NavigationStyle,
|
||||
val showsNotificationPanel: Boolean,
|
||||
val hasRoomForNotificationPanel: Boolean,
|
||||
) {
|
||||
/**
|
||||
* True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin
|
||||
@@ -69,16 +70,18 @@ data class ScreenLayoutSpec(
|
||||
val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR
|
||||
|
||||
companion object {
|
||||
val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false)
|
||||
val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, hasRoomForNotificationPanel = false)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone }
|
||||
|
||||
/**
|
||||
* Minimum window width for the docked notification panel: the permanent drawer
|
||||
* ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth])
|
||||
* only coexist comfortably from a landscape-tablet-sized window up.
|
||||
* Minimum window width for the docked notification panel: a leading navigation pane, a
|
||||
* readable center pane and the panel ([NotificationPanelWidth]) only coexist comfortably from
|
||||
* a landscape-tablet-sized window up. Sized against the widest leading pane, the permanent
|
||||
* drawer ([PermanentDrawerWidth]); the rail is narrower, so a railed window that clears this
|
||||
* gets a roomier center pane rather than a tighter one.
|
||||
*/
|
||||
private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200
|
||||
|
||||
@@ -95,6 +98,53 @@ val NotificationPanelWidth = 360.dp
|
||||
*/
|
||||
val FeedContentMaxWidth = 600.dp
|
||||
|
||||
/**
|
||||
* Minimum window height for the docked drawer. Higher than Material's 480dp Compact/Medium
|
||||
* height boundary on purpose: the permanent drawer's own header — banner, avatar, status
|
||||
* editor, follower counts — fills most of a ~540dp column before the first navigation row, so
|
||||
* below this the rail shows more of the menu than the dock does.
|
||||
*/
|
||||
private const val DOCK_MIN_WINDOW_HEIGHT_DP = 600
|
||||
|
||||
/**
|
||||
* The navigation tier for a window of this shape.
|
||||
*
|
||||
* The dock is not a width decision. A tablet is past the Expanded breakpoint in both
|
||||
* orientations, so keying on width alone pins 300dp of menu open in portrait with no closed
|
||||
* state to fall back on (issue #4024). It docks only when the window is wide, landscape, and
|
||||
* tall enough for the drawer's own content to be usable; everything else that is not Compact
|
||||
* falls through to the rail, which pairs with the existing swipe-in modal drawer.
|
||||
*
|
||||
* A square window counts as landscape and docks; `Configuration.ORIENTATION_LANDSCAPE`
|
||||
* breaks that tie the other way, so the two disagree at exactly width == height.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
internal fun decideNavigationStyle(
|
||||
windowWidthDp: Int,
|
||||
windowHeightDp: Int,
|
||||
): NavigationStyle {
|
||||
val widthSizeClass =
|
||||
WindowSizeClass
|
||||
.calculateFromSize(DpSize(windowWidthDp.dp, windowHeightDp.dp))
|
||||
.widthSizeClass
|
||||
|
||||
return when {
|
||||
widthSizeClass == WindowWidthSizeClass.Expanded &&
|
||||
windowWidthDp >= windowHeightDp &&
|
||||
windowHeightDp >= DOCK_MIN_WINDOW_HEIGHT_DP -> NavigationStyle.PERMANENT_DRAWER
|
||||
widthSizeClass != WindowWidthSizeClass.Compact -> NavigationStyle.NAV_RAIL
|
||||
else -> NavigationStyle.BOTTOM_BAR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the window is wide enough to dock the notification feed beside the content.
|
||||
*
|
||||
* Deliberately not keyed on [NavigationStyle]: a wide portrait window now gets the rail, and
|
||||
* gating on the dock would strip a panel it has today.
|
||||
*/
|
||||
internal fun hasRoomForNotificationPanel(windowWidthDp: Int): Boolean = windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP
|
||||
|
||||
/**
|
||||
* Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme
|
||||
* background so the gutters match the screens' own surfaces; on Compact windows the cap is
|
||||
@@ -119,23 +169,15 @@ fun CappedScreenContent(content: @Composable () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
fun rememberScreenLayoutSpec(): ScreenLayoutSpec {
|
||||
val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass
|
||||
val windowWidthDp = LocalConfiguration.current.screenWidthDp
|
||||
return remember(widthSizeClass, windowWidthDp) {
|
||||
val style =
|
||||
when (widthSizeClass) {
|
||||
WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER
|
||||
WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL
|
||||
else -> NavigationStyle.BOTTOM_BAR
|
||||
}
|
||||
val configuration = LocalConfiguration.current
|
||||
val windowWidthDp = configuration.screenWidthDp
|
||||
val windowHeightDp = configuration.screenHeightDp
|
||||
return remember(windowWidthDp, windowHeightDp) {
|
||||
ScreenLayoutSpec(
|
||||
navigationStyle = style,
|
||||
showsNotificationPanel =
|
||||
style == NavigationStyle.PERMANENT_DRAWER &&
|
||||
windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP,
|
||||
navigationStyle = decideNavigationStyle(windowWidthDp, windowHeightDp),
|
||||
hasRoomForNotificationPanel = hasRoomForNotificationPanel(windowWidthDp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -864,11 +865,7 @@ private fun SlidingAnimationCount(
|
||||
if (accountViewModel.settings.isPerformanceMode()) {
|
||||
TextCount(baseCount, textColor)
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = baseCount,
|
||||
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec,
|
||||
label = "SlidingAnimationCount",
|
||||
) { count ->
|
||||
DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count ->
|
||||
TextCount(count, textColor)
|
||||
}
|
||||
}
|
||||
@@ -891,6 +888,48 @@ val slideAnimation: ContentTransform =
|
||||
),
|
||||
)
|
||||
|
||||
/** Latches the first time an animated counter's value moves off the one it was composed with. */
|
||||
private class CountChangeLatch {
|
||||
var changed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* An [AnimatedContent] that does not build its transition until the value actually changes.
|
||||
*
|
||||
* Same reasoning as `DeferredCrossfade`: `AnimatedContent` builds a transition plus its content map
|
||||
* and size animation on first composition, but first composition has nothing to animate. A reaction
|
||||
* counter only slides when the count moves, which practically never happens in the second a card
|
||||
* spends on screen during a scroll — so the apparatus was built and thrown away, once per counter
|
||||
* per card.
|
||||
*
|
||||
* Rendering the bare content until the first change, then seeding a [MutableTransitionState] at the
|
||||
* original value, keeps that first change animated exactly as before.
|
||||
*/
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
private fun <T> DeferredAnimatedContent(
|
||||
targetState: T,
|
||||
label: String,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val initial = remember { targetState }
|
||||
val latch = remember { CountChangeLatch() }
|
||||
if (targetState != initial) latch.changed = true
|
||||
|
||||
if (!latch.changed) {
|
||||
content(targetState)
|
||||
} else {
|
||||
val transitionState = remember { MutableTransitionState(initial) }
|
||||
transitionState.targetState = targetState
|
||||
val transition = rememberTransition(transitionState, label)
|
||||
transition.AnimatedContent(
|
||||
transitionSpec = { transitionSpec() },
|
||||
) { value ->
|
||||
content(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextCount(
|
||||
count: Int,
|
||||
@@ -918,11 +957,7 @@ fun SlidingAnimationAmount(
|
||||
maxLines = 1,
|
||||
)
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = amount,
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
|
||||
label = "SlidingAnimationAmount",
|
||||
) { count ->
|
||||
DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = Font14SP,
|
||||
|
||||
+72
-27
@@ -138,8 +138,10 @@ fun AccountSwitcherAndLeftDrawerLayout(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact and Medium windows: the drawer slides in as a modal sheet. On Medium an
|
||||
* [AppNavigationRail] sits at the left edge in place of the phone bottom bar.
|
||||
* Every window that does not dock the drawer: the drawer slides in as a modal sheet. On the
|
||||
* rail tier an [AppNavigationRail] sits at the left edge in place of the phone bottom bar and
|
||||
* the shell becomes multi-pane — a wide portrait window rails and is still wide enough for
|
||||
* the notification panel.
|
||||
*/
|
||||
@Composable
|
||||
private fun ModalDrawerShell(
|
||||
@@ -183,11 +185,12 @@ private fun ModalDrawerShell(
|
||||
},
|
||||
content = {
|
||||
if (showRail) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
AppNavigationRail(nav, accountViewModel)
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
CenterPane(Modifier.weight(1f), content)
|
||||
}
|
||||
MultiPaneShell(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
leading = { AppNavigationRail(nav, accountViewModel) },
|
||||
content = content,
|
||||
)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
@@ -196,8 +199,62 @@ private fun ModalDrawerShell(
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded windows: the drawer is permanently docked on the left, the bottom bar disappears,
|
||||
* and — when the window is wide enough — the notification feed docks on the right.
|
||||
* The wide-window arrangement both shells render: a [leading] navigation pane, the centre
|
||||
* content, and the notification feed when the window has room. Shared because a wide portrait
|
||||
* window now rails rather than docks and is still wide enough for the panel — the two shells
|
||||
* differ only in which navigation pane leads.
|
||||
*/
|
||||
@Composable
|
||||
private fun MultiPaneShell(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
leading: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
leading()
|
||||
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
|
||||
CenterPane(Modifier.weight(1f), content)
|
||||
|
||||
NotificationSidePanelSlot(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The docked notification feed, when there is room for it. The panel duplicates the
|
||||
* Notifications screen, so it steps aside while the user is there.
|
||||
*
|
||||
* The back-stack entry is collected here rather than in the shells so windows too narrow for
|
||||
* the panel never observe it, and so navigation churn recomposes this slot instead of the
|
||||
* whole shell. The trade is that a rail-tier window wide enough for the panel ends up with a
|
||||
* second collector beside [ModalDrawerShell]'s own — one extra subscriber on a shared flow,
|
||||
* against a shell restart per navigation on every window that cannot show the panel.
|
||||
*
|
||||
* [Route] matching goes through `remember` because `hasRoute` resolves a serializer
|
||||
* reflectively on every call.
|
||||
*/
|
||||
@Composable
|
||||
private fun NotificationSidePanelSlot(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
if (!LocalScreenLayout.current.hasRoomForNotificationPanel) return
|
||||
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
val destination = navBackStackEntry?.destination
|
||||
val onNotifications = remember(destination) { destination?.hasRoute<Route.Notification>() == true }
|
||||
|
||||
if (!onNotifications) {
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
NotificationSidePanel(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wide, landscape, tall windows: the drawer is permanently docked on the left, the bottom bar
|
||||
* disappears, and — when the window is wide enough — the notification feed docks on the right.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermanentDrawerShell(
|
||||
@@ -206,24 +263,12 @@ private fun PermanentDrawerShell(
|
||||
openSheet: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
// The panel duplicates the Notifications screen, so it steps aside while the user is there.
|
||||
val showPanel =
|
||||
LocalScreenLayout.current.showsNotificationPanel &&
|
||||
navBackStackEntry?.destination?.hasRoute<Route.Notification>() != true
|
||||
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
PermanentDrawerContent(nav, openSheet, accountViewModel)
|
||||
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
|
||||
CenterPane(Modifier.weight(1f), content)
|
||||
|
||||
if (showPanel) {
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
NotificationSidePanel(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
MultiPaneShell(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
leading = { PermanentDrawerContent(nav, openSheet, accountViewModel) },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+57
-87
@@ -20,48 +20,24 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Lightning-family target types Amethyst can pay in-app through the Send Payment screen. */
|
||||
private val LIGHTNING_TARGET_TYPES = setOf("lightning", "ln", "lnurl")
|
||||
@@ -97,6 +73,15 @@ fun inAppPaymentRouteFor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The URI an external wallet app should receive for [target]: the type's own
|
||||
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905
|
||||
* `payto://` for types Amethyst has no dedicated scheme for. Shared with the
|
||||
* payment-target dialog so the same pill hands off to the same app wherever
|
||||
* it is tapped.
|
||||
*/
|
||||
fun paymentTargetUri(target: PaymentTarget): String = paymentTargetStyleFor(target.type).uriFor(target.authority)
|
||||
|
||||
/**
|
||||
* Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s
|
||||
* FlowRow alongside the wallet-rail chips so all payment chips share one
|
||||
@@ -111,72 +96,57 @@ fun PaymentTargetChip(
|
||||
) {
|
||||
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val copyLabel = stringRes(R.string.copy_to_clipboard)
|
||||
val copiedMessage = stringRes(R.string.copied_to_clipboard)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = style.color.copy(alpha = 0.10f),
|
||||
border = BorderStroke(1.dp, style.color.copy(alpha = 0.35f)),
|
||||
modifier =
|
||||
Modifier.combinedClickable(
|
||||
onClick = {
|
||||
// Targets one of the user's in-app wallets can pay (lightning,
|
||||
// bitcoin) go to the Send Payment screen, which collects the
|
||||
// amount and pays this exact target; everything else hands off
|
||||
// to an external wallet app via its payment URI.
|
||||
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
|
||||
if (inAppRoute != null) {
|
||||
nav.nav(inAppRoute)
|
||||
} else {
|
||||
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
|
||||
.onFailure {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_payment_error,
|
||||
R.string.no_payment_app_found_for_type,
|
||||
style.label,
|
||||
)
|
||||
}
|
||||
PaymentTargetPill(
|
||||
target = target,
|
||||
onClick = {
|
||||
// Targets one of the user's in-app wallets can pay (lightning,
|
||||
// bitcoin) go to the Send Payment screen, which collects the
|
||||
// amount and pays this exact target; everything else hands off
|
||||
// to an external wallet app via its payment URI.
|
||||
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
|
||||
if (inAppRoute != null) {
|
||||
nav.nav(inAppRoute)
|
||||
} else {
|
||||
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
|
||||
.onFailure {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_payment_error,
|
||||
R.string.no_payment_app_found_for_type,
|
||||
style.label,
|
||||
)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboard.setText(target.authority)
|
||||
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onLongClickLabel = copyLabel,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill for a single NIP-A3 payment target: the type's icon and tinted
|
||||
* label followed by the shortened authority, with a long-press copy of the
|
||||
* full authority. Shared by the profile's payment rail and the payment-target
|
||||
* dialog so a target looks the same wherever it shows up.
|
||||
*/
|
||||
@Composable
|
||||
fun PaymentTargetPill(
|
||||
target: PaymentTarget,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
|
||||
|
||||
ProfilePaymentChip(
|
||||
color = style.color,
|
||||
label = style.label,
|
||||
detail = remember(target.authority) { shortAddress(target.authority) },
|
||||
copyValue = target.authority,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = style.symbol,
|
||||
contentDescription = style.label,
|
||||
tint = style.color,
|
||||
modifier = Size16Modifier,
|
||||
)
|
||||
Text(
|
||||
text = style.label,
|
||||
color = style.color,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = shortAddress(target.authority),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 180.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = style.symbol,
|
||||
contentDescription = null,
|
||||
tint = style.color,
|
||||
modifier = Size16Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-34
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -47,11 +47,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -75,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -156,6 +154,7 @@ fun PaymentTargetsDialog(
|
||||
payInApp: ((PaymentTarget) -> Boolean)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
@@ -191,15 +190,13 @@ fun PaymentTargetsDialog(
|
||||
},
|
||||
onPay = {
|
||||
if (payInApp?.invoke(target) != true) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.startActivity(intent)
|
||||
onDismiss()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
errorMessage = stringRes(context, R.string.no_payment_app_found)
|
||||
}
|
||||
// Same handoff the profile chip does: the type's own
|
||||
// scheme when it has one (payto:// only as the
|
||||
// fallback), and no task flags — CLEAR_TASK used to
|
||||
// wipe whatever the wallet app already had open.
|
||||
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
|
||||
.onSuccess { onDismiss() }
|
||||
.onFailure { errorMessage = stringRes(context, R.string.no_payment_app_found) }
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -235,19 +232,13 @@ private fun PaymentTargetRow(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = target.type.replaceFirstChar(Char::titlecase),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = target.authority,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Same pill the profile page renders for this target: type icon,
|
||||
// tinted type label and the shortened authority, instead of the raw
|
||||
// wallet id spelled out over two lines. Tapping it pays and long-press
|
||||
// copies, exactly like on the profile, so the row carries no separate
|
||||
// pay button — three icon buttons left the address 0dp of width.
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
PaymentTargetPill(target = target, onClick = onPay)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = onShowQr) {
|
||||
@@ -266,14 +257,6 @@ private fun PaymentTargetRow(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPay) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = stringRes(R.string.payment_targets),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -295,6 +295,10 @@ fun ProfilePaymentChip(
|
||||
color = color,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// Narrow hosts (the payment-target dialog's row) would otherwise
|
||||
// wrap a long type label into a two-line pill.
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (detail != null) {
|
||||
Text(
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -49,7 +47,6 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -712,24 +709,15 @@ fun AmethystTheme(
|
||||
fontSize: FontSizeType = FontSizeType.NORMAL,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Deliberately no UiModeManager.nightMode write: changing the device night mode needs
|
||||
// MODIFY_DAY_NIGHT_MODE, which this app does not declare, so the call silently no-ops — and it
|
||||
// ran on every recomposition of the theme, writing device state from inside composition. The
|
||||
// in-app choice is applied through the colour scheme below, which is what actually took effect.
|
||||
val darkTheme =
|
||||
when (prefTheme) {
|
||||
ThemeType.DARK -> {
|
||||
val uiManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||
uiManager.nightMode = UiModeManager.MODE_NIGHT_YES
|
||||
true
|
||||
}
|
||||
|
||||
ThemeType.LIGHT -> {
|
||||
val uiManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||
uiManager.nightMode = UiModeManager.MODE_NIGHT_NO
|
||||
false
|
||||
}
|
||||
|
||||
else -> {
|
||||
isSystemInDarkTheme()
|
||||
}
|
||||
ThemeType.DARK -> true
|
||||
ThemeType.LIGHT -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val colors =
|
||||
remember(darkTheme, accentColor) {
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.Amethyst" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/purple_700</item>
|
||||
<!-- Pinned to the brand colour rather than left to inherit the theme background.
|
||||
The system builds the launch splash from this theme before the process starts,
|
||||
using the *system* light/dark config — but the in-app ThemeType may be pinned to
|
||||
the opposite, so an inherited background flashes the wrong colour before the UI
|
||||
appears (white before a dark UI, or black before a light one). A fixed brand
|
||||
colour reads as an intentional splash in every combination. -->
|
||||
<item name="android:windowSplashScreenBackground" tools:targetApi="31">@color/purple_700</item>
|
||||
<item name="android:windowBackground">@color/black</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
|
||||
</style>
|
||||
|
||||
@@ -1732,6 +1732,7 @@
|
||||
|
||||
|
||||
<string name="payment_required_title">Message from %1$s</string>
|
||||
<string name="notify_block_relay">Block Relay</string>
|
||||
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.Amethyst" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/purple_700</item>
|
||||
<!-- Pinned to the brand colour rather than left to inherit the theme background.
|
||||
The system builds the launch splash from this theme before the process starts,
|
||||
using the *system* light/dark config — but the in-app ThemeType may be pinned to
|
||||
the opposite, so an inherited background flashes the wrong colour before the UI
|
||||
appears (white before a dark UI, or black before a light one). A fixed brand
|
||||
colour reads as an intentional splash in every combination. -->
|
||||
<item name="android:windowSplashScreenBackground" tools:targetApi="31">@color/purple_700</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
|
||||
</style>
|
||||
<style name="noAnimTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Drives the real kind-10006 mutation behind the NOTIFY dialog's "Block Relay" button.
|
||||
*
|
||||
* The button hands a single relay to [BlockedRelayListState.addRelay], which read-modify-writes the
|
||||
* whole list — and [BlockedRelayListEvent.updateRelayList] *replaces* every relay tag with what it
|
||||
* is given. So "does blocking work" is really two questions this pins: the new relay lands, and the
|
||||
* relays already blocked survive. A regression here silently wipes the user's block list on their
|
||||
* next tap, which no amount of UI testing would make obvious.
|
||||
*
|
||||
* Each case uses a fresh keypair so its kind-10006 lives at its own address in the process-wide
|
||||
* [LocalCache].
|
||||
*/
|
||||
class BlockedRelayListAddRelayTest {
|
||||
private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com")
|
||||
private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com")
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
|
||||
// where null == null reads as "main". Distinct mocks make it a worker thread.
|
||||
mockkStatic(Looper::class)
|
||||
every { Looper.myLooper() } returns mockk<Looper>()
|
||||
every { Looper.getMainLooper() } returns mockk<Looper>()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(Looper::class)
|
||||
}
|
||||
|
||||
private class Fixture {
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
val decryptionCache = BlockedRelayListDecryptionCache(signer)
|
||||
|
||||
// Stubbed rather than real: AccountSettings reaches for Resources.getSystem() to read the
|
||||
// user's spoken languages, which is null outside an Android runtime. The state only asks it
|
||||
// for the backup list (none here) and hands it updates to persist.
|
||||
val settings = mockk<AccountSettings>(relaxed = true) { every { backupBlockedRelayList } returns null }
|
||||
|
||||
val state =
|
||||
BlockedRelayListState(
|
||||
signer = signer,
|
||||
cache = LocalCache,
|
||||
decryptionCache = decryptionCache,
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
settings = settings,
|
||||
)
|
||||
|
||||
/** Mirrors what the publish path does: the signed event goes into the cache it came from. */
|
||||
fun land(event: BlockedRelayListEvent) = LocalCache.justConsumeMyOwnEvent(event)
|
||||
|
||||
suspend fun blockedIn(event: BlockedRelayListEvent) = BlockedRelayListDecryptionCache(signer).relays(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingTheFirstRelayCreatesTheList() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
|
||||
val event = f.state.addRelay(paid)
|
||||
|
||||
assertEquals(BlockedRelayListEvent.KIND, event.kind)
|
||||
assertEquals(setOf(paid), f.blockedIn(event))
|
||||
}
|
||||
|
||||
/** The one that matters: a second tap must not throw away the first block. */
|
||||
@Test
|
||||
fun blockingASecondRelayKeepsTheFirst() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
f.land(f.state.addRelay(paid))
|
||||
|
||||
val event = f.state.addRelay(alsoPaid)
|
||||
|
||||
assertEquals(setOf(paid, alsoPaid), f.blockedIn(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
f.land(f.state.addRelay(paid))
|
||||
|
||||
val event = f.state.addRelay(paid)
|
||||
|
||||
assertEquals(setOf(paid), f.blockedIn(event))
|
||||
assertEquals(1, BlockedRelayListDecryptionCache(f.signer).relays(event).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocked relays are private tags. Leaking them to public tags would publish which paid relays
|
||||
* the user walked away from to anyone who reads their kind-10006.
|
||||
*/
|
||||
@Test
|
||||
fun blockedRelaysStayInEncryptedPrivateTags() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
f.land(f.state.addRelay(paid))
|
||||
|
||||
val event = f.state.addRelay(alsoPaid)
|
||||
|
||||
assertTrue("blocked relays must not appear in public tags", event.publicRelays().isEmpty())
|
||||
assertTrue(
|
||||
"no relay url may appear in the cleartext tag array",
|
||||
event.tags.none { tag -> tag.any { it.contains("paid.example.com") || it.contains("other.example.com") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NotifyRequestsCacheTest {
|
||||
private val paid = NormalizedRelayUrl("wss://paid.relay/")
|
||||
private val other = NormalizedRelayUrl("wss://other.relay/")
|
||||
|
||||
@Test
|
||||
fun blockingARelayDropsEveryPendingPromptFromIt() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
cache.addPaymentRequestIfNew("Still unpaid", paid)
|
||||
cache.addPaymentRequestIfNew("Unrelated", other)
|
||||
|
||||
cache.dismissAllFrom(paid)
|
||||
|
||||
assertEquals(
|
||||
setOf(NotifyRequest(other, "Unrelated")),
|
||||
cache.transientPaymentRequests.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDismissedPromptStaysDismissedWhenTheRelayRepeatsIt() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
|
||||
cache.dismissAllFrom(paid)
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
|
||||
assertTrue(cache.transientPaymentRequests.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aConcurrentPromptFromTheSameRelayIsNotSilentlySwallowed() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
|
||||
// Stands in for a NOTIFY landing on the socket coroutine while the UI drains the relay:
|
||||
// whatever survives the drain must still be reachable, never removed-but-unrecorded.
|
||||
cache.dismissAllFrom(paid)
|
||||
cache.addPaymentRequestIfNew("A different demand", paid)
|
||||
|
||||
val pending = cache.transientPaymentRequests.value
|
||||
val dismissed = cache.transientPaymentRequestDismissals.value
|
||||
|
||||
assertEquals(setOf(NotifyRequest(paid, "Pay up")), dismissed)
|
||||
assertEquals(setOf(NotifyRequest(paid, "A different demand")), pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissingARelayWithNoPromptsChangesNothing() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Unrelated", other)
|
||||
|
||||
cache.dismissAllFrom(paid)
|
||||
|
||||
assertEquals(
|
||||
setOf(NotifyRequest(other, "Unrelated")),
|
||||
cache.transientPaymentRequests.value,
|
||||
)
|
||||
assertTrue(cache.transientPaymentRequestDismissals.value.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.layouts
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The tier rule from amethyst/plans/2026-08-31-portrait-sidebar-tier-rule.md:
|
||||
*
|
||||
* dock <=> widthClass == Expanded && width >= height && height >= 600dp
|
||||
*
|
||||
* Sizes are window dp, width first. Every row of the spec's Behaviour table appears here.
|
||||
*/
|
||||
class ScreenLayoutTest {
|
||||
private fun assertStyle(
|
||||
expected: NavigationStyle,
|
||||
widthDp: Int,
|
||||
heightDp: Int,
|
||||
) = assertEquals("${widthDp}x${heightDp}dp", expected, decideNavigationStyle(widthDp, heightDp))
|
||||
|
||||
// ---- Behaviour table ----
|
||||
|
||||
@Test
|
||||
fun phonePortraitKeepsTheBottomBar() = assertStyle(NavigationStyle.BOTTOM_BAR, 411, 923)
|
||||
|
||||
@Test
|
||||
fun phoneLandscapeNoLongerDocks() = assertStyle(NavigationStyle.NAV_RAIL, 923, 411)
|
||||
|
||||
@Test
|
||||
fun reporterTabletPortraitNoLongerDocks() = assertStyle(NavigationStyle.NAV_RAIL, 889, 1422)
|
||||
|
||||
@Test
|
||||
fun wideTabletPortraitNoLongerDocks() = assertStyle(NavigationStyle.NAV_RAIL, 1201, 1920)
|
||||
|
||||
@Test
|
||||
fun mediumTabletPortraitStillRails() = assertStyle(NavigationStyle.NAV_RAIL, 800, 1280)
|
||||
|
||||
@Test
|
||||
fun tabletLandscapeStillDocks() = assertStyle(NavigationStyle.PERMANENT_DRAWER, 1422, 889)
|
||||
|
||||
@Test
|
||||
fun mediumTabletLandscapeStillDocks() = assertStyle(NavigationStyle.PERMANENT_DRAWER, 1280, 800)
|
||||
|
||||
@Test
|
||||
fun wideButShortLandscapeNoLongerDocks() = assertStyle(NavigationStyle.NAV_RAIL, 1200, 540)
|
||||
|
||||
// ---- Boundaries ----
|
||||
|
||||
@Test
|
||||
fun squareWindowAtTheWidthBreakpointDocks() = assertStyle(NavigationStyle.PERMANENT_DRAWER, 840, 840)
|
||||
|
||||
@Test
|
||||
fun portraitByOneDpDoesNotDock() = assertStyle(NavigationStyle.NAV_RAIL, 840, 841)
|
||||
|
||||
@Test
|
||||
fun exactlyAtTheHeightFloorDocks() = assertStyle(NavigationStyle.PERMANENT_DRAWER, 840, 600)
|
||||
|
||||
@Test
|
||||
fun oneDpBelowTheHeightFloorDoesNotDock() = assertStyle(NavigationStyle.NAV_RAIL, 840, 599)
|
||||
|
||||
@Test
|
||||
fun oneDpBelowTheWidthBreakpointRails() = assertStyle(NavigationStyle.NAV_RAIL, 839, 600)
|
||||
|
||||
@Test
|
||||
fun compactWidthKeepsTheBottomBar() = assertStyle(NavigationStyle.BOTTOM_BAR, 599, 900)
|
||||
|
||||
// ---- Notification panel ----
|
||||
|
||||
@Test
|
||||
fun panelHiddenJustBelowTheThreshold() = assertFalse(hasRoomForNotificationPanel(1199))
|
||||
|
||||
@Test
|
||||
fun panelShownExactlyAtTheThreshold() = assertTrue(hasRoomForNotificationPanel(1200))
|
||||
|
||||
/**
|
||||
* A landscape-short window: wide enough for the panel, too short for the dock. Rail plus
|
||||
* panel is a combination that has never shipped, so assert both halves together.
|
||||
*/
|
||||
@Test
|
||||
fun shortLandscapeYieldsRailPlusPanel() {
|
||||
assertStyle(NavigationStyle.NAV_RAIL, 1200, 599)
|
||||
assertTrue(hasRoomForNotificationPanel(1200))
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -73,12 +73,25 @@ class BlockedRelayFilteringClient(
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val blocked = blockedRelays()
|
||||
delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked)
|
||||
delegate.publish(event, if (blocked.hitsNoneOf(relayList)) relayList else relayList - blocked)
|
||||
}
|
||||
|
||||
private fun Map<NormalizedRelayUrl, List<Filter>>.withoutBlocked(): Map<NormalizedRelayUrl, List<Filter>> {
|
||||
val blocked = blockedRelays()
|
||||
if (blocked.isEmpty()) return this
|
||||
if (blocked.hitsNoneOf(keys)) return this
|
||||
return filterKeys { it !in blocked }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether none of the blocked relays appear in [targets] — i.e. whether the caller can be
|
||||
* handed its own collection back untouched.
|
||||
*
|
||||
* Worth the extra scan because this runs on every REQ, COUNT and publish (filter assemblers
|
||||
* rebuild their per-relay targets constantly) while a blocked relay is, by definition, one the
|
||||
* app has stopped aiming at — so "the block list is non-empty but irrelevant to this call" is
|
||||
* the overwhelmingly common case. Without the check, `filterKeys` / `minus` allocate and copy
|
||||
* the whole collection every time just to reproduce it unchanged. A hash lookup per target and
|
||||
* no allocation beats an allocation plus a full copy.
|
||||
*/
|
||||
private fun Set<NormalizedRelayUrl>.hitsNoneOf(targets: Collection<NormalizedRelayUrl>): Boolean = isEmpty() || targets.none { it in this }
|
||||
}
|
||||
|
||||
+26
-6
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.notify
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
@Stable
|
||||
@@ -38,12 +39,12 @@ class NotifyRequestsCache {
|
||||
}
|
||||
|
||||
fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) {
|
||||
if (
|
||||
!this.transientPaymentRequests.value.contains(paymentRequest) &&
|
||||
!this.transientPaymentRequestDismissals.value.contains(paymentRequest)
|
||||
) {
|
||||
this.transientPaymentRequests.value += paymentRequest
|
||||
}
|
||||
if (this.transientPaymentRequestDismissals.value.contains(paymentRequest)) return
|
||||
|
||||
// `update` rather than `value +=`: NOTIFYs are filed from the relay's socket coroutine
|
||||
// while dismissals run from the UI, and a plain read-modify-write silently drops one of
|
||||
// two concurrent edits — either losing a prompt or resurrecting a dismissed one.
|
||||
this.transientPaymentRequests.update { if (paymentRequest in it) it else it + paymentRequest }
|
||||
}
|
||||
|
||||
fun dismissPaymentRequest(request: NotifyRequest) {
|
||||
@@ -52,4 +53,23 @@ class NotifyRequestsCache {
|
||||
this.transientPaymentRequestDismissals.update { it + request }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every pending prompt from [relayUrl] at once.
|
||||
*
|
||||
* Used when the user blocks the relay: a relay that asks for payment usually queues one NOTIFY
|
||||
* per rejected AUTH, so dismissing them one at a time would keep re-showing the dialog for a
|
||||
* relay the user just told us never to talk to again.
|
||||
*/
|
||||
fun dismissAllFrom(relayUrl: NormalizedRelayUrl) {
|
||||
// getAndUpdate so the drain and the snapshot of what was drained are one atomic step: a
|
||||
// NOTIFY filed by the socket coroutine between a separate read and write would otherwise
|
||||
// be dropped from the pending set without ever being recorded as dismissed.
|
||||
val before = this.transientPaymentRequests.getAndUpdate { pending -> pending.filterNotTo(mutableSetOf()) { it.relayUrl == relayUrl } }
|
||||
|
||||
val dismissed = before.filterTo(mutableSetOf()) { it.relayUrl == relayUrl }
|
||||
if (dismissed.isEmpty()) return
|
||||
|
||||
this.transientPaymentRequestDismissals.update { it + dismissed }
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
|
||||
class BlockedRelayFilteringClientTest {
|
||||
private val good = NormalizedRelayUrl("wss://good.example/")
|
||||
@@ -133,6 +134,24 @@ class BlockedRelayFilteringClientTest {
|
||||
assertEquals(emptySet(), inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBlockListThatTouchesNothingHereIsPassedThroughWithoutCopying() {
|
||||
val inner = RecordingClient()
|
||||
// Non-empty block list, but none of it is aimed at on this call — the common case once the
|
||||
// user has blocked anything at all.
|
||||
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
|
||||
|
||||
val filters = mapOf(good to listOf(Filter()), alsoGood to listOf(Filter()))
|
||||
val relays = setOf(good, alsoGood)
|
||||
client.subscribe("sub", filters, null)
|
||||
client.publish(event(), relays)
|
||||
|
||||
// Identity, not just equality: reproducing an unchanged map/set costs an allocation and a
|
||||
// full copy on a path that runs on every REQ and publish.
|
||||
assertSame(filters, inner.subscribedFilters)
|
||||
assertSame(relays, inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockSetIsReadPerCallSoLaterChangesApply() {
|
||||
val inner = RecordingClient()
|
||||
|
||||
@@ -55,7 +55,6 @@ media3 = "1.11.0"
|
||||
mockk = "1.14.11"
|
||||
kotlinx-coroutines-test = "1.11.0"
|
||||
negentropyKmp = "v1.2.0"
|
||||
netUrlencoderLibVersion = "1.6.0"
|
||||
navigationCompose = "2.9.8"
|
||||
okhttp = "5.5.0"
|
||||
osmdroid = "6.1.20"
|
||||
@@ -209,7 +208,6 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
||||
negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", version.ref = "negentropyKmp" }
|
||||
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
||||
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||
|
||||
@@ -289,7 +289,6 @@ kotlin {
|
||||
dependsOn(nativeMain)
|
||||
dependencies {
|
||||
implementation(libs.charlietap.cachemap)
|
||||
implementation(libs.net.thauvin.erik.urlencoder.lib)
|
||||
implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal)
|
||||
implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4")
|
||||
implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4")
|
||||
@@ -347,7 +346,6 @@ kotlin {
|
||||
create("linuxMain") {
|
||||
dependsOn(nativeMain)
|
||||
dependencies {
|
||||
implementation(libs.net.thauvin.erik.urlencoder.lib)
|
||||
implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +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.quartz.utils
|
||||
|
||||
import net.thauvin.erik.urlencoder.UrlEncoderUtil
|
||||
|
||||
actual object UrlEncoder {
|
||||
actual fun encode(value: String): String = UrlEncoderUtil.encode(value)
|
||||
|
||||
actual fun decode(value: String): String = UrlEncoderUtil.decode(value)
|
||||
}
|
||||
@@ -40,6 +40,25 @@ package com.vitorpamplona.quartz.nip50Search
|
||||
* once values are pre-joined a backend can't unmix them. Values produced by
|
||||
* [SearchFieldExtractor] are trimmed and non-empty; the types themselves do
|
||||
* not enforce it.
|
||||
*
|
||||
* ## PROFILE XOR TIERED — a contract, not an implementation detail
|
||||
*
|
||||
* A kind fills the profile roles or the content roles, NEVER both. The sealed
|
||||
* type enforces it today, and weighted backends are entitled to depend on it:
|
||||
* a ranker that scores the two role groups independently and SUMS them stays
|
||||
* correct only while no document can answer from a naming column in each
|
||||
* group. A shape that filled, say, [Profile.name] and [Tiered.primary] at
|
||||
* once would claim the top band twice — in the store this extractor was
|
||||
* built for, ~260 000 against the ~130 000 a whole-field title match earns,
|
||||
* i.e. a document matching one word per column outranking one that IS the
|
||||
* query.
|
||||
*
|
||||
* So a new kind that looks like both is a deliberate decision with a known
|
||||
* downstream cost, not an accident of the extractor. Pick the shape the kind
|
||||
* really has and route the rest through it — the way kind 31990 (an app
|
||||
* handler, whose metadata is a kind-0 clone) returns [Profile] wholesale
|
||||
* rather than a profile plus a tier. If a future shape genuinely must fill
|
||||
* both, the backends that sum these groups have to be told.
|
||||
*/
|
||||
sealed interface IndexableFields {
|
||||
fun isEmpty(): Boolean
|
||||
|
||||
+225
-18
@@ -27,25 +27,42 @@ import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastAny
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.marketplace.MarketplaceEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
@@ -66,6 +83,7 @@ import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEven
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
@@ -78,6 +96,7 @@ import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
@@ -85,6 +104,7 @@ import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
@@ -92,6 +112,8 @@ import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
/**
|
||||
* Decomposes every [SearchableEvent] into [IndexableFields] by priority tier:
|
||||
@@ -148,8 +170,35 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.summary(), event.content)
|
||||
}
|
||||
|
||||
// NIP-15 kinds 30017/30018/30019/30020 -- the marketplace family
|
||||
// keeps its name and description inside a JSON `content` blob, so
|
||||
// the fallback dropped a stall/product/auction NAME into the body
|
||||
// tier, where a title can never reach the title band. Decoding
|
||||
// failures still reach the funnel, as they do for the buzz kinds.
|
||||
is StallEvent -> {
|
||||
event.stallData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
// ProductEvent.categories() is `t` under another name, so the
|
||||
// funnel already carries it in the hashtag role -- passing it
|
||||
// again would index the same words twice.
|
||||
is ProductEvent -> {
|
||||
event.productData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
is MarketplaceEvent -> {
|
||||
event.marketplaceData()?.let { tiers(event, it.name, it.about, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
is AuctionEvent -> {
|
||||
event.auctionData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
// The clone URL is as much a repository's public address as its
|
||||
// homepage is -- `github.com/owner/repo.git` is how most people
|
||||
// would search for it -- so both fill the affiliation role.
|
||||
is GitRepositoryEvent -> {
|
||||
tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = event.webs())
|
||||
tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = (event.webs() + event.clones()).distinct())
|
||||
}
|
||||
|
||||
is GitIssueEvent -> {
|
||||
@@ -238,8 +287,10 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.summary(), event.content)
|
||||
}
|
||||
|
||||
// endpoint() is the `streaming` tag -- the same role
|
||||
// LiveActivitiesEvent.streaming() fills above.
|
||||
is MeetingSpaceEvent -> {
|
||||
tiers(event, event.room(), event.summary(), event.content)
|
||||
tiers(event, event.room(), event.summary(), event.content, website = event.endpoint())
|
||||
}
|
||||
|
||||
is MeetingRoomEvent -> {
|
||||
@@ -282,10 +333,29 @@ object SearchFieldExtractor {
|
||||
tiers(event, listOf(event.title()), listOf(event.description()), null, websites = event.websites())
|
||||
}
|
||||
|
||||
// kinds 30054/30055 -- the Podcasting 2.0 pair carries the same
|
||||
// title/description shape kind 54 does and was falling through:
|
||||
// an episode title indexed as body text. topics() is hashtags()
|
||||
// under another name, so the funnel carries it once.
|
||||
is Podcasting20EpisodeEvent -> {
|
||||
tiers(event, event.title(), event.description(), event.content)
|
||||
}
|
||||
|
||||
is Podcasting20TrailerEvent -> {
|
||||
tiers(event, event.title(), null, event.content)
|
||||
}
|
||||
|
||||
is GroupMetadataEvent -> {
|
||||
tiers(event, event.name(), event.about(), null)
|
||||
}
|
||||
|
||||
// kind 9002 edits the very metadata kind 39000 publishes, so it
|
||||
// splits the same way -- it was the only half of the pair falling
|
||||
// through. Its hashtags() is `t`, carried once by the funnel.
|
||||
is EditMetadataEvent -> {
|
||||
tiers(event, event.name(), event.about(), null)
|
||||
}
|
||||
|
||||
is InterestSetEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
}
|
||||
@@ -328,12 +398,14 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.description(), null, website = event.url())
|
||||
}
|
||||
|
||||
// A site's `source` tag is the URL its files were published
|
||||
// from -- the same affiliation role a repo's homepage fills.
|
||||
is NamedSiteEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
tiers(event, event.title(), event.description(), null, website = event.source())
|
||||
}
|
||||
|
||||
is RootSiteEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
tiers(event, event.title(), event.description(), null, website = event.source())
|
||||
}
|
||||
|
||||
is RootNappletEvent -> {
|
||||
@@ -412,6 +484,53 @@ object SearchFieldExtractor {
|
||||
tiers(event, null, event.summary(), event.content)
|
||||
}
|
||||
|
||||
// kinds 1065/1163 -- summary-only kinds. Their whole searchable
|
||||
// text IS a summary, so it belongs in the summary tier, next to
|
||||
// kind 1063's, rather than in the body tier the fallback gave it.
|
||||
is FileStorageHeaderEvent -> {
|
||||
tiers(event, null, event.summary(), null)
|
||||
}
|
||||
|
||||
is ProfileGalleryEntryEvent -> {
|
||||
tiers(event, null, event.summary(), null)
|
||||
}
|
||||
|
||||
// kind 2473 -- a sighting IS its species, under both names: the
|
||||
// scientific one from the `n` tag and the vernacular one
|
||||
// commonName() parses out of the `alt`. The `alt` itself still
|
||||
// goes to the summary tier whole. It is usually just Birdstar's
|
||||
// boilerplate wrapper around those two names ("Bird detection:
|
||||
// <Common> (<Scientific>)"), so this repeats them in a second
|
||||
// role -- but only commonName()'s PREFIX match decides that the
|
||||
// alt is boilerplate, and a publisher can write anything after
|
||||
// the parenthetical. Dropping the alt on a prefix match lost that
|
||||
// tail from every role, which is precisely the drift against
|
||||
// indexableContent() this file exists to prevent: the repeat
|
||||
// costs a duplicate in the weakest role, the drop cost recall.
|
||||
is BirdDetectionEvent -> {
|
||||
tiers(event, listOf(event.speciesName(), event.commonName()), listOf(event.summary()), null)
|
||||
}
|
||||
|
||||
// kind 12473 is a life LIST, not a sighting: its species names are
|
||||
// an unbounded collection, so they sit in the secondary tier with
|
||||
// a torrent's file names rather than claiming the title band once
|
||||
// per bird.
|
||||
is BirdexEvent -> {
|
||||
tiers(event, emptyList(), listOf(event.summary()) + event.speciesNames(), null)
|
||||
}
|
||||
|
||||
// kind 38192 -- the save's title is a title; region and filename
|
||||
// are the keywords beside it, like a torrent's file names.
|
||||
is Ps1SaveEvent -> {
|
||||
tiers(event, listOf(event.saveTitle()), listOf(event.summary(), event.region(), event.filename()), null)
|
||||
}
|
||||
|
||||
// kind 38383 -- an order is looked up by who is offering it;
|
||||
// currency and payment methods are the keywords that qualify it.
|
||||
is P2POrderEvent -> {
|
||||
tiers(event, listOf(event.makerName()), listOf(event.currency()) + event.paymentMethods().orEmpty(), null)
|
||||
}
|
||||
|
||||
is AudioTrackEvent -> {
|
||||
tiers(event, event.subject(), null, null)
|
||||
}
|
||||
@@ -458,6 +577,46 @@ object SearchFieldExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
// kind 1010 -- `content` is the proposed replacement text and
|
||||
// `summary` describes the edit, so they split the way kind 1063's
|
||||
// summary and body do (indexableContent concatenates them in the
|
||||
// other order; the roles, not the order, are what a weighted
|
||||
// backend reads).
|
||||
is TextNoteModificationEvent -> {
|
||||
tiers(event, null, event.summary(), event.content)
|
||||
}
|
||||
|
||||
// kinds 1068/6969 -- the question is the body, the option labels
|
||||
// are short answer-like values a searcher matches whole, so they
|
||||
// sit in the secondary tier UNJOINED instead of being appended to
|
||||
// the body the way indexableContent() has to.
|
||||
is PollEvent -> {
|
||||
tiers(event, emptyList(), event.options().map { it.label }, event.content)
|
||||
}
|
||||
|
||||
is ZapPollEvent -> {
|
||||
tiers(event, emptyList(), event.pollOptionsArray().map { it.descriptor }, event.content)
|
||||
}
|
||||
|
||||
// kind 1985 -- the label values are the keywords of the event;
|
||||
// the reasoning, if any, is the body.
|
||||
is LabelEvent -> {
|
||||
tiers(event, emptyList(), event.labels().map { it.label }, event.content)
|
||||
}
|
||||
|
||||
// kinds 1111/1311 -- body-only kinds whose indexableContent()
|
||||
// concatenates the `t` tags INTO the body. The funnel already
|
||||
// carries them in the hashtag role, so passing the body alone
|
||||
// stops the same words being indexed twice (the ContactCard
|
||||
// reasoning above, applied to the two chat/comment kinds).
|
||||
is CommentEvent -> {
|
||||
tiers(event, null, null, event.content)
|
||||
}
|
||||
|
||||
is LiveActivitiesChatMessageEvent -> {
|
||||
tiers(event, null, null, event.content)
|
||||
}
|
||||
|
||||
// kind 1 LAST among the explicit branches, defensively: a future
|
||||
// kind extending the text-note base must hit its own branch first.
|
||||
is TextNoteEvent -> {
|
||||
@@ -475,46 +634,94 @@ object SearchFieldExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-value convenience over the list funnel — most kinds carry one title, one summary, one body. */
|
||||
/**
|
||||
* Single-value convenience over the list funnel — most kinds carry one
|
||||
* title, one summary, one body. Cleans each value straight into its role
|
||||
* rather than wrapping it in a list first: extraction runs once per stored
|
||||
* event, and the wrappers were three throwaway lists on every one of them.
|
||||
*/
|
||||
private fun tiers(
|
||||
event: Event,
|
||||
primary: String?,
|
||||
secondary: String?,
|
||||
text: String?,
|
||||
website: String? = null,
|
||||
) = tiers(event, listOf(primary), listOf(secondary), text, listOf(website))
|
||||
) = build(event, cleanOne(primary), cleanOne(secondary), text, cleanOne(website))
|
||||
|
||||
/**
|
||||
* The one funnel every content branch uses — [IndexableFields.Tiered.hashtags]
|
||||
* and [IndexableFields.Tiered.locations] are filled here, so no branch can
|
||||
* forget them. Values stay UNJOINED: separator choices belong to the backend.
|
||||
*/
|
||||
private fun tiers(
|
||||
event: Event,
|
||||
primary: List<String?>,
|
||||
secondary: List<String?>,
|
||||
text: String?,
|
||||
websites: List<String?> = emptyList(),
|
||||
) = build(event, cleanAll(primary), cleanAll(secondary), text, cleanAll(websites))
|
||||
|
||||
/**
|
||||
* The one funnel BOTH [tiers] overloads end in — [IndexableFields.Tiered.hashtags]
|
||||
* and [IndexableFields.Tiered.locations] are filled here, so no branch can
|
||||
* forget them. Values stay UNJOINED: separator choices belong to the backend.
|
||||
*/
|
||||
private fun build(
|
||||
event: Event,
|
||||
primary: List<String>,
|
||||
secondary: List<String>,
|
||||
text: String?,
|
||||
websites: List<String>,
|
||||
) = IndexableFields.Tiered(
|
||||
primary = cleanAll(primary),
|
||||
secondary = cleanAll(secondary),
|
||||
primary = primary,
|
||||
secondary = secondary,
|
||||
text = clean(text),
|
||||
hashtags = cleanAll(event.tags.hashtags()),
|
||||
hashtags = hashtagValues(event),
|
||||
locations = locationValues(event),
|
||||
websites = cleanAll(websites),
|
||||
websites = websites,
|
||||
)
|
||||
|
||||
/** Trim and drop empties at the single funnel every derived string passes through. */
|
||||
private fun clean(s: String?): String? = s?.trim()?.ifEmpty { null }
|
||||
|
||||
private fun cleanAll(parts: List<String?>): List<String> = parts.mapNotNull { clean(it) }
|
||||
private fun cleanOne(s: String?): List<String> = clean(s)?.let { listOf(it) } ?: emptyList()
|
||||
|
||||
/**
|
||||
* Collects lazily: a role whose values are all absent — the common case on
|
||||
* most kinds — costs no list at all.
|
||||
*/
|
||||
private fun cleanAll(parts: List<String?>): List<String> {
|
||||
var values: MutableList<String>? = null
|
||||
for (i in parts.indices) {
|
||||
val value = clean(parts[i]) ?: continue
|
||||
(values ?: ArrayList<String>(parts.size).also { values = it }).add(value)
|
||||
}
|
||||
return values ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The hashtag role. [hashtags] allocates unconditionally, so the scan for
|
||||
* a `t` tag comes first — most events carry none. The guard is exactly
|
||||
* [HashtagTag.parse]'s own acceptance test, so it can never skip a tag the
|
||||
* accessor would have returned.
|
||||
*/
|
||||
private fun hashtagValues(event: Event): List<String> = if (!event.tags.fastAny(HashtagTag::isTagged)) emptyList() else cleanAll(event.tags.hashtags())
|
||||
|
||||
/**
|
||||
* Every `location` tag value, on ANY kind. Deliberately a raw scan, not a
|
||||
* typed accessor: Quartz's LocationTag classes are per-NIP (calendar,
|
||||
* picture, classifieds) and only those kinds expose locations(), while
|
||||
* this funnel must also catch location tags on kinds whose class doesn't
|
||||
* model them.
|
||||
* model them. Collects lazily, like [cleanAll]: an event with no location
|
||||
* tag — nearly all of them — allocates nothing here.
|
||||
*/
|
||||
private fun locationValues(event: Event): List<String> = event.tags.mapNotNull { tag -> if (tag.getOrNull(0) != "location") null else clean(tag.getOrNull(1)) }
|
||||
private fun locationValues(event: Event): List<String> {
|
||||
var values: MutableList<String>? = null
|
||||
event.tags.fastForEach { tag ->
|
||||
if (tag.size > 1 && tag[0] == LOCATION_TAG) {
|
||||
val value = clean(tag[1])
|
||||
if (value != null) {
|
||||
(values ?: ArrayList<String>(2).also { values = it }).add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values ?: emptyList()
|
||||
}
|
||||
|
||||
private const val LOCATION_TAG = "location"
|
||||
}
|
||||
|
||||
+245
@@ -21,16 +21,31 @@
|
||||
package com.vitorpamplona.quartz.nip50Search
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -219,4 +234,234 @@ class SearchFieldExtractorTest {
|
||||
val fields = SearchFieldExtractor.extract(MetadataEvent("9".repeat(64), alice, 1L, tags, "{}", ""))
|
||||
assertEquals(IndexableFields.None, fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marketplaceNamesReachTheTitleTierNotTheBody() {
|
||||
// NIP-15 keeps the stall's name inside a JSON content blob. Falling
|
||||
// through to the catch-all put that NAME in the body tier, where a
|
||||
// weighted backend can never rank it as a title.
|
||||
val content = """{"id":"s1","name":"Vitor's Coffee","description":"beans from Minas","currency":"BRL"}"""
|
||||
val fields = SearchFieldExtractor.extract(StallEvent("20".repeat(32), alice, 1L, arrayOf(arrayOf("d", "s1")), content, ""))
|
||||
assertEquals(IndexableFields.Tiered(primary = listOf("Vitor's Coffee"), secondary = listOf("beans from Minas")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun productCategoriesAreCarriedOnceAsHashtags() {
|
||||
// categories() is `t` under another name: indexableContent()
|
||||
// concatenates it into the flat blob, but the funnel already carries
|
||||
// it in the hashtag role, so the branch must not pass it again.
|
||||
val content = """{"id":"p1","stall_id":"s1","name":"Bag of Beans","description":"1kg","currency":"BRL","price":90.0}"""
|
||||
val tags = arrayOf(arrayOf("d", "p1"), arrayOf("t", "coffee"))
|
||||
val fields = SearchFieldExtractor.extract(ProductEvent("21".repeat(32), alice, 1L, tags, content, ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Bag of Beans"), secondary = listOf("1kg"), hashtags = listOf("coffee")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unparseableMarketplaceContentStillIndexesItsHashtags() {
|
||||
val fields = SearchFieldExtractor.extract(StallEvent("22".repeat(32), alice, 1L, arrayOf(arrayOf("t", "coffee")), "not json", ""))
|
||||
assertEquals(IndexableFields.Tiered(hashtags = listOf("coffee")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupMetadataEditsSplitLikeTheMetadataTheyEdit() {
|
||||
// kind 9002 edits what kind 39000 publishes; it was the only half of
|
||||
// the pair without a branch. Its hashtags() is `t`: carried once.
|
||||
val tags = arrayOf(arrayOf("h", "grp"), arrayOf("name", "Nostr Devs"), arrayOf("about", "we build"), arrayOf("t", "nostr"))
|
||||
val fields = SearchFieldExtractor.extract(EditMetadataEvent("23".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Nostr Devs"), secondary = listOf("we build"), hashtags = listOf("nostr")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun podcasting20EpisodesSplitLikeEveryOtherTitledKind() {
|
||||
val tags = arrayOf(arrayOf("d", "ep1"), arrayOf("title", "Episode 42"), arrayOf("description", "on search"), arrayOf("t", "podcast"))
|
||||
val fields = SearchFieldExtractor.extract(Podcasting20EpisodeEvent("24".repeat(32), alice, 1L, tags, "show notes", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Episode 42"), secondary = listOf("on search"), text = "show notes", hashtags = listOf("podcast")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun summaryOnlyKindsUseTheSummaryTier() {
|
||||
// kind 1065's whole searchable text IS a summary -- it belongs beside
|
||||
// kind 1063's, not in the body tier.
|
||||
val tags = arrayOf(arrayOf("summary", "the quarterly report"))
|
||||
val fields = SearchFieldExtractor.extract(FileStorageHeaderEvent("25".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(IndexableFields.Tiered(secondary = listOf("the quarterly report")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun birdSightingsAreNamedByTheirSpeciesUnderBothNames() {
|
||||
// The scientific name comes from the `n` tag, the vernacular one from
|
||||
// commonName()'s parse of the `alt` -- both are names, so both are
|
||||
// titles. The alt still reaches the summary tier whole.
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("n", "Porphyrio martinica"),
|
||||
arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica)"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("26".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Porphyrio martinica", "Purple Gallinule"),
|
||||
secondary = listOf("Bird detection: Purple Gallinule (Porphyrio martinica)"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBirdSightingKeepsAltTextBeyondTheParsedNames() {
|
||||
// commonName() only matches a PREFIX, so a publisher can write
|
||||
// anything after the parenthetical. Dropping the alt whenever that
|
||||
// prefix parsed lost the tail ("at Lake Merritt, 7am") from every
|
||||
// role, while indexableContent() still carried it -- the exact drift
|
||||
// this extractor exists to prevent.
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("n", "Porphyrio martinica"),
|
||||
arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica) at Lake Merritt, 7am"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("3a".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Porphyrio martinica", "Purple Gallinule"),
|
||||
secondary = listOf("Bird detection: Purple Gallinule (Porphyrio martinica) at Lake Merritt, 7am"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBirdSightingWithAnUnrecognizedAltStillIndexesIt() {
|
||||
// An alt that does not start with the known prefix parses to no
|
||||
// common name, and is carried as the summary it is.
|
||||
val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("alt", "a toucan at the feeder"))
|
||||
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("3c".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Ramphastos toco"), secondary = listOf("a toucan at the feeder")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLifeListKeepsItsSpeciesOutOfTheTitleTier() {
|
||||
// kind 12473 is an unbounded collection, not a sighting: one title
|
||||
// band per bird would dilute the tier the way a torrent's file list
|
||||
// would.
|
||||
val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("n", "Porphyrio martinica"), arrayOf("alt", "my life list"))
|
||||
val fields = SearchFieldExtractor.extract(BirdexEvent("3b".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(secondary = listOf("my life list", "Ramphastos toco", "Porphyrio martinica")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTitlesAreTitlesAndTheRestAreKeywords() {
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "save1"),
|
||||
arrayOf("title", "Final Fantasy VII"),
|
||||
arrayOf("filename", "BASCUS-94163"),
|
||||
arrayOf("region", "NTSC-U"),
|
||||
arrayOf("alt", "a memory card save"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(Ps1SaveEvent("27".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Final Fantasy VII"),
|
||||
secondary = listOf("a memory card save", "NTSC-U", "BASCUS-94163"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun p2pOrdersAreNamedByTheirMaker() {
|
||||
val tags = arrayOf(arrayOf("d", "o1"), arrayOf("name", "Satoshi"), arrayOf("f", "BRL"), arrayOf("pm", "pix", "wire"))
|
||||
val fields = SearchFieldExtractor.extract(P2POrderEvent("28".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Satoshi"), secondary = listOf("BRL", "pix", "wire")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pollOptionsAreCarriedUnjoinedInTheSecondaryTier() {
|
||||
// indexableContent() has to append the labels to the body; the roles
|
||||
// keep them separate, so the backend chooses how to weight them.
|
||||
val tags = arrayOf(arrayOf("option", "1", "Coffee"), arrayOf("option", "2", "Tea"))
|
||||
val fields = SearchFieldExtractor.extract(PollEvent("29".repeat(32), alice, 1L, tags, "what should I drink?", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(secondary = listOf("Coffee", "Tea"), text = "what should I drink?"),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labelValuesAreKeywordsNotBody() {
|
||||
val tags = arrayOf(arrayOf("l", "spam", "report"), arrayOf("L", "report"))
|
||||
val fields = SearchFieldExtractor.extract(LabelEvent("2b".repeat(32), alice, 1L, tags, "obvious bot", ""))
|
||||
assertEquals(IndexableFields.Tiered(secondary = listOf("spam"), text = "obvious bot"), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentHashtagsAreNotIndexedTwice() {
|
||||
// indexableContent() concatenates the `t` tags INTO the body for kind
|
||||
// 1111; the funnel already carries them in the hashtag role, so the
|
||||
// branch passes the body alone.
|
||||
val tags = arrayOf(arrayOf("t", "nostr"))
|
||||
val fields = SearchFieldExtractor.extract(CommentEvent("2c".repeat(32), alice, 1L, tags, "good point", ""))
|
||||
assertEquals(IndexableFields.Tiered(text = "good point", hashtags = listOf("nostr")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repositoriesAreFindableByCloneUrlAndHomepage() {
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "amethyst"),
|
||||
arrayOf("name", "Amethyst"),
|
||||
arrayOf("description", "a nostr client"),
|
||||
arrayOf("web", "https://amethyst.social"),
|
||||
arrayOf("clone", "https://github.com/vitorpamplona/amethyst.git"),
|
||||
// A repo whose homepage IS its clone URL must not index it twice.
|
||||
arrayOf("clone", "https://amethyst.social"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(GitRepositoryEvent("2d".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Amethyst"),
|
||||
secondary = listOf("a nostr client"),
|
||||
websites = listOf("https://amethyst.social", "https://github.com/vitorpamplona/amethyst.git"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meetingSpacesCarryTheirStreamingUrlLikeLiveActivitiesDo() {
|
||||
val tags = arrayOf(arrayOf("d", "room1"), arrayOf("title", "Design Sync"), arrayOf("streaming", "https://nests.example/room1"))
|
||||
val fields = SearchFieldExtractor.extract(MeetingSpaceEvent("2e".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Design Sync"), websites = listOf("https://nests.example/room1")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staticSitesCarryTheirSourceUrl() {
|
||||
val tags = arrayOf(arrayOf("d", "blog"), arrayOf("title", "My Blog"), arrayOf("source", "https://github.com/me/blog"))
|
||||
val fields = SearchFieldExtractor.extract(NamedSiteEvent("2f".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("My Blog"), websites = listOf("https://github.com/me/blog")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Cross-target contract for [UrlEncoder].
|
||||
*
|
||||
* This is not a style preference — [UrlEncoder.encode] builds strings that leave the
|
||||
* device. `TorrentEvent` puts it in magnet links, `Nip54InlineMetadata` in inline
|
||||
* metadata, and `Nip47DeepLink` in the `callback`/`appname`/`value` parameters of NWC
|
||||
* deep links. If Android encodes a title one way and iOS another, the two clients emit
|
||||
* different bytes for the same event, and a wallet that round-trips a deep link built
|
||||
* on one platform can fail on the other.
|
||||
*
|
||||
* The JVM/Android actual is `java.net.URLEncoder`/`URLDecoder` with UTF-8, so that is
|
||||
* the reference every other target has to match. The expectations below are its
|
||||
* `application/x-www-form-urlencoded` rules, which differ from plain RFC 3986
|
||||
* percent-encoding in exactly the three places a generic library gets "wrong": space,
|
||||
* `*` and `~`.
|
||||
*/
|
||||
class UrlEncoderTest {
|
||||
@Test
|
||||
fun keepsTheUnreservedSet() {
|
||||
// URLEncoder's dontNeedEncoding set is alphanumerics plus these four, and only
|
||||
// these four. Note `*` survives and `~` does not — the opposite of RFC 3986.
|
||||
assertEquals("abcXYZ019", UrlEncoder.encode("abcXYZ019"))
|
||||
assertEquals("-_.*", UrlEncoder.encode("-_.*"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesSpaceAsPlus() {
|
||||
// Form encoding, not %20.
|
||||
assertEquals("hello+world", UrlEncoder.encode("hello world"))
|
||||
assertEquals("a+b+c", UrlEncoder.encode("a b c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesTildeAndTheOtherSubDelimiters() {
|
||||
assertEquals("%7E", UrlEncoder.encode("~"))
|
||||
assertEquals("%21", UrlEncoder.encode("!"))
|
||||
assertEquals("%27", UrlEncoder.encode("'"))
|
||||
assertEquals("%28%29", UrlEncoder.encode("()"))
|
||||
assertEquals("%24%2C%3B", UrlEncoder.encode("$,;"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesUriPunctuationWithUppercaseHex() {
|
||||
assertEquals("%3A%2F%2F", UrlEncoder.encode("://"))
|
||||
assertEquals("%2B", UrlEncoder.encode("+"))
|
||||
assertEquals("%3F%26%3D", UrlEncoder.encode("?&="))
|
||||
assertEquals("%40%23%25", UrlEncoder.encode("@#%"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesNonAsciiAsUtf8() {
|
||||
assertEquals("%C3%A9", UrlEncoder.encode("é"))
|
||||
assertEquals("caf%C3%A9", UrlEncoder.encode("café"))
|
||||
assertEquals("%E2%82%AC", UrlEncoder.encode("€"))
|
||||
// Outside the BMP: a surrogate pair has to encode as one 4-byte sequence.
|
||||
assertEquals("%F0%9F%98%80", UrlEncoder.encode("😀"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesPlusAsSpace() {
|
||||
assertEquals("hello world", UrlEncoder.decode("hello+world"))
|
||||
assertEquals("hello world", UrlEncoder.decode("hello%20world"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesPercentEscapes() {
|
||||
assertEquals("://", UrlEncoder.decode("%3A%2F%2F"))
|
||||
assertEquals("~", UrlEncoder.decode("%7E"))
|
||||
assertEquals("café", UrlEncoder.decode("caf%C3%A9"))
|
||||
assertEquals("€", UrlEncoder.decode("%E2%82%AC"))
|
||||
assertEquals("😀", UrlEncoder.decode("%F0%9F%98%80"))
|
||||
// Lowercase hex decodes the same as uppercase.
|
||||
assertEquals("é", UrlEncoder.decode("%c3%a9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesUnescapedTextAlone() {
|
||||
assertEquals("plain", UrlEncoder.decode("plain"))
|
||||
assertEquals("-_.*~", UrlEncoder.decode("-_.*~"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsTheStringsThisIsActuallyUsedFor() {
|
||||
// A torrent title (TorrentEvent) and a tracker URL.
|
||||
val title = "Big Buck Bunny (2008) [1080p] ~ 60% done!"
|
||||
assertEquals(title, UrlEncoder.decode(UrlEncoder.encode(title)))
|
||||
|
||||
val tracker = "udp://tracker.example.org:1337/announce"
|
||||
assertEquals("udp%3A%2F%2Ftracker.example.org%3A1337%2Fannounce", UrlEncoder.encode(tracker))
|
||||
assertEquals(tracker, UrlEncoder.decode(UrlEncoder.encode(tracker)))
|
||||
|
||||
// An NWC pairing code (Nip47DeepLink.buildCallbackUri puts this in `value=`).
|
||||
val pairing =
|
||||
"nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4" +
|
||||
"?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5"
|
||||
assertEquals(pairing, UrlEncoder.decode(UrlEncoder.encode(pairing)))
|
||||
|
||||
// A callback deep link (Nip47DeepLink.parseConnectUri reads this back).
|
||||
val callback = "amethystnwc://callback"
|
||||
assertEquals("amethystnwc%3A%2F%2Fcallback", UrlEncoder.encode(callback))
|
||||
assertEquals(callback, UrlEncoder.decode(UrlEncoder.encode(callback)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsEveryAsciiCharacter() {
|
||||
val ascii = (0..127).map { it.toChar() }.joinToString("")
|
||||
assertEquals(ascii, UrlEncoder.decode(UrlEncoder.encode(ascii)))
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Cross-target contract for [LargeCache], the store behind `LocalCache`.
|
||||
*
|
||||
* There was no shared suite for this class: the JVM/Android actual is covered only
|
||||
* indirectly through `LocalCache`, and the linuxX64 and Apple actuals — hand-written
|
||||
* reimplementations of the same ~40 methods — were covered by nothing at all. This
|
||||
* runs the same assertions against whichever actual the target picked, so a divergence
|
||||
* shows up as a test failure instead of at runtime on one platform.
|
||||
*
|
||||
* Deliberately order-agnostic: iteration order is insertion order on linux, sorted-key
|
||||
* order on JVM/Android (`ConcurrentSkipListMap`) and hash order on Apple. Anything
|
||||
* order-sensitive is compared as a set or sorted first.
|
||||
*/
|
||||
class LargeCacheTest {
|
||||
private fun cacheOf(vararg pairs: Pair<String, Int>) =
|
||||
LargeCache<String, Int>().apply {
|
||||
pairs.forEach { put(it.first, it.second) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyCache() {
|
||||
val cache = LargeCache<String, Int>()
|
||||
|
||||
assertEquals(0, cache.size())
|
||||
assertTrue(cache.isEmpty())
|
||||
assertNull(cache.get("a"))
|
||||
assertFalse(cache.containsKey("a"))
|
||||
assertTrue(cache.keys().isEmpty())
|
||||
assertTrue(cache.values().toList().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putGetAndOverwrite() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2)
|
||||
|
||||
assertEquals(1, cache.get("a"))
|
||||
assertEquals(2, cache.get("b"))
|
||||
assertEquals(2, cache.size())
|
||||
assertFalse(cache.isEmpty())
|
||||
assertTrue(cache.containsKey("a"))
|
||||
|
||||
cache.put("a", 10)
|
||||
|
||||
assertEquals(10, cache.get("a"))
|
||||
assertEquals(2, cache.size(), "overwriting a key must not grow the cache")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeReturnsOldValue() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2)
|
||||
|
||||
assertEquals(1, cache.remove("a"))
|
||||
assertNull(cache.get("a"))
|
||||
assertFalse(cache.containsKey("a"))
|
||||
assertEquals(1, cache.size())
|
||||
|
||||
assertNull(cache.remove("a"), "removing an absent key returns null")
|
||||
assertEquals(1, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearEmptiesEverything() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2)
|
||||
|
||||
cache.clear()
|
||||
|
||||
assertEquals(0, cache.size())
|
||||
assertTrue(cache.isEmpty())
|
||||
assertNull(cache.get("a"))
|
||||
assertTrue(cache.keys().isEmpty())
|
||||
assertTrue(cache.values().toList().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getOrCreateBuildsOnlyOnce() {
|
||||
val cache = LargeCache<String, Int>()
|
||||
var builds = 0
|
||||
|
||||
assertEquals(
|
||||
7,
|
||||
cache.getOrCreate("k") {
|
||||
builds++
|
||||
7
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
7,
|
||||
cache.getOrCreate("k") {
|
||||
builds++
|
||||
99
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(1, builds, "the builder must not run for a key that is already present")
|
||||
assertEquals(7, cache.get("k"))
|
||||
assertEquals(1, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createIfAbsentReportsWhoInserted() {
|
||||
val cache = LargeCache<String, Int>()
|
||||
|
||||
assertTrue(cache.createIfAbsent("k") { 1 }, "the first call inserts")
|
||||
assertFalse(cache.createIfAbsent("k") { 2 }, "the second call must not report an insert")
|
||||
|
||||
assertEquals(1, cache.get("k"), "a losing createIfAbsent must not overwrite")
|
||||
assertEquals(1, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keysAndValuesSeeLaterWrites() {
|
||||
val cache = cacheOf("a" to 1)
|
||||
|
||||
// Reads the collections first, so an implementation that caches a snapshot has
|
||||
// one to go stale.
|
||||
assertEquals(setOf("a"), cache.keys().toSet())
|
||||
assertEquals(listOf(1), cache.values().toList())
|
||||
|
||||
cache.put("b", 2)
|
||||
|
||||
assertEquals(setOf("a", "b"), cache.keys().toSet())
|
||||
assertEquals(listOf(1, 2), cache.values().sorted())
|
||||
|
||||
cache.remove("a")
|
||||
|
||||
assertEquals(setOf("b"), cache.keys().toSet())
|
||||
assertEquals(listOf(2), cache.values().toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bulkReadsSeeLaterWrites() {
|
||||
val cache = cacheOf("a" to 1)
|
||||
|
||||
// Same idea for the collector path: warm every kind of bulk read, then mutate
|
||||
// and re-read. Guards the lazily rebuilt snapshot in the linux actual.
|
||||
assertEquals(1, cache.count { _, _ -> true })
|
||||
assertEquals(1, cache.sumOf { _, v -> v })
|
||||
|
||||
cache.put("b", 2)
|
||||
assertEquals(2, cache.count { _, _ -> true })
|
||||
assertEquals(3, cache.sumOf { _, v -> v })
|
||||
|
||||
cache.put("a", 10)
|
||||
assertEquals(12, cache.sumOf { _, v -> v }, "an overwrite must invalidate a cached read view")
|
||||
|
||||
cache.remove("b")
|
||||
assertEquals(10, cache.sumOf { _, v -> v })
|
||||
|
||||
cache.getOrCreate("c") { 5 }
|
||||
assertEquals(15, cache.sumOf { _, v -> v })
|
||||
|
||||
cache.createIfAbsent("d") { 100 }
|
||||
assertEquals(115, cache.sumOf { _, v -> v })
|
||||
|
||||
cache.clear()
|
||||
assertEquals(0, cache.count { _, _ -> true })
|
||||
assertEquals(0, cache.sumOf { _, v -> v })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forEachVisitsEveryEntry() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2, "c" to 3)
|
||||
|
||||
val seen = mutableMapOf<String, Int>()
|
||||
cache.forEach { k, v -> seen[k] = v }
|
||||
|
||||
assertEquals(mapOf("a" to 1, "b" to 2, "c" to 3), seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterAndCount() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2, "c" to 3, "d" to 4)
|
||||
|
||||
assertEquals(listOf(2, 4), cache.filter { _, v -> v % 2 == 0 }.sorted())
|
||||
assertEquals(setOf(2, 4), cache.filterIntoSet { _, v -> v % 2 == 0 })
|
||||
assertEquals(2, cache.count { _, v -> v % 2 == 0 })
|
||||
assertEquals(1, cache.count { k, _ -> k == "a" })
|
||||
assertEquals(0, cache.count { _, _ -> false })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapVariants() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2, "c" to 3)
|
||||
|
||||
assertEquals(listOf(2, 4, 6), cache.map { _, v -> v * 2 }.sorted())
|
||||
assertEquals(listOf(2, 3), cache.mapNotNull { _, v -> if (v > 1) v else null }.sorted())
|
||||
assertEquals(setOf(2, 3), cache.mapNotNullIntoSet { _, v -> if (v > 1) v else null })
|
||||
assertEquals(
|
||||
listOf(1, 1, 2, 2, 3, 3),
|
||||
cache.mapFlatten { _, v -> listOf(v, v) }.sorted(),
|
||||
)
|
||||
assertEquals(
|
||||
setOf(1, 2, 3),
|
||||
cache.mapFlattenIntoSet { _, v -> listOf(v, v) },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(2, 3),
|
||||
cache.mapFlatten { _, v -> if (v > 1) listOf(v) else null }.sorted(),
|
||||
"a null collection contributes nothing",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregates() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2, "c" to 3, "d" to 4)
|
||||
|
||||
assertEquals(10, cache.sumOf { _, v -> v })
|
||||
assertEquals(10L, cache.sumOfLong { _, v -> v.toLong() })
|
||||
assertEquals(4, cache.maxOrNullOf({ _, _ -> true }, naturalOrder<Int>()))
|
||||
assertEquals(3, cache.maxOrNullOf({ _, v -> v % 2 == 1 }, naturalOrder<Int>()))
|
||||
assertNull(cache.maxOrNullOf({ _, _ -> false }, naturalOrder<Int>()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupings() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2, "c" to 3, "d" to 4)
|
||||
|
||||
assertEquals(
|
||||
mapOf(0 to listOf(2, 4), 1 to listOf(1, 3)),
|
||||
cache.groupBy<Int> { _, v -> v % 2 }.mapValues { it.value.sorted() },
|
||||
)
|
||||
assertEquals(mapOf(0 to 2, 1 to 2), cache.countByGroup<Int> { _, v -> v % 2 })
|
||||
assertEquals(
|
||||
mapOf(0 to 6L, 1 to 4L),
|
||||
cache.sumByGroup({ _, v -> v % 2 }, { _, v -> v.toLong() }),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun associates() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2)
|
||||
|
||||
assertEquals(mapOf(1 to "a", 2 to "b"), cache.associate { k, v -> v to k })
|
||||
assertEquals(mapOf("a" to 2, "b" to 4), cache.associateWith { _, v -> v * 2 })
|
||||
assertEquals(mapOf("a" to null, "b" to 4), cache.associateWith { _, v -> if (v > 1) v * 2 else null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun joinToStringRendersEveryEntry() {
|
||||
val cache = cacheOf("a" to 1, "b" to 2, "c" to 3)
|
||||
|
||||
val rendered =
|
||||
cache.joinToString(
|
||||
separator = ",",
|
||||
prefix = "[",
|
||||
postfix = "]",
|
||||
limit = -1,
|
||||
truncated = "...",
|
||||
) { k, v -> "$k=$v" }
|
||||
|
||||
assertTrue(rendered.startsWith("[") && rendered.endsWith("]"), rendered)
|
||||
assertEquals(
|
||||
listOf("a=1", "b=2", "c=3"),
|
||||
rendered
|
||||
.removePrefix("[")
|
||||
.removeSuffix("]")
|
||||
.split(",")
|
||||
.sorted(),
|
||||
)
|
||||
|
||||
assertEquals("[]", LargeCache<String, Int>().joinToString(",", "[", "]", -1, "...") { k, v -> "$k=$v" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertingManyEntriesStaysLinear() {
|
||||
// Regression guard for the copy-on-write linux actual this replaced, where every
|
||||
// put rebuilt the whole map: this loop cost ~1.25 billion entry copies there and
|
||||
// is instant against any O(1)-write implementation. No wall-clock assertion —
|
||||
// the run time itself is the signal.
|
||||
val n = 50_000
|
||||
val cache = LargeCache<Int, Int>()
|
||||
|
||||
for (i in 0 until n) {
|
||||
cache.put(i, i)
|
||||
// A full scan every thousandth insert, so an implementation that rebuilds a
|
||||
// read view on write has to rebuild it ~50 times rather than amortize it away.
|
||||
if (i % 1_000 == 0) assertEquals(i + 1, cache.count { _, _ -> true })
|
||||
}
|
||||
|
||||
assertEquals(n, cache.size())
|
||||
assertEquals(0, cache.get(0))
|
||||
assertEquals(n - 1, cache.get(n - 1))
|
||||
assertEquals(n.toLong() * (n - 1) / 2, cache.sumOfLong { _, v -> v.toLong() })
|
||||
|
||||
for (i in 0 until n step 2) cache.remove(i)
|
||||
|
||||
assertEquals(n / 2, cache.size())
|
||||
assertNull(cache.get(0))
|
||||
assertEquals(1, cache.get(1))
|
||||
}
|
||||
}
|
||||
@@ -121,41 +121,54 @@ actual class UriParser actual constructor(
|
||||
|
||||
actual fun path(): String? = parsedPath
|
||||
|
||||
actual fun queryParameterNames(): Set<String> {
|
||||
val query = parsedQuery ?: return emptySet()
|
||||
return query
|
||||
.split('&')
|
||||
.map { param ->
|
||||
val eqIndex = param.indexOf('=')
|
||||
if (eqIndex >= 0) param.substring(0, eqIndex) else param
|
||||
}.toSet()
|
||||
}
|
||||
/**
|
||||
* Parsed once and reused, mirroring the JVM actual's lazy map — the previous version
|
||||
* re-split the entire query string on every [getQueryParameter] call.
|
||||
*
|
||||
* Decoded with [UrlEncoder.decode], which matches `URLDecoder.decode(.., "UTF-8")` —
|
||||
* what the JVM actual calls. Skipping this is why NIP-47 failed on this target:
|
||||
* `relay=wss%3A%2F%2Frelay.damus.io` reached `RelayUrlNormalizer` still encoded and
|
||||
* came back "Invalid relay Url".
|
||||
*/
|
||||
private val queryParameters: Map<String, List<String>> by lazy {
|
||||
parsedQuery?.ifBlank { null }?.let { query ->
|
||||
val params = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
actual fun getQueryParameter(param: String): List<String>? {
|
||||
val query = parsedQuery ?: return null
|
||||
return query
|
||||
.split('&')
|
||||
.filter { part ->
|
||||
val eqIndex = part.indexOf('=')
|
||||
if (eqIndex >= 0) part.substring(0, eqIndex) == param else part == param
|
||||
}.map { part ->
|
||||
val eqIndex = part.indexOf('=')
|
||||
if (eqIndex >= 0) part.substring(eqIndex + 1) else ""
|
||||
query.split('&').forEach { paramValue ->
|
||||
val parts = paramValue.split("=", limit = 2)
|
||||
val currentValue =
|
||||
params.getOrPut(parts[0]) {
|
||||
mutableListOf()
|
||||
}
|
||||
|
||||
if (parts.size == 2) {
|
||||
currentValue.add(UrlEncoder.decode(parts[1]))
|
||||
} else {
|
||||
currentValue.add("")
|
||||
}
|
||||
}
|
||||
|
||||
params
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
val fragments: Map<String, String> by lazy {
|
||||
private val parsedFragments: Map<String, String> by lazy {
|
||||
parsedFragment?.ifBlank { null }?.let { keyValuePair ->
|
||||
keyValuePair.split('&').associate { paramValue ->
|
||||
val parts = paramValue.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
parts[0] to parts[1]
|
||||
parts[0] to UrlEncoder.decode(parts[1])
|
||||
} else {
|
||||
parts[0] to ""
|
||||
parts[0] to "" // Handle parameters without a value
|
||||
}
|
||||
}
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
actual fun fragments(): Map<String, String> = fragments
|
||||
actual fun queryParameterNames(): Set<String> = queryParameters.keys
|
||||
|
||||
/** Null — not an empty list — when the parameter is absent, as on the JVM. */
|
||||
actual fun getQueryParameter(param: String): List<String>? = queryParameters[param]
|
||||
|
||||
actual fun fragments(): Map<String, String> = parsedFragments
|
||||
}
|
||||
|
||||
@@ -1,29 +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.quartz.utils
|
||||
|
||||
import net.thauvin.erik.urlencoder.UrlEncoderUtil
|
||||
|
||||
actual object UrlEncoder {
|
||||
actual fun encode(value: String): String = UrlEncoderUtil.encode(value)
|
||||
|
||||
actual fun decode(value: String): String = UrlEncoderUtil.decode(value)
|
||||
}
|
||||
Vendored
+14
-14
@@ -20,30 +20,30 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils.cache
|
||||
|
||||
import kotlin.concurrent.AtomicReference
|
||||
|
||||
// Copy-on-write, mirroring LargeCache.linux: correct and simple; the linux
|
||||
// target is CI-only so write cost is acceptable.
|
||||
/**
|
||||
* Linux/Native actual for [ConcurrentHashCache], over the same [StripedHashMap] as
|
||||
* `LargeCache.linux.kt` — read its docs for why.
|
||||
*
|
||||
* This one was the worst-placed of the copy-on-write caches: its only caller,
|
||||
* `CachingEventDecoder`, writes once per event arriving from a relay, so every decode
|
||||
* rebuilt the whole map under a CAS retry loop. Now a write touches one bucket and a
|
||||
* read takes no lock at all.
|
||||
*/
|
||||
actual class ConcurrentHashCache<K : Any, V : Any> {
|
||||
private val mapRef = AtomicReference(HashMap<K, V>())
|
||||
private val cache = StripedHashMap<K, V>()
|
||||
|
||||
actual fun get(key: K): V? = mapRef.value[key]
|
||||
actual fun get(key: K): V? = cache.get(key)
|
||||
|
||||
actual fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
while (true) {
|
||||
val current = mapRef.value
|
||||
val copy = HashMap(current)
|
||||
copy[key] = value
|
||||
if (mapRef.compareAndSet(current, copy)) return
|
||||
}
|
||||
cache.put(key, value)
|
||||
}
|
||||
|
||||
actual fun size(): Int = mapRef.value.size
|
||||
actual fun size(): Int = cache.size()
|
||||
|
||||
actual fun clear() {
|
||||
mapRef.value = HashMap()
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
+159
-108
@@ -20,175 +20,226 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils.cache
|
||||
|
||||
import kotlin.concurrent.AtomicReference
|
||||
|
||||
/**
|
||||
* Linux/Native actual for [LargeCache] — the store behind Amethyst's `LocalCache`.
|
||||
*
|
||||
* All of the concurrency and the performance rationale lives in [StripedHashMap]; this
|
||||
* class is only the [ICacheOperations] surface over it. The short version: `LocalCache`
|
||||
* fills ~100,000 entries in a few seconds while feeds scan the whole cache, so the
|
||||
* backing store has to take writes at O(1) with next to no garbage *and* let a scan run
|
||||
* in place without copying. A chained hash table with lock-free reads and striped-lock
|
||||
* writes — `ConcurrentHashMap`'s shape, which Kotlin/Native does not ship — is the
|
||||
* structure that does both; copy-on-write and a persistent HAMT each fail one half.
|
||||
*
|
||||
* Every bulk operation below walks the table through [StripedHashMap.forEachEntry],
|
||||
* which takes no lock and allocates nothing beyond the result being built. Two things
|
||||
* follow, both of which the earlier implementations had to work around:
|
||||
*
|
||||
* - The caller's lambda never runs inside a critical section, so a `LocalCache`
|
||||
* predicate that reaches back into the cache cannot deadlock.
|
||||
* - There is no snapshot and no defensive `entries.toList()`, so no
|
||||
* `ConcurrentModificationException` window and no per-scan copy.
|
||||
*
|
||||
* Iteration is weakly consistent and in bucket order. JVM/Android iterates in
|
||||
* sorted-key order (`ConcurrentSkipListMap`) and Apple in hash order; nothing in the
|
||||
* codebase depends on a specific one. The `from`/`to` range overloads degrade to a full
|
||||
* scan here, as they always have — they have no callers outside the JVM-only
|
||||
* `LargeSoftCache`.
|
||||
*/
|
||||
actual class LargeCache<K, V> : ICacheOperations<K, V> {
|
||||
private val mapRef = AtomicReference(LinkedHashMap<K, V>())
|
||||
private val cache = StripedHashMap<K, V>()
|
||||
|
||||
private inline fun <R> withMap(block: (LinkedHashMap<K, V>) -> R): R = block(mapRef.value)
|
||||
|
||||
private inline fun mutate(block: (LinkedHashMap<K, V>) -> Unit) {
|
||||
val copy = LinkedHashMap(mapRef.value)
|
||||
block(copy)
|
||||
mapRef.value = copy
|
||||
actual fun keys(): Set<K> {
|
||||
val results = LinkedHashSet<K>(cache.size())
|
||||
cache.forEachEntry { key, _ -> results.add(key) }
|
||||
return results
|
||||
}
|
||||
|
||||
actual fun keys(): Set<K> = withMap { LinkedHashSet(it.keys) }
|
||||
|
||||
actual fun values(): Iterable<V> = withMap { ArrayList(it.values) }
|
||||
|
||||
actual fun get(key: K): V? = withMap { it[key] }
|
||||
|
||||
actual fun remove(key: K): V? {
|
||||
val current = mapRef.value
|
||||
val value = current[key]
|
||||
if (value != null) {
|
||||
mutate { it.remove(key) }
|
||||
}
|
||||
return value
|
||||
actual fun values(): Iterable<V> {
|
||||
val results = ArrayList<V>(cache.size())
|
||||
cache.forEachEntry { _, value -> results.add(value) }
|
||||
return results
|
||||
}
|
||||
|
||||
actual fun isEmpty(): Boolean = withMap { it.isEmpty() }
|
||||
actual fun get(key: K): V? = cache.get(key)
|
||||
|
||||
actual fun remove(key: K): V? = cache.remove(key)
|
||||
|
||||
actual fun isEmpty(): Boolean = cache.isEmpty()
|
||||
|
||||
actual fun clear() {
|
||||
mapRef.value = LinkedHashMap()
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
actual fun containsKey(key: K): Boolean = withMap { it.containsKey(key) }
|
||||
actual fun containsKey(key: K): Boolean = cache.containsKey(key)
|
||||
|
||||
actual fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
mutate { it[key] = value }
|
||||
cache.put(key, value)
|
||||
}
|
||||
|
||||
// The next two are the JVM actual's bodies verbatim, over the same putIfAbsent
|
||||
// contract: [builder] runs outside the write path, and the loser of a race keeps
|
||||
// the winner's value.
|
||||
|
||||
actual fun getOrCreate(
|
||||
key: K,
|
||||
builder: (key: K) -> V,
|
||||
): V {
|
||||
val existing = get(key)
|
||||
if (existing != null) return existing
|
||||
val newObject = builder(key)
|
||||
mutate { it[key] = newObject }
|
||||
return get(key) ?: newObject
|
||||
val value = cache.get(key)
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
} else {
|
||||
val newObject = builder(key)
|
||||
cache.putIfAbsent(key, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when *this* call inserted. An early implementation returned
|
||||
* `get(key) != null`, which also reported true when another thread had just created
|
||||
* the entry, double-firing whatever the caller does with a fresh key.
|
||||
*/
|
||||
actual fun createIfAbsent(
|
||||
key: K,
|
||||
builder: (key: K) -> V,
|
||||
): Boolean {
|
||||
val existing = get(key)
|
||||
if (existing != null) return false
|
||||
val newObject = builder(key)
|
||||
mutate { it[key] = newObject }
|
||||
return get(key) != null
|
||||
val value = cache.get(key)
|
||||
return if (value != null) {
|
||||
false
|
||||
} else {
|
||||
val newObject = builder(key)
|
||||
cache.putIfAbsent(key, newObject) == null
|
||||
}
|
||||
}
|
||||
|
||||
actual override fun size(): Int = withMap { it.size }
|
||||
actual override fun size(): Int = cache.size()
|
||||
|
||||
actual override fun forEach(consumer: ICacheBiConsumer<K, V>) {
|
||||
// Snapshot entries to avoid ConcurrentModificationException
|
||||
withMap { map -> map.entries.toList() }.forEach { consumer.accept(it.key, it.value) }
|
||||
cache.forEachEntry { key, value -> consumer.accept(key, value) }
|
||||
}
|
||||
|
||||
actual override fun filter(consumer: CacheCollectors.BiFilter<K, V>): List<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toList() }
|
||||
actual override fun filter(consumer: CacheCollectors.BiFilter<K, V>): List<V> {
|
||||
val results = ArrayList<V>()
|
||||
cache.forEachEntry { key, value -> if (consumer.filter(key, value)) results.add(value) }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toSet() }
|
||||
actual override fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> {
|
||||
val results = LinkedHashSet<V>()
|
||||
cache.forEachEntry { key, value -> if (consumer.filter(key, value)) results.add(value) }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> map(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): List<R> = withMap { map -> map.map { consumer.map(it.key, it.value) } }
|
||||
actual override fun <R> map(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): List<R> {
|
||||
val results = ArrayList<R>(cache.size())
|
||||
cache.forEachEntry { key, value -> results.add(consumer.map(key, value)) }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> mapNotNull(consumer: CacheCollectors.BiMapper<K, V, R?>): List<R> = withMap { map -> map.mapNotNull { consumer.map(it.key, it.value) } }
|
||||
actual override fun <R> mapNotNull(consumer: CacheCollectors.BiMapper<K, V, R?>): List<R> {
|
||||
val results = ArrayList<R>()
|
||||
cache.forEachEntry { key, value -> consumer.map(key, value)?.let { results.add(it) } }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> mapNotNullIntoSet(consumer: CacheCollectors.BiMapper<K, V, R?>): Set<R> = mapNotNull(consumer).toSet()
|
||||
actual override fun <R> mapNotNullIntoSet(consumer: CacheCollectors.BiMapper<K, V, R?>): Set<R> {
|
||||
val results = LinkedHashSet<R>()
|
||||
cache.forEachEntry { key, value -> consumer.map(key, value)?.let { results.add(it) } }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> mapFlatten(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): List<R> = withMap { map -> map.flatMap { entry -> consumer.map(entry.key, entry.value) ?: emptyList() } }
|
||||
actual override fun <R> mapFlatten(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): List<R> {
|
||||
val results = ArrayList<R>()
|
||||
cache.forEachEntry { key, value -> consumer.map(key, value)?.let { results.addAll(it) } }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> mapFlattenIntoSet(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): Set<R> = mapFlatten(consumer).toSet()
|
||||
actual override fun <R> mapFlattenIntoSet(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): Set<R> {
|
||||
val results = LinkedHashSet<R>()
|
||||
cache.forEachEntry { key, value -> consumer.map(key, value)?.let { results.addAll(it) } }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun maxOrNullOf(
|
||||
filter: CacheCollectors.BiFilter<K, V>,
|
||||
comparator: Comparator<V>,
|
||||
): V? =
|
||||
withMap { map ->
|
||||
var maxV: V? = null
|
||||
map.forEach {
|
||||
if (filter.filter(it.key, it.value)) {
|
||||
if (maxV == null || comparator.compare(it.value, maxV) > 0) {
|
||||
maxV = it.value
|
||||
}
|
||||
): V? {
|
||||
var maxV: V? = null
|
||||
cache.forEachEntry { key, value ->
|
||||
if (filter.filter(key, value)) {
|
||||
if (maxV == null || comparator.compare(value, maxV) > 0) {
|
||||
maxV = value
|
||||
}
|
||||
}
|
||||
maxV
|
||||
}
|
||||
return maxV
|
||||
}
|
||||
|
||||
actual override fun sumOf(consumer: CacheCollectors.BiSumOf<K, V>): Int =
|
||||
withMap { map ->
|
||||
var sum = 0
|
||||
map.forEach { sum += consumer.map(it.key, it.value) }
|
||||
sum
|
||||
}
|
||||
actual override fun sumOf(consumer: CacheCollectors.BiSumOf<K, V>): Int {
|
||||
var sum = 0
|
||||
cache.forEachEntry { key, value -> sum += consumer.map(key, value) }
|
||||
return sum
|
||||
}
|
||||
|
||||
actual override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong<K, V>): Long =
|
||||
withMap { map ->
|
||||
var sum = 0L
|
||||
map.forEach { sum += consumer.map(it.key, it.value) }
|
||||
sum
|
||||
}
|
||||
actual override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong<K, V>): Long {
|
||||
var sum = 0L
|
||||
cache.forEachEntry { key, value -> sum += consumer.map(key, value) }
|
||||
return sum
|
||||
}
|
||||
|
||||
actual override fun <R> groupBy(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, List<V>> =
|
||||
withMap { map ->
|
||||
val results = HashMap<R, ArrayList<V>>()
|
||||
map.forEach {
|
||||
val group = consumer.map(it.key, it.value)
|
||||
results.getOrPut(group) { ArrayList() }.add(it.value)
|
||||
}
|
||||
results
|
||||
actual override fun <R> groupBy(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, List<V>> {
|
||||
val results = HashMap<R, ArrayList<V>>()
|
||||
cache.forEachEntry { key, value ->
|
||||
results.getOrPut(consumer.map(key, value)) { ArrayList() }.add(value)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> countByGroup(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, Int> =
|
||||
withMap { map ->
|
||||
val results = HashMap<R, Int>()
|
||||
map.forEach {
|
||||
val group = consumer.map(it.key, it.value)
|
||||
results[group] = (results[group] ?: 0) + 1
|
||||
}
|
||||
results
|
||||
actual override fun <R> countByGroup(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, Int> {
|
||||
val results = HashMap<R, Int>()
|
||||
cache.forEachEntry { key, value ->
|
||||
val group = consumer.map(key, value)
|
||||
results[group] = (results[group] ?: 0) + 1
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <R> sumByGroup(
|
||||
groupMap: CacheCollectors.BiNotNullMapper<K, V, R>,
|
||||
sumOf: CacheCollectors.BiNotNullMapper<K, V, Long>,
|
||||
): Map<R, Long> =
|
||||
withMap { map ->
|
||||
val results = HashMap<R, Long>()
|
||||
map.forEach {
|
||||
val group = groupMap.map(it.key, it.value)
|
||||
results[group] = (results[group] ?: 0L) + sumOf.map(it.key, it.value)
|
||||
}
|
||||
results
|
||||
): Map<R, Long> {
|
||||
val results = HashMap<R, Long>()
|
||||
cache.forEachEntry { key, value ->
|
||||
val group = groupMap.map(key, value)
|
||||
results[group] = (results[group] ?: 0L) + sumOf.map(key, value)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun count(consumer: CacheCollectors.BiFilter<K, V>): Int = withMap { map -> map.count { consumer.filter(it.key, it.value) } }
|
||||
actual override fun count(consumer: CacheCollectors.BiFilter<K, V>): Int {
|
||||
var count = 0
|
||||
cache.forEachEntry { key, value -> if (consumer.filter(key, value)) count++ }
|
||||
return count
|
||||
}
|
||||
|
||||
actual override fun <T, U> associate(transform: (K, V) -> Pair<T, U>): Map<T, U> =
|
||||
withMap { map ->
|
||||
val results = LinkedHashMap<T, U>(map.size)
|
||||
map.forEach {
|
||||
val pair = transform(it.key, it.value)
|
||||
results[pair.first] = pair.second
|
||||
}
|
||||
results
|
||||
actual override fun <T, U> associate(transform: (K, V) -> Pair<T, U>): Map<T, U> {
|
||||
val results = LinkedHashMap<T, U>(cache.size())
|
||||
cache.forEachEntry { key, value ->
|
||||
val pair = transform(key, value)
|
||||
results[pair.first] = pair.second
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun <U> associateWith(transform: (K, V) -> U?): Map<K, U?> =
|
||||
withMap { map ->
|
||||
val results = LinkedHashMap<K, U?>(map.size)
|
||||
map.forEach {
|
||||
results[it.key] = transform(it.key, it.value)
|
||||
}
|
||||
results
|
||||
}
|
||||
actual override fun <U> associateWith(transform: (K, V) -> U?): Map<K, U?> {
|
||||
val results = LinkedHashMap<K, U?>(cache.size())
|
||||
cache.forEachEntry { key, value -> results[key] = transform(key, value) }
|
||||
return results
|
||||
}
|
||||
|
||||
actual override fun filter(
|
||||
from: K,
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import com.vitorpamplona.quartz.utils.concurrent.PlatformLock
|
||||
import com.vitorpamplona.quartz.utils.concurrent.withLock
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.concurrent.atomics.AtomicArray
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* A chained hash table with **lock-free reads** and **striped-lock writes** — the shape
|
||||
* of `java.util.concurrent.ConcurrentHashMap`, which Kotlin/Native has no equivalent of.
|
||||
*
|
||||
* Exists because `LocalCache` fills on the order of 100,000 entries in a few seconds,
|
||||
* and every structure available on this target fails that workload in some way:
|
||||
*
|
||||
* - **Copy-on-write over a `HashMap`** (what shipped first) rebuilds the whole map per
|
||||
* write: O(n) each, O(n^2) to fill.
|
||||
* - **A persistent HAMT + CAS** is O(log32 n) per write, but allocates a fresh path of
|
||||
* ~4-5 nodes for *every* write — including overwrites, which change no structure at
|
||||
* all — and throws the old path away. Measured over a 100k fill plus scans that is 24
|
||||
* GC cycles against this table's 1.
|
||||
* - **One lock around a `HashMap`** writes fast but has to hand bulk operations an O(n)
|
||||
* copy, because a caller's lambda must not run inside the critical section (the
|
||||
* linux `PlatformLock` is a spin lock and is not reentrant, and `LocalCache`
|
||||
* predicates reach back into the cache).
|
||||
*
|
||||
* A chained table avoids all three. Structure is only touched when a key is *added*
|
||||
* (one node, prepended), an overwrite is a single volatile store into the existing
|
||||
* node, and scans walk the buckets in place with no copy and no lock — so caller
|
||||
* lambdas run outside any critical section and cannot deadlock.
|
||||
*
|
||||
* Measured on linuxX64 (`-opt`), 100,000 String keys of event-id length, ms per phase
|
||||
* and GC cycles over the whole run:
|
||||
*
|
||||
* ```
|
||||
* fill overwrite reads 20 scans mixed GCs heap
|
||||
* copy-on-write* n/a n/a n/a n/a n/a n/a n/a
|
||||
* HAMT + CAS 70 78 6 117 676 24 67MB
|
||||
* lock + HashMap 13 6 3 71 1197 36 51MB
|
||||
* this 16 4 1 16 86 1 41MB
|
||||
* ```
|
||||
*
|
||||
* (*copy-on-write is off the scale: 20k entries alone took 18s to fill.) "mixed" is a
|
||||
* full fill with a whole-table scan every 1000 writes, which is the shape `LocalCache`
|
||||
* actually has — arriving events interleaved with feeds filtering the whole cache.
|
||||
* Figures are one representative run of several; they were stable to within ~10%, except
|
||||
* the HAMT's scan column, which wandered between 115ms and 190ms. This row was
|
||||
* re-measured on the shipped code, after the stripe-selection fix and after
|
||||
* [INITIAL_CAPACITY] dropped to [STRIPES] — neither moved it out of the noise.
|
||||
*
|
||||
* An *empty* instance costs ~970 bytes, nearly all of it the 16 [PlatformLock]s (two
|
||||
* objects each). That is well above the ~50 bytes an empty map used to cost, and it is
|
||||
* charged to every one of the hundreds of small caches a client holds. Folding the stripe
|
||||
* locks into a single `AtomicIntArray` would take it to ~250 bytes and is the obvious next
|
||||
* step if it ever shows up in a heap profile; it is left alone here because hand-rolling
|
||||
* the spin is exactly the kind of change that wants its own review.
|
||||
*
|
||||
* ## Concurrency contract
|
||||
*
|
||||
* - **Readers never block and never allocate.** [get], [containsKey], [size] and
|
||||
* [forEachEntry] take no lock. A reader loads [table] once and walks immutable
|
||||
* `next` links, so it always sees a well-formed chain.
|
||||
* - **Writers block only against writers hashing to the same stripe**, and only for a
|
||||
* bucket walk of a few nodes. This is where it differs from the JVM actual's fully
|
||||
* non-blocking `ConcurrentSkipListMap`; it matches `ConcurrentHashMap`, which also
|
||||
* locks a bin to write it.
|
||||
* - Iteration is **weakly consistent**, like both of those: it reflects the table as of
|
||||
* its first load and may or may not observe writes that land while it runs. It never
|
||||
* throws, never sees a torn chain, and never needs a defensive copy.
|
||||
* - A resize takes every stripe lock, so no write can be in flight while it runs.
|
||||
* Nodes are rebuilt rather than relinked, which is what lets a reader that captured
|
||||
* the pre-resize table keep walking it safely.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
internal class StripedHashMap<K, V> {
|
||||
/**
|
||||
* [value] is mutable so that overwriting an existing key allocates nothing; [next]
|
||||
* is not, so that a reader walking a chain can never see it change under them.
|
||||
* Structural edits publish a new head instead.
|
||||
*/
|
||||
internal class Node<K, V>(
|
||||
val hash: Int,
|
||||
val key: K,
|
||||
@Volatile var value: V,
|
||||
val next: Node<K, V>?,
|
||||
)
|
||||
|
||||
private val locks = Array(STRIPES) { PlatformLock() }
|
||||
private val entryCount = AtomicInt(0)
|
||||
|
||||
@Volatile internal var table = AtomicArray<Node<K, V>?>(INITIAL_CAPACITY) { null }
|
||||
|
||||
@Volatile private var threshold = INITIAL_CAPACITY / 4 * 3
|
||||
|
||||
/** Spreads the high bits down, so that both the bucket and the stripe see entropy. */
|
||||
private fun hashOf(key: K): Int {
|
||||
val h = key?.hashCode() ?: 0
|
||||
return h xor (h ushr 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* **The stripe must be a function of the bucket**, or the locks do not partition the
|
||||
* table and the whole design is unsound: two keys could share a bucket while holding
|
||||
* different locks, so two writers would read the same chain head and both publish
|
||||
* over it, silently dropping one insert.
|
||||
*
|
||||
* It is a function of the bucket here because [STRIPES] and every table capacity are
|
||||
* powers of two with `STRIPES <= capacity`, so `hash and (STRIPES - 1)` is exactly the
|
||||
* low bits of `hash and (capacity - 1)`. Same bucket therefore implies same stripe, at
|
||||
* every size. Using the hash and not the capacity also keeps a key on one stripe
|
||||
* across a resize, which is what lets [growTable] exclude writers by taking all of
|
||||
* them.
|
||||
*
|
||||
* An earlier version took bits 16-19 instead. That is still resize-stable and still
|
||||
* spreads well, which is why it looked right — but it is not derived from the bucket,
|
||||
* so it broke the invariant above.
|
||||
*/
|
||||
private fun lockFor(hash: Int) = locks[hash and (STRIPES - 1)]
|
||||
|
||||
fun size(): Int = entryCount.load()
|
||||
|
||||
fun isEmpty(): Boolean = entryCount.load() == 0
|
||||
|
||||
fun get(key: K): V? {
|
||||
val hash = hashOf(key)
|
||||
val current = table
|
||||
var node = current.loadAt(hash and (current.size - 1))
|
||||
while (node != null) {
|
||||
if (node.hash == hash && node.key == key) return node.value
|
||||
node = node.next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun containsKey(key: K): Boolean {
|
||||
val hash = hashOf(key)
|
||||
val current = table
|
||||
var node = current.loadAt(hash and (current.size - 1))
|
||||
while (node != null) {
|
||||
if (node.hash == hash && node.key == key) return true
|
||||
node = node.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
val hash = hashOf(key)
|
||||
var grew = false
|
||||
lockFor(hash).withLock {
|
||||
val current = table
|
||||
val index = hash and (current.size - 1)
|
||||
val head = current.loadAt(index)
|
||||
var node = head
|
||||
while (node != null) {
|
||||
if (node.hash == hash && node.key == key) {
|
||||
// Present already: no structural change, no allocation.
|
||||
node.value = value
|
||||
return@withLock
|
||||
}
|
||||
node = node.next
|
||||
}
|
||||
current.storeAt(index, Node(hash, key, value, head))
|
||||
grew = entryCount.fetchAndAdd(1) + 1 > threshold
|
||||
}
|
||||
if (grew) growTable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts [value] only if [key] is absent, and returns the value already stored —
|
||||
* or null when this call performed the insert. Exactly `ConcurrentMap.putIfAbsent`,
|
||||
* which the JVM actual builds `getOrCreate` and `createIfAbsent` out of, including
|
||||
* its inability to represent a stored null (`ConcurrentSkipListMap` rejects those).
|
||||
*/
|
||||
fun putIfAbsent(
|
||||
key: K,
|
||||
value: V,
|
||||
): V? {
|
||||
val hash = hashOf(key)
|
||||
var grew = false
|
||||
var existing: V? = null
|
||||
lockFor(hash).withLock {
|
||||
val current = table
|
||||
val index = hash and (current.size - 1)
|
||||
val head = current.loadAt(index)
|
||||
var node = head
|
||||
while (node != null) {
|
||||
if (node.hash == hash && node.key == key) {
|
||||
existing = node.value
|
||||
return@withLock
|
||||
}
|
||||
node = node.next
|
||||
}
|
||||
current.storeAt(index, Node(hash, key, value, head))
|
||||
grew = entryCount.fetchAndAdd(1) + 1 > threshold
|
||||
}
|
||||
if (grew) growTable()
|
||||
return existing
|
||||
}
|
||||
|
||||
fun remove(key: K): V? {
|
||||
val hash = hashOf(key)
|
||||
var removed: V? = null
|
||||
lockFor(hash).withLock {
|
||||
val current = table
|
||||
val index = hash and (current.size - 1)
|
||||
val head = current.loadAt(index)
|
||||
|
||||
var target = head
|
||||
while (target != null && !(target.hash == hash && target.key == key)) target = target.next
|
||||
if (target == null) return@withLock
|
||||
|
||||
// `next` is immutable, so the nodes ahead of the removed one are cloned onto
|
||||
// its tail. A reader still walking the old head sees the entry one last time
|
||||
// rather than a broken chain.
|
||||
var rebuilt = target.next
|
||||
var ahead = head
|
||||
while (ahead !== target) {
|
||||
val node = ahead!!
|
||||
rebuilt = Node(node.hash, node.key, node.value, rebuilt)
|
||||
ahead = node.next
|
||||
}
|
||||
|
||||
current.storeAt(index, rebuilt)
|
||||
entryCount.fetchAndAdd(-1)
|
||||
removed = target.value
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
lockAll()
|
||||
try {
|
||||
table = AtomicArray(INITIAL_CAPACITY) { null }
|
||||
threshold = INITIAL_CAPACITY / 4 * 3
|
||||
entryCount.store(0)
|
||||
} finally {
|
||||
unlockAll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks every entry without locking. Inline so the caller's body runs with no
|
||||
* `Function2` dispatch and no captured-variable box per entry, which is what keeps
|
||||
* a full-cache scan allocation-free.
|
||||
*/
|
||||
inline fun forEachEntry(action: (K, V) -> Unit) {
|
||||
val current = table
|
||||
for (index in 0 until current.size) {
|
||||
var node = current.loadAt(index)
|
||||
while (node != null) {
|
||||
action(node.key, node.value)
|
||||
node = node.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun growTable() {
|
||||
lockAll()
|
||||
try {
|
||||
val old = table
|
||||
// Another writer may have grown it while this one waited for the locks.
|
||||
if (entryCount.load() <= threshold) return
|
||||
if (old.size >= MAX_CAPACITY) {
|
||||
threshold = Int.MAX_VALUE
|
||||
return
|
||||
}
|
||||
|
||||
val capacity = old.size shl 1
|
||||
val next = AtomicArray<Node<K, V>?>(capacity) { null }
|
||||
for (index in 0 until old.size) {
|
||||
var node = old.loadAt(index)
|
||||
while (node != null) {
|
||||
val target = node.hash and (capacity - 1)
|
||||
next.storeAt(target, Node(node.hash, node.key, node.value, next.loadAt(target)))
|
||||
node = node.next
|
||||
}
|
||||
}
|
||||
|
||||
table = next
|
||||
threshold = capacity / 4 * 3
|
||||
} finally {
|
||||
unlockAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** Always in index order, and only ever from a thread holding no stripe lock. */
|
||||
private fun lockAll() {
|
||||
for (lock in locks) lock.lock()
|
||||
}
|
||||
|
||||
private fun unlockAll() {
|
||||
for (index in locks.indices.reversed()) locks[index].unlock()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Writes are O(1), so a stripe is held for a few nanoseconds and 16 ways is
|
||||
* plenty — `ConcurrentHashMap` shipped with the same default for years.
|
||||
*/
|
||||
private const val STRIPES = 16
|
||||
|
||||
/**
|
||||
* Tied to [STRIPES] rather than chosen independently: the stripe is only a
|
||||
* function of the bucket while `STRIPES <= capacity` (see [lockFor]), and defining
|
||||
* it this way makes that impossible to break by raising [STRIPES] alone.
|
||||
*
|
||||
* Kept small on purpose. `LargeCache` is not only the one big `LocalCache`
|
||||
* instance: `EphemeralRoom`, `RelaySession`, `PoolRequests` and friends each build
|
||||
* one per room, per connection and per subscription set, so a client holds
|
||||
* hundreds of them and most stay nearly empty. Starting at 1024 slots charged every
|
||||
* one of those ~8 KB it would never use. Growth is geometric, so a table that does
|
||||
* fill to 100k pays the same ~2n node rebuilds in total either way.
|
||||
*/
|
||||
private const val INITIAL_CAPACITY = STRIPES
|
||||
|
||||
private const val MAX_CAPACITY = 1 shl 30
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,82 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz
|
||||
|
||||
actual class TestResourceLoader actual constructor() {
|
||||
actual fun loadDecompressString(file: String): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
import com.vitorpamplona.quartz.utils.GZip
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.toKString
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.posix.SEEK_END
|
||||
import platform.posix.SEEK_SET
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fread
|
||||
import platform.posix.fseek
|
||||
import platform.posix.ftell
|
||||
import platform.posix.getenv
|
||||
|
||||
actual fun loadString(file: String): String {
|
||||
TODO("Not yet implemented")
|
||||
/**
|
||||
* Linux/Native actual for [TestResourceLoader].
|
||||
*
|
||||
* Until this existed it was `TODO()`, which failed 69 tests on this target — every
|
||||
* suite driven by a vector file: the whole MLS interop set, NIP-44, the NIP-01 hint
|
||||
* indexer, the SQLite store's large-DB tests and the Bolt12 payer proofs. None of them
|
||||
* were failing because of missing production code; they could not read their input.
|
||||
*
|
||||
* Resolves paths against `TEST_RESOURCES_ROOT`, the same environment variable the Apple
|
||||
* actual uses, exported onto every `KotlinNativeTest` task by `quartz/build.gradle.kts`.
|
||||
* Reads through `platform.posix` rather than Foundation, which linuxX64 does not have.
|
||||
*
|
||||
* The read is a single `stat`-sized allocation filled by `fread`, so a vector file
|
||||
* costs exactly one `ByteArray` — less than the JVM actual's `bufferedReader().readText()`,
|
||||
* which grows a `StringBuilder` as it goes.
|
||||
*/
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual class TestResourceLoader actual constructor() {
|
||||
actual fun loadDecompressString(file: String): String = GZip.decompress(readBytes(file))
|
||||
|
||||
actual fun loadString(file: String): String = readBytes(file).decodeToString()
|
||||
|
||||
private fun readBytes(file: String): ByteArray {
|
||||
val root =
|
||||
getenv("TEST_RESOURCES_ROOT")?.toKString()
|
||||
?: throw IllegalStateException(
|
||||
"TEST_RESOURCES_ROOT is not set. quartz/build.gradle.kts exports it onto every " +
|
||||
"KotlinNativeTest task; running the test binary directly has to set it too.",
|
||||
)
|
||||
|
||||
val path = "$root/$file"
|
||||
val handle = fopen(path, "rb") ?: throw IllegalArgumentException("Resource not found: $path")
|
||||
|
||||
try {
|
||||
if (fseek(handle, 0, SEEK_END) != 0) throw IllegalArgumentException("Resource is not seekable: $path")
|
||||
val size = ftell(handle)
|
||||
if (size < 0L) throw IllegalArgumentException("Cannot determine the size of: $path")
|
||||
if (size == 0L) return ByteArray(0)
|
||||
if (fseek(handle, 0, SEEK_SET) != 0) throw IllegalArgumentException("Cannot rewind: $path")
|
||||
|
||||
val bytes = ByteArray(size.toInt())
|
||||
bytes.usePinned { pinned ->
|
||||
var read = 0
|
||||
while (read < bytes.size) {
|
||||
val count =
|
||||
fread(
|
||||
pinned.addressOf(read),
|
||||
1.convert(),
|
||||
(bytes.size - read).convert(),
|
||||
handle,
|
||||
).toInt()
|
||||
if (count <= 0) break
|
||||
read += count
|
||||
}
|
||||
if (read != bytes.size) {
|
||||
throw IllegalArgumentException("Short read on $path: got $read of ${bytes.size} bytes")
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
} finally {
|
||||
fclose(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Drives every key into a single bucket of the [StripedHashMap] backing this target's
|
||||
* [LargeCache], so the bucket-chain paths run deterministically instead of only when a
|
||||
* hash happens to collide.
|
||||
*
|
||||
* The one that most needs it is removal. Chain nodes hold their `next` immutably — that
|
||||
* is what lets a reader walk a chain with no lock — so removing from the middle has to
|
||||
* clone the nodes ahead of the target onto its tail and publish a new head. With
|
||||
* well-spread keys that path almost never sees a chain longer than two.
|
||||
*/
|
||||
class LargeCacheCollisionTest {
|
||||
/** Every instance lands in the same bucket, and in the same stripe. */
|
||||
private data class Collides(
|
||||
val id: Int,
|
||||
) {
|
||||
override fun hashCode() = 0
|
||||
}
|
||||
|
||||
private fun filled(n: Int) =
|
||||
LargeCache<Collides, Int>().apply {
|
||||
for (i in 0 until n) put(Collides(i), i)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsFindEveryEntryInOneChain() {
|
||||
val cache = filled(200)
|
||||
|
||||
assertEquals(200, cache.size())
|
||||
for (i in 0 until 200) {
|
||||
assertEquals(i, cache.get(Collides(i)), "entry $i")
|
||||
assertTrue(cache.containsKey(Collides(i)))
|
||||
}
|
||||
assertNull(cache.get(Collides(200)))
|
||||
assertFalse(cache.containsKey(Collides(200)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overwriteInAChainReplacesInPlace() {
|
||||
val cache = filled(200)
|
||||
|
||||
for (i in 0 until 200) cache.put(Collides(i), i * 10)
|
||||
|
||||
assertEquals(200, cache.size(), "overwriting must not lengthen the chain")
|
||||
for (i in 0 until 200) assertEquals(i * 10, cache.get(Collides(i)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeFromTheMiddleKeepsTheRestOfTheChain() {
|
||||
val cache = filled(200)
|
||||
|
||||
// Head, tail and middle of the chain, in an order that leaves gaps behind.
|
||||
for (i in 0 until 200 step 3) {
|
||||
assertEquals(i, cache.remove(Collides(i)), "remove $i returns its value")
|
||||
}
|
||||
|
||||
val expected = (0 until 200).filter { it % 3 != 0 }
|
||||
assertEquals(expected.size, cache.size())
|
||||
for (i in 0 until 200) {
|
||||
if (i % 3 == 0) {
|
||||
assertNull(cache.get(Collides(i)), "entry $i was removed")
|
||||
} else {
|
||||
assertEquals(i, cache.get(Collides(i)), "entry $i survived")
|
||||
}
|
||||
}
|
||||
|
||||
val seen = mutableListOf<Int>()
|
||||
cache.forEach { _, v -> seen.add(v) }
|
||||
assertEquals(expected.toSet(), seen.toSet(), "iteration must match the survivors")
|
||||
assertEquals(expected.size, seen.size, "iteration must not double-count")
|
||||
|
||||
assertNull(cache.remove(Collides(0)), "removing twice is a no-op")
|
||||
assertEquals(expected.size, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getOrCreateAndCreateIfAbsentWalkTheChain() {
|
||||
val cache = filled(200)
|
||||
var builds = 0
|
||||
|
||||
for (i in 0 until 200) {
|
||||
assertEquals(
|
||||
i,
|
||||
cache.getOrCreate(Collides(i)) {
|
||||
builds++
|
||||
-1
|
||||
},
|
||||
)
|
||||
assertFalse(cache.createIfAbsent(Collides(i)) { -1 })
|
||||
}
|
||||
assertEquals(0, builds, "nothing in the chain should have been rebuilt")
|
||||
|
||||
assertTrue(cache.createIfAbsent(Collides(500)) { 500 })
|
||||
assertEquals(500, cache.get(Collides(500)))
|
||||
assertEquals(201, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearEmptiesAFullBucket() {
|
||||
val cache = filled(200)
|
||||
|
||||
cache.clear()
|
||||
|
||||
assertEquals(0, cache.size())
|
||||
assertTrue(cache.isEmpty())
|
||||
assertNull(cache.get(Collides(7)))
|
||||
assertEquals(0, cache.count { _, _ -> true })
|
||||
|
||||
cache.put(Collides(1), 1)
|
||||
assertEquals(1, cache.size())
|
||||
assertEquals(1, cache.get(Collides(1)))
|
||||
}
|
||||
}
|
||||
Vendored
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import kotlin.native.concurrent.ObsoleteWorkersApi
|
||||
import kotlin.native.concurrent.TransferMode
|
||||
import kotlin.native.concurrent.Worker
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The linux actual is the only [LargeCache] whose thread safety is hand-rolled rather
|
||||
* than delegated to a concurrent map, so it gets its own multi-threaded test. The
|
||||
* copy-on-write version this replaced would fail every assertion here: its
|
||||
* read-copy-write was not a CAS loop, so concurrent writers silently dropped each
|
||||
* other's entries.
|
||||
*
|
||||
* Uses `Worker` rather than coroutines on purpose — a coroutine dispatcher gives no
|
||||
* guarantee of genuine parallelism, and parallelism is the whole point.
|
||||
*/
|
||||
@OptIn(ObsoleteWorkersApi::class)
|
||||
class LargeCacheConcurrencyTest {
|
||||
private val workerCount = 4
|
||||
private val perWorker = 5_000
|
||||
|
||||
private fun <R> inParallel(job: (workerId: Int) -> R): List<R> {
|
||||
val workers = List(workerCount) { Worker.start() }
|
||||
val futures =
|
||||
workers.mapIndexed { id, worker ->
|
||||
worker.execute(TransferMode.SAFE, { Pair(job, id) }) { (block, workerId) -> block(workerId) }
|
||||
}
|
||||
val results = futures.map { it.result }
|
||||
workers.forEach { it.requestTermination().result }
|
||||
return results
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentPutsKeepEveryEntry() {
|
||||
val cache = LargeCache<Int, Int>()
|
||||
|
||||
inParallel { id ->
|
||||
repeat(perWorker) { i -> cache.put(id * perWorker + i, i) }
|
||||
}
|
||||
|
||||
assertEquals(workerCount * perWorker, cache.size())
|
||||
for (id in 0 until workerCount) {
|
||||
assertEquals(0, cache.get(id * perWorker))
|
||||
assertEquals(perWorker - 1, cache.get(id * perWorker + perWorker - 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentGetOrCreateBuildsOneValuePerKey() {
|
||||
val cache = LargeCache<Int, String>()
|
||||
|
||||
// Every worker races for the same 500 keys. Whoever wins, all of them must end
|
||||
// up holding the identical instance, and the cache must hold exactly 500.
|
||||
val seen =
|
||||
inParallel { _ ->
|
||||
(0 until 500).map { key -> cache.getOrCreate(key) { "v$it" } }
|
||||
}
|
||||
|
||||
assertEquals(500, cache.size())
|
||||
seen.forEach { perWorkerValues ->
|
||||
perWorkerValues.forEachIndexed { key, value ->
|
||||
assertEquals(cache.get(key), value, "getOrCreate handed out a value it did not publish")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentCreateIfAbsentReportsExactlyOneInsertPerKey() {
|
||||
val cache = LargeCache<Int, Int>()
|
||||
|
||||
val insertsPerWorker = inParallel { _ -> (0 until 500).count { key -> cache.createIfAbsent(key) { key } } }
|
||||
|
||||
assertEquals(500, cache.size())
|
||||
assertEquals(500, insertsPerWorker.sum(), "exactly one caller per key may report an insert")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bulkReadsStayConsistentWhileWritesLand() {
|
||||
val cache = LargeCache<Int, Int>()
|
||||
repeat(1_000) { cache.put(it, it) }
|
||||
|
||||
// Writers churn the map while readers walk snapshots of it. A reader must never
|
||||
// see a torn map, and must never crash on a concurrent modification.
|
||||
inParallel { id ->
|
||||
if (id % 2 == 0) {
|
||||
repeat(perWorker) { i -> cache.put(1_000 + id * perWorker + i, i) }
|
||||
} else {
|
||||
repeat(200) {
|
||||
val sum = cache.sumOfLong { _, v -> v.toLong() }
|
||||
check(sum >= 0) { "unexpected negative sum $sum" }
|
||||
cache.count { _, _ -> true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(1_000 + (workerCount / 2) * perWorker, cache.size())
|
||||
}
|
||||
}
|
||||
Vendored
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The `from`/`to` overloads narrow to a key range only where the backing map is sorted
|
||||
* (`ConcurrentSkipListMap` on JVM/Android). This target's map is not sorted, and `K`
|
||||
* carries no `Comparable` bound to sort it by, so every range overload deliberately
|
||||
* degrades to a full scan and leaves the caller's predicate to do the filtering.
|
||||
*
|
||||
* That is safe today because the range overloads have no callers outside the JVM-only
|
||||
* `LargeSoftCache`. This test pins the behaviour so the fallback stays *consistent*
|
||||
* with the unbounded form — the property anything sharing code across targets would
|
||||
* rely on — rather than silently returning a different set on this platform.
|
||||
*
|
||||
* Linux-only on purpose: it is a statement about this actual. The Apple actual walks
|
||||
* its map by index instead, which is a different (and order-dependent) contract.
|
||||
*/
|
||||
class LargeCacheRangeFallbackTest {
|
||||
private val cache =
|
||||
LargeCache<String, Int>().apply {
|
||||
put("a", 1)
|
||||
put("b", 2)
|
||||
put("c", 3)
|
||||
}
|
||||
|
||||
private val all = CacheCollectors.BiFilter<String, Int> { _, _ -> true }
|
||||
|
||||
@Test
|
||||
fun rangeOverloadsMatchTheirUnboundedForm() {
|
||||
assertContentEquals(cache.filter(all), cache.filter("a", "c", all))
|
||||
assertEquals(cache.filterIntoSet(all), cache.filterIntoSet("a", "c", all))
|
||||
assertEquals(cache.count(all), cache.count("a", "c", all))
|
||||
assertEquals(cache.sumOf { _, v -> v }, cache.sumOf("a", "c") { _, v -> v })
|
||||
assertEquals(cache.sumOfLong { _, v -> v.toLong() }, cache.sumOfLong("a", "c") { _, v -> v.toLong() })
|
||||
assertContentEquals(cache.map<Int> { _, v -> v }, cache.map<Int>("a", "c") { _, v -> v })
|
||||
assertContentEquals(cache.mapNotNull<Int> { _, v -> v }, cache.mapNotNull<Int>("a", "c") { _, v -> v })
|
||||
assertEquals(cache.associate { k, v -> k to v }, cache.associate("a", "c") { k, v -> k to v })
|
||||
assertEquals(cache.associateWith { _, v -> v }, cache.associateWith("a", "c") { _, v -> v })
|
||||
assertEquals(cache.countByGroup<Int> { _, v -> v % 2 }, cache.countByGroup<Int>("a", "c") { _, v -> v % 2 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNarrowerRangeStillScansEverything() {
|
||||
// Documents the degradation explicitly: on a sorted target this would return
|
||||
// only "a", here it returns all three. Anyone who later gives this actual real
|
||||
// range support should update this test rather than discover the difference in
|
||||
// production.
|
||||
assertEquals(3, cache.count("a", "a", all))
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.native.concurrent.ObsoleteWorkersApi
|
||||
import kotlin.native.concurrent.TransferMode
|
||||
import kotlin.native.concurrent.Worker
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Stresses the one path the rest of the suite never reaches: **many writers inserting into
|
||||
* the same bucket at the same time.**
|
||||
*
|
||||
* A striped table is only sound if the stripe is a function of the bucket. Derive the two
|
||||
* from different parts of the hash and the locks stop partitioning the table: two keys can
|
||||
* share a bucket while holding different locks, so two writers read the same chain head and
|
||||
* both publish over it, and one insert vanishes. The first cut of [StripedHashMap] did
|
||||
* exactly that — bucket from the low bits, stripe from bits 16-19 — and nothing here caught
|
||||
* it, because every other suite uses small `Int` keys or a constant `hashCode`, which all
|
||||
* collapse onto stripe 0 and serialise by accident.
|
||||
*
|
||||
* So the keys are built on purpose: groups of four sharing their low 16 bits, which puts a
|
||||
* group in one bucket at any table size this reaches, while differing in bits 16-19 — the
|
||||
* part the broken version striped on. Only [BUCKETS] buckets are used, so chains run to
|
||||
* hundreds of nodes and each insert holds its lock for a while, which is what widens the
|
||||
* window; with well-spread keys it is a few nanoseconds and nothing is observable.
|
||||
*
|
||||
* Being straight about what this is: a stress test, not a deterministic reproducer. It did
|
||||
* not fail against the broken striping in the runs attempted, which says that race is rare
|
||||
* rather than absent — the defect is a plain lost update, provable by reading
|
||||
* [StripedHashMap]'s stripe selection against its bucket index, and was fixed on that
|
||||
* basis. What this test is worth is being the only coverage of concurrent same-bucket
|
||||
* inserts at all, and it would catch a coarser regression.
|
||||
*/
|
||||
@OptIn(ObsoleteWorkersApi::class, ExperimentalAtomicApi::class)
|
||||
class LargeCacheStripingTest {
|
||||
/**
|
||||
* `hashOf` in [StripedHashMap] spreads with `h xor (h ushr 16)`, so to land on a final
|
||||
* hash of `(member shl 16) or bucket` the raw hashCode has to be
|
||||
* `(member shl 16) or (bucket xor member)`. The low 16 bits are then exactly `bucket`,
|
||||
* shared by all four members of a group at every table size this test reaches.
|
||||
*
|
||||
* Only [BUCKETS] distinct buckets are used, so chains run to hundreds of nodes. That
|
||||
* matters: a writer walks its chain *holding the stripe lock*, so a long chain is what
|
||||
* makes the window wide enough for two writers on two different locks to overlap
|
||||
* inside the same bucket. With well-spread keys the window is a few nanoseconds and
|
||||
* the defect hides.
|
||||
*/
|
||||
private data class GroupedKey(
|
||||
val group: Int,
|
||||
val member: Int,
|
||||
) {
|
||||
override fun hashCode(): Int {
|
||||
val bucket = group and (BUCKETS - 1)
|
||||
return (member shl 16) or (bucket xor member)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs [job] on [WORKERS] threads released together, so they actually contend. */
|
||||
private fun inParallel(job: (workerId: Int) -> Unit) {
|
||||
val ready = AtomicInt(0)
|
||||
val go = AtomicInt(0)
|
||||
val workers = List(WORKERS) { Worker.start() }
|
||||
|
||||
val futures =
|
||||
workers.mapIndexed { id, worker ->
|
||||
worker.execute(TransferMode.SAFE, { Triple(job, id, ready to go) }) { (block, workerId, gates) ->
|
||||
val (readyGate, goGate) = gates
|
||||
readyGate.fetchAndAdd(1)
|
||||
while (goGate.load() == 0) { }
|
||||
block(workerId)
|
||||
}
|
||||
}
|
||||
|
||||
while (ready.load() < WORKERS) { }
|
||||
go.store(1)
|
||||
|
||||
futures.forEach { it.result }
|
||||
workers.forEach { it.requestTermination().result }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentInsertsAcrossSharedBucketsKeepEveryEntry() {
|
||||
repeat(ROUNDS) { round -> insertRound(round) }
|
||||
}
|
||||
|
||||
private fun insertRound(round: Int) {
|
||||
val cache = LargeCache<GroupedKey, Int>()
|
||||
|
||||
inParallel { worker ->
|
||||
for (group in 0 until GROUPS) {
|
||||
cache.put(GroupedKey(group, worker), group)
|
||||
}
|
||||
}
|
||||
|
||||
val total = GROUPS * WORKERS
|
||||
|
||||
val seen = mutableSetOf<GroupedKey>()
|
||||
cache.forEach { key, _ -> seen.add(key) }
|
||||
|
||||
assertEquals(total, seen.size, "round $round: iteration lost or duplicated entries in a shared bucket")
|
||||
assertEquals(total, cache.size(), "round $round: size() disagrees with what the table holds")
|
||||
|
||||
for (group in 0 until GROUPS) {
|
||||
for (member in 0 until WORKERS) {
|
||||
assertEquals(
|
||||
group,
|
||||
assertNotNull(
|
||||
cache.get(GroupedKey(group, member)),
|
||||
"round $round: entry ($group, $member) was dropped by a concurrent insert",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentCreateIfAbsentAcrossSharedBucketsReportsOneInsertEach() {
|
||||
repeat(ROUNDS) { createIfAbsentRound() }
|
||||
}
|
||||
|
||||
private fun createIfAbsentRound() {
|
||||
val cache = LargeCache<GroupedKey, Int>()
|
||||
val contended = GROUPS / 4
|
||||
|
||||
// Every worker races for the same keys this time, so a lost update shows up as a
|
||||
// duplicate in the chain rather than a missing entry.
|
||||
inParallel { _ ->
|
||||
for (group in 0 until contended) {
|
||||
for (member in 0 until WORKERS) {
|
||||
cache.createIfAbsent(GroupedKey(group, member)) { group }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val total = contended * WORKERS
|
||||
val seen = mutableSetOf<GroupedKey>()
|
||||
var visited = 0
|
||||
cache.forEach { key, _ ->
|
||||
seen.add(key)
|
||||
visited++
|
||||
}
|
||||
|
||||
assertEquals(total, visited, "a key was inserted twice into the same chain")
|
||||
assertEquals(total, seen.size)
|
||||
assertEquals(total, cache.size())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORKERS = 4
|
||||
|
||||
/** Large enough that the four workers overlap for essentially the whole run. */
|
||||
private const val GROUPS = 4_096
|
||||
|
||||
/** Few enough that chains grow long and every insert holds its lock for a while. */
|
||||
private const val BUCKETS = 64
|
||||
|
||||
/** Repeated on a fresh table, because only the *insert* path can lose a write. */
|
||||
private const val ROUNDS = 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Native actual for [UrlEncoder], shared by linuxX64 and every Apple target.
|
||||
*
|
||||
* ## Why this is not a library call any more
|
||||
*
|
||||
* Both native targets used to delegate to `net.thauvin.erik.urlencoder.UrlEncoderUtil`,
|
||||
* which implements RFC 3986 percent-encoding. The JVM/Android actual is
|
||||
* `java.net.URLEncoder`/`URLDecoder`, which implements
|
||||
* `application/x-www-form-urlencoded`. Those are different specifications, and the
|
||||
* difference was observable in three places:
|
||||
*
|
||||
* ```
|
||||
* JVM/Android UrlEncoderUtil
|
||||
* encode(" ") "+" "%20"
|
||||
* encode("*") "*" "%2A"
|
||||
* decode("a+b") "a b" "a+b"
|
||||
* ```
|
||||
*
|
||||
* That is not cosmetic. [encode] builds strings that leave the device — `TorrentEvent`
|
||||
* puts it in magnet links, `Nip54InlineMetadata` in inline metadata, `Nip47DeepLink` in
|
||||
* the `callback`, `appname` and `value` parameters of NWC deep links — so Android and
|
||||
* iOS were emitting different bytes for the same title. The decode row is worse than
|
||||
* cosmetic: a magnet link or deep link written by Android carries `+` for its spaces,
|
||||
* and reading it on iOS produced a string with literal plus signs instead of spaces, no
|
||||
* error anywhere.
|
||||
*
|
||||
* So this matches `URLEncoder`/`URLDecoder` exactly instead: the unreserved set is
|
||||
* alphanumerics plus `-`, `_`, `.` and `*` (note `*` survives and `~` does not — the
|
||||
* opposite of RFC 3986), space encodes to `+`, everything else to uppercase `%XX` of
|
||||
* its UTF-8 bytes, and decoding maps `+` back to a space. `UrlEncoderTest` in
|
||||
* `commonTest` pins all of it against the JVM on every target.
|
||||
*
|
||||
* Both directions short-circuit the way the `java.net` pair does: a string with nothing
|
||||
* to change is returned as-is rather than rebuilt.
|
||||
*
|
||||
* One deliberate edge difference: an *unpaired* UTF-16 surrogate encodes as `%EF%BF%BD`
|
||||
* (Kotlin's replacement character) where the JVM gives `%3F`. Nostr content is
|
||||
* well-formed UTF-16, and chasing it would cost a scan on every call.
|
||||
*/
|
||||
actual object UrlEncoder {
|
||||
private const val HEX = "0123456789ABCDEF"
|
||||
|
||||
actual fun encode(value: String): String {
|
||||
var index = 0
|
||||
while (index < value.length && isUnreserved(value[index])) index++
|
||||
if (index == value.length) return value
|
||||
|
||||
val result = StringBuilder(value.length + ESCAPE_HEADROOM)
|
||||
result.append(value, 0, index)
|
||||
|
||||
while (index < value.length) {
|
||||
val char = value[index]
|
||||
when {
|
||||
isUnreserved(char) -> {
|
||||
result.append(char)
|
||||
index++
|
||||
}
|
||||
|
||||
char == ' ' -> {
|
||||
result.append('+')
|
||||
index++
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Escaped as a run rather than character by character, so a surrogate
|
||||
// pair becomes one 4-byte sequence instead of two malformed 3-byte ones.
|
||||
val start = index
|
||||
do {
|
||||
index++
|
||||
} while (index < value.length && !isUnreserved(value[index]) && value[index] != ' ')
|
||||
appendEscaped(result, value, start, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
actual fun decode(value: String): String {
|
||||
if (value.indexOf('%') < 0 && value.indexOf('+') < 0) return value
|
||||
|
||||
val result = StringBuilder(value.length)
|
||||
var index = 0
|
||||
// Sized on first use for the longest run that could still follow, then reused —
|
||||
// one allocation for the whole string, as in URLDecoder.
|
||||
var escaped: ByteArray? = null
|
||||
|
||||
while (index < value.length) {
|
||||
when (val char = value[index]) {
|
||||
'+' -> {
|
||||
result.append(' ')
|
||||
index++
|
||||
}
|
||||
|
||||
'%' -> {
|
||||
val buffer = escaped ?: ByteArray((value.length - index) / 3).also { escaped = it }
|
||||
var count = 0
|
||||
while (index + 2 < value.length && value[index] == '%') {
|
||||
buffer[count++] = decodeEscape(value, index)
|
||||
index += 3
|
||||
}
|
||||
if (index < value.length && value[index] == '%') {
|
||||
throw IllegalArgumentException("URLDecoder: Incomplete trailing escape (%) pattern")
|
||||
}
|
||||
// Decoded as a run so a multi-byte UTF-8 sequence survives.
|
||||
result.append(buffer.decodeToString(0, count))
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.append(char)
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/** `URLEncoder`'s `dontNeedEncoding` set: alphanumerics plus these four, and only these. */
|
||||
private fun isUnreserved(char: Char): Boolean =
|
||||
char in 'a'..'z' ||
|
||||
char in 'A'..'Z' ||
|
||||
char in '0'..'9' ||
|
||||
char == '-' ||
|
||||
char == '_' ||
|
||||
char == '.' ||
|
||||
char == '*'
|
||||
|
||||
private fun appendEscaped(
|
||||
result: StringBuilder,
|
||||
value: String,
|
||||
start: Int,
|
||||
end: Int,
|
||||
) {
|
||||
val bytes = value.substring(start, end).encodeToByteArray()
|
||||
for (byte in bytes) {
|
||||
val code = byte.toInt()
|
||||
result.append('%')
|
||||
result.append(HEX[(code shr 4) and 0xF])
|
||||
result.append(HEX[code and 0xF])
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeEscape(
|
||||
value: String,
|
||||
index: Int,
|
||||
): Byte {
|
||||
val high = hexDigit(value[index + 1])
|
||||
val low = hexDigit(value[index + 2])
|
||||
if (high < 0 || low < 0) {
|
||||
throw IllegalArgumentException(
|
||||
"URLDecoder: Illegal hex characters in escape (%) pattern - ${value.substring(index, index + 3)}",
|
||||
)
|
||||
}
|
||||
return ((high shl 4) or low).toByte()
|
||||
}
|
||||
|
||||
private fun hexDigit(char: Char): Int =
|
||||
when (char) {
|
||||
in '0'..'9' -> char - '0'
|
||||
in 'a'..'f' -> char - 'a' + 10
|
||||
in 'A'..'F' -> char - 'A' + 10
|
||||
else -> -1
|
||||
}
|
||||
|
||||
/** Enough for a handful of escapes before the builder has to grow. */
|
||||
private const val ESCAPE_HEADROOM = 16
|
||||
}
|
||||
+6
-4
@@ -23,10 +23,12 @@ package com.vitorpamplona.quartz.utils.concurrent
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
// Copy-on-write, mirroring ConcurrentHashCache.linux: correct and simple. The
|
||||
// native targets never run the crawl this backs (it is JVM/Android-only work);
|
||||
// they only compile it, so the O(n)-per-write cost is irrelevant. A CAS retry
|
||||
// loop gives getOrPut/merge the same atomicity the JVM actual gets for free.
|
||||
// Copy-on-write: correct and simple. Unlike LargeCache/ConcurrentHashCache — which
|
||||
// back LocalCache and the event decoder and were moved off copy-on-write for exactly
|
||||
// this reason — the native targets never run the crawl this backs (it is
|
||||
// JVM/Android-only work); they only compile it, so the O(n)-per-write cost is
|
||||
// irrelevant. A CAS retry loop gives getOrPut/merge the same atomicity the JVM actual
|
||||
// gets for free.
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
actual class ConcurrentMap<K : Any, V : Any> {
|
||||
private val ref = AtomicReference(HashMap<K, V>())
|
||||
|
||||
Reference in New Issue
Block a user