refactor: KMP WeakReference + drop synchronized(this) from commonMain

Phase 2 of the iOS plan — clears the java.lang.ref.WeakReference
blocker from commons/commonMain. Four model files migrated; one
additional sync primitive replaced.

- Adds expect class WeakReference<T : Any> in
  commons/commonMain/util/, with a jvmAndroid actual that typealiases
  to java.lang.ref.WeakReference. iOS actual will typealias to
  kotlin.native.ref.WeakReference when the target is added.
- Channel / Chatroom / MarmotGroupChatroom: the WeakReference(null)
  initializer relied on platform-type nullability of
  java.lang.ref.WeakReference's constructor. With T : Any in the expect
  class, fields become nullable (WeakReference<...>? = null) and the
  .get() callsites become ?.get(). Behaviorally equivalent.
- UserRelaysCache: same WeakReference migration, plus the
  synchronized(this) double-checked-locking idiom is replaced with
  co.touchlab.stately.concurrency.Lock + withLock (KMP).
  kotlin.synchronized is JVM-only; Lock comes in transitively via
  stately-concurrent-collections already added in the previous PR.

Model-layer @Synchronized usage in Channel/Chatroom/MarmotGroupChatroom/
Note (also JVM-only) is a separate iOS blocker and a separate PR.
This commit is contained in:
Claude
2026-05-24 18:30:27 +00:00
parent bf6467cdcf
commit 95beed16e1
6 changed files with 83 additions and 23 deletions
@@ -21,13 +21,13 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
abstract class Channel : NotesGatherer {
@@ -41,10 +41,10 @@ abstract class Channel : NotesGatherer {
private var relays = mapOf<NormalizedRelayUrl, Counter>()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -107,7 +107,7 @@ abstract class Channel : NotesGatherer {
addRelay(relay)
}
changesFlow.get()?.tryEmit(ListChange.Addition(note))
changesFlow?.get()?.tryEmit(ListChange.Addition(note))
flowSet?.notes?.invalidateData()
}
@@ -122,7 +122,7 @@ abstract class Channel : NotesGatherer {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
changesFlow?.get()?.tryEmit(ListChange.Deletion(note))
flowSet?.notes?.invalidateData()
}
@@ -140,7 +140,7 @@ abstract class Channel : NotesGatherer {
toBeRemoved.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
flowSet?.notes?.invalidateData()
@@ -156,7 +156,7 @@ abstract class Channel : NotesGatherer {
hidden.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden))
flowSet?.notes?.invalidateData()
@@ -26,13 +26,13 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference
/**
* Represents a Marmot MLS group chat room.
@@ -105,10 +105,10 @@ class MarmotGroupChatroom(
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
}
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -131,7 +131,7 @@ class MarmotGroupChatroom(
}
unreadCount.value += 1
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return true
}
return false
@@ -154,7 +154,7 @@ class MarmotGroupChatroom(
newestMessage = msg
}
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return true
}
return false
@@ -170,7 +170,7 @@ class MarmotGroupChatroom(
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
}
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
return true
}
return false
@@ -199,7 +199,7 @@ class MarmotGroupChatroom(
val toRemove = messages.minus(toKeep)
messages = toKeep
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
@@ -216,7 +216,7 @@ class MarmotGroupChatroom(
messages = emptySet()
newestMessage = null
unreadCount.value = 0
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
}
@@ -21,10 +21,12 @@
package com.vitorpamplona.amethyst.commons.model.nip01Core
import androidx.compose.runtime.Stable
import co.touchlab.stately.concurrency.Lock
import co.touchlab.stately.concurrency.withLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
data class RelayInfo(
@@ -53,9 +55,10 @@ val DefaultOrder =
class UserRelaysCache {
var data: Map<NormalizedRelayUrl, RelayInfo> = mapOf()
private var flow: WeakReference<MutableStateFlow<Wrapper>>? = null
private val flowLock = Lock()
fun flow() =
flow?.get() ?: synchronized(this) {
flow?.get() ?: flowLock.withLock {
flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) }
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
@@ -33,7 +34,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
class Chatroom : NotesGatherer {
@@ -44,10 +44,10 @@ class Chatroom : NotesGatherer {
var ownerSentMessage: Boolean = false
var newestMessage: Note? = null
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -82,7 +82,7 @@ class Chatroom : NotesGatherer {
subjectCreatedAt = msg.createdAt()
}
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return true
}
@@ -114,7 +114,7 @@ class Chatroom : NotesGatherer {
}
}
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
return true
}
@@ -138,7 +138,7 @@ class Chatroom : NotesGatherer {
val toRemove = messages.minus(toKeep)
messages = toKeep
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
@@ -0,0 +1,34 @@
/*
* 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.commons.util
/**
* KMP-friendly weak reference. JVM/Android map to `java.lang.ref.WeakReference`;
* iOS will map to `kotlin.native.ref.WeakReference` when that target is added.
*
* The referent may be reclaimed by GC at any time after this reference is
* constructed — callers must always null-check the result of [get].
*/
expect class WeakReference<T : Any>(
referent: T,
) {
fun get(): T?
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
actual typealias WeakReference<T> = java.lang.ref.WeakReference<T>