refactor: drop ConcurrentHashMap from commonMain

Phase 2 of the iOS plan — clears the ConcurrentHashMap blockers from
commons/commonMain. Five files migrated (the four flagged in the
initial audit + ChessLobbyLogic, which used fully-qualified inline
java.util references that the import-based audit missed).

Adds co.touchlab:stately-concurrent-collections 2.1.0 — a small,
mature KMP library that provides ConcurrentMutableMap /
ConcurrentMutableSet with semantics equivalent to ConcurrentHashMap /
ConcurrentHashMap.newKeySet on every Kotlin target. The .block { }
helper covers the compound-update paths (ChessRelayFetchHelper's
per-relay event-count compute, ChessLobbyLogic's bounded-LRU dedup).

- ComposeSubscriptionManager + MutableComposeSubscriptionManager:
  ConcurrentHashMap -> ConcurrentMutableMap
- ChessEventCollector + ChessEventCollectorManager: map and Set
- ChessRelayFetchHelper: in-function event/relay state
- ChessLobbyLogic: replaces dismissedGameIds (synchronizedSet),
  recentlyLoadedGames (ConcurrentHashMap), seenEventIds (bounded LRU
  using LinkedHashSet via Collections.synchronizedSet + synchronized {}).
  seenEventIds keeps insertion-order eviction semantics because
  mutableSetOf returns LinkedHashSet on every KMP target.
This commit is contained in:
Claude
2026-05-24 18:24:22 +00:00
parent 1b6b699d76
commit bf6467cdcf
7 changed files with 56 additions and 30 deletions
+7
View File
@@ -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)
@@ -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<JesterEvent?> = _startEvent.asStateFlow()
// Move events (deduplicated by event ID)
private val moves = ConcurrentHashMap<String, JesterEvent>()
private val moves = ConcurrentMutableMap<String, JesterEvent>()
// Track all processed event IDs for fast deduplication
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
private val processedEventIds = ConcurrentMutableSet<String>()
// 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<String, ChessEventCollector>()
private val collectors = ConcurrentMutableMap<String, ChessEventCollector>()
/**
* Get or create a collector for a game.
@@ -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<String> =
java.util.Collections.synchronizedSet(
dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(),
)
private val dismissedGameIds =
ConcurrentMutableSet<String>().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<String, Long>()
private val recentlyLoadedGames = ConcurrentMutableMap<String, Long>()
// Dedup incoming events (same event delivered by multiple relays)
// Bounded LRU: evict oldest when exceeding capacity
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
// 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<String>()
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())
}
/**
@@ -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<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val events = ConcurrentMutableMap<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
val eoseReceived = ConcurrentMutableSet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentMutableMap<NormalizedRelayUrl, Int>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
@@ -96,7 +97,12 @@ class ChessRelayFetchHelper(
forFilters: List<Filter>?,
) {
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))
}
@@ -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<T> :
ComposeSubscriptionManagerControls,
Subscribable<T> {
private var composeSubscriptions: ConcurrentHashMap<T, T> = ConcurrentHashMap()
private val composeSubscriptions = ConcurrentMutableMap<T, T>()
// This is called by main. Keep it really fast.
override fun subscribe(query: T?) {
@@ -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<T : MutableQueryState>(
val scope: CoroutineScope,
) : ComposeSubscriptionManagerControls {
private var composeSubscriptions: ConcurrentHashMap<T, Job?> = ConcurrentHashMap()
private val composeSubscriptions = ConcurrentMutableMap<T, Job?>()
// This is called by main. Keep it really fast.
fun subscribe(query: T?) {
+2
View File
@@ -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" }