diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 684213eea6..acfd45e211 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -72,6 +72,13 @@ kotlin { // LruCache (KMP-ready) implementation(libs.androidx.collection) + // KMP-friendly concurrent map/set. Replaces ConcurrentHashMap + // for code that genuinely needs cross-thread atomic put/remove + // (chess event collector, relay subscription managers). On JVM + // it wraps mutableMapOf with synchronized; on Native it uses + // platform mutex. No new gradle plugin required. + implementation(libs.stately.concurrent.collections) + // Immutable collections api(libs.kotlinx.collections.immutable) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 9c9b0e6761..2f5db8dffb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import co.touchlab.stately.collections.ConcurrentMutableMap +import co.touchlab.stately.collections.ConcurrentMutableSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents @@ -29,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.ConcurrentHashMap /** * Collects and aggregates Jester chess events for a game from any source. @@ -68,10 +69,10 @@ class ChessEventCollector( val startEvent: StateFlow = _startEvent.asStateFlow() // Move events (deduplicated by event ID) - private val moves = ConcurrentHashMap() + private val moves = ConcurrentMutableMap() // Track all processed event IDs for fast deduplication - private val processedEventIds = ConcurrentHashMap.newKeySet() + private val processedEventIds = ConcurrentMutableSet() // Flow that emits when any event is added (for reactive updates) private val _eventCount = MutableStateFlow(0) @@ -218,7 +219,7 @@ class ChessEventCollector( * such as in a chess lobby or when spectating multiple games. */ class ChessEventCollectorManager { - private val collectors = ConcurrentHashMap() + private val collectors = ConcurrentMutableMap() /** * Get or create a collector for a game. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 8735c8ec09..e49849a0f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import co.touchlab.stately.collections.ConcurrentMutableMap +import co.touchlab.stately.collections.ConcurrentMutableSet import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color @@ -129,18 +131,20 @@ class ChessLobbyLogic( ) { val state = ChessLobbyState(userPubkey, scope) - private val dismissedGameIds: MutableSet = - java.util.Collections.synchronizedSet( - dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(), - ) + private val dismissedGameIds = + ConcurrentMutableSet().apply { + dismissedStorage?.load(userPubkey)?.let { addAll(it) } + } // Track when games were last loaded to prevent duplicate fetches // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it) - private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap() + private val recentlyLoadedGames = ConcurrentMutableMap() - // Dedup incoming events (same event delivered by multiple relays) - // Bounded LRU: evict oldest when exceeding capacity - private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet()) + // Dedup incoming events (same event delivered by multiple relays). + // Bounded LRU: evict oldest when exceeding capacity. ConcurrentMutableSet + // wraps mutableSetOf() (LinkedHashSet on every KMP target), so iterator + // order is insertion order — required for LRU eviction below. + private val seenEventIds = ConcurrentMutableSet() private val seenEventIdsMax = 500 private val pollingDelegate = @@ -207,15 +211,21 @@ class ChessLobbyLogic( if (!event.isStartEvent() && !event.isMoveEvent()) return // Dedup: skip if we already processed this event ID (multiple relays deliver same event) - synchronized(seenEventIds) { - if (!seenEventIds.add(event.id)) return - if (seenEventIds.size > seenEventIdsMax) { - seenEventIds.iterator().let { - it.next() - it.remove() + val isNew = + seenEventIds.block { + if (!seenEventIds.add(event.id)) { + false + } else { + if (seenEventIds.size > seenEventIdsMax) { + seenEventIds.iterator().let { + it.next() + it.remove() + } + } + true } } - } + if (!isNew) return Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" } when { @@ -952,14 +962,14 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) dismissedGameIds.add(gameId) - dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + dismissedStorage?.save(userPubkey, dismissedGameIds.toSet()) } fun dismissAllCompletedGames() { val allIds = state.completedGames.value.map { it.gameId } state.clearCompletedGames() dismissedGameIds.addAll(allIds) - dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + dismissedStorage?.save(userPubkey, dismissedGameIds.toSet()) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt index 703ab3c985..6cfb0971e3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import co.touchlab.stately.collections.ConcurrentMutableMap +import co.touchlab.stately.collections.ConcurrentMutableSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener @@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.ConcurrentHashMap /** * Progress callback for relay fetch operations @@ -74,10 +75,10 @@ class ChessRelayFetchHelper( ): List { if (filters.isEmpty()) return emptyList() - val events = ConcurrentHashMap() + val events = ConcurrentMutableMap() val relayCount = filters.keys.size - val eoseReceived = ConcurrentHashMap.newKeySet() - val relayEventCounts = ConcurrentHashMap() + val eoseReceived = ConcurrentMutableSet() + val relayEventCounts = ConcurrentMutableMap() val allEose = CompletableDeferred() val subId = newSubId() @@ -96,7 +97,12 @@ class ChessRelayFetchHelper( forFilters: List?, ) { events[event.id] = event - val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1 + val count = + relayEventCounts.block { + val newVal = (relayEventCounts[relay] ?: 0) + 1 + relayEventCounts[relay] = newVal + newVal + } onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 06851efead..4e66aee673 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable -import java.util.concurrent.ConcurrentHashMap +import co.touchlab.stately.collections.ConcurrentMutableMap /** * This allows composables to directly register their queries @@ -32,7 +32,7 @@ import java.util.concurrent.ConcurrentHashMap abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls, Subscribable { - private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + private val composeSubscriptions = ConcurrentMutableMap() // This is called by main. Keep it really fast. override fun subscribe(query: T?) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index da8e82276a..61e6d46ce7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable +import co.touchlab.stately.collections.ConcurrentMutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap /** * This allows composables to directly register their queries @@ -41,7 +41,7 @@ import java.util.concurrent.ConcurrentHashMap abstract class MutableComposeSubscriptionManager( val scope: CoroutineScope, ) : ComposeSubscriptionManagerControls { - private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + private val composeSubscriptions = ConcurrentMutableMap() // This is called by main. Keep it really fast. fun subscribe(query: T?) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24ca396591..c33aeb00e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ zelory = "3.0.1" zoomable = "2.11.1" vlcj = "4.8.3" commonsImaging = "1.0.0-alpha6" +statelyConcurrent = "2.1.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" @@ -100,6 +101,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } +stately-concurrent-collections = { group = "co.touchlab", name = "stately-concurrent-collections", version.ref = "statelyConcurrent" } androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }