mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
+5
-4
@@ -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.
|
||||
|
||||
+27
-17
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-5
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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?) {
|
||||
|
||||
+2
-2
@@ -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?) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user