update claude.md

This commit is contained in:
nrobi144
2025-12-31 07:13:27 +02:00
parent f180fd39e1
commit 65859a9269
9 changed files with 1743 additions and 661 deletions
+419
View File
@@ -0,0 +1,419 @@
---
name: kotlin-coroutines
description: Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication.
---
# Kotlin Coroutines - Advanced Async Patterns
Expert guidance for complex async operations in Amethyst: relay pools, event streams, structured concurrency, and testing.
## Mental Model
```
Async Architecture in Amethyst:
Relay Pool (supervisorScope)
├── Relay 1 (launch) → callbackFlow → Events
├── Relay 2 (launch) → callbackFlow → Events
└── Relay 3 (launch) → callbackFlow → Events
merge() → distinctBy(id) → shareIn
Multiple Collectors (ViewModels, Services)
```
**Key principles:**
- **supervisorScope** - Children fail independently
- **callbackFlow** - Bridge callbacks to Flow
- **shareIn/stateIn** - Hot flows from cold
- **Backpressure** - buffer(), conflate(), DROP_OLDEST
## When to Use This Skill
Use for **advanced** async patterns:
- Multi-relay subscriptions with supervisorScope
- Complex Flow operators (flatMapLatest, combine, merge)
- callbackFlow for Android callbacks (connectivity, location)
- Backpressure handling in high-frequency streams
- Exception handling with CoroutineExceptionHandler
- Testing coroutines with runTest and Turbine
**Delegate to kotlin-expert for:**
- Basic StateFlow/SharedFlow patterns
- Simple viewModelScope.launch
- MutableStateFlow → asStateFlow()
## Core Patterns
### Pattern: callbackFlow for Relay Subscriptions
```kotlin
// Real pattern from NostrClientStaticReqAsStateFlow.kt
fun INostrClient.reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> = callbackFlow {
val subId = RandomInstance.randomChars(10)
var hasBeenLive = false
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener = object : IRequestListener {
override fun onEvent(event: Event, ...) {
if (event.id !in eventIds) {
currentEvents = if (hasBeenLive) {
// After EOSE: prepend
listOf(event) + currentEvents
} else {
// Before EOSE: append
currentEvents + event
}
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(...) {
hasBeenLive = true
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose { close(subId) }
}
```
**Key techniques:**
1. Deduplication with Set
2. EOSE handling (append → prepend strategy)
3. trySend (non-blocking from callback)
4. awaitClose for cleanup
### Pattern: Structured Concurrency for Relays
```kotlin
suspend fun connectToRelays(relays: List<Relay>) = supervisorScope {
relays.forEach { relay ->
launch {
try {
relay.connect()
relay.subscribe(filters).collect { event ->
eventChannel.send(event)
}
} catch (e: IOException) {
Log.e("Relay", "Connection failed: ${relay.url}", e)
// Other relays continue
}
}
}
}
```
**Why supervisorScope:**
- One relay failure doesn't cancel others
- All cancelled together when scope cancelled
- Proper cleanup guaranteed
### Pattern: Exception Handling for Services
```kotlin
// Real pattern from PushNotificationReceiverService.kt
class MyService : Service() {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("Service", "Caught: ${throwable.message}", throwable)
}
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + exceptionHandler
)
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
```
**Pattern benefits:**
- SupervisorJob: children fail independently
- ExceptionHandler: log instead of crash
- Scoped lifecycle: cancel all on destroy
### Pattern: Network Connectivity as Flow
```kotlin
// Real pattern from ConnectivityFlow.kt
val status = callbackFlow {
val networkCallback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(ConnectivityStatus.Active(...))
}
override fun onLost(network: Network) {
trySend(ConnectivityStatus.Off)
}
}
connectivityManager.registerCallback(networkCallback)
// Initial state
activeNetwork?.let { trySend(ConnectivityStatus.Active(...)) }
awaitClose {
connectivityManager.unregisterCallback(networkCallback)
}
}
.distinctUntilChanged()
.debounce(200) // Stabilize flapping
.flowOn(Dispatchers.IO)
```
**Key patterns:**
1. Emit initial state immediately
2. Register callback in flow body
3. Cleanup in awaitClose
4. Stabilize with debounce + distinctUntilChanged
### Pattern: Merge Events from Multiple Relays
```kotlin
fun observeFromRelays(
relays: List<NormalizedRelayUrl>,
filters: List<Filter>
): Flow<Event> =
relays.map { relay ->
client.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
}.merge()
.distinctBy { it.id }
```
**Flow:**
- Each relay: `Flow<List<Event>>`
- flatMapConcat: flatten to `Flow<Event>`
- merge(): combine all relays
- distinctBy: deduplicate across relays
## Advanced Operators
For comprehensive coverage of Flow operators:
- **flatMapLatest, combine, zip, merge** → See [advanced-flow-operators.md](references/advanced-flow-operators.md)
- **shareIn, stateIn** → Conversion to hot flows
- **buffer, conflate** → Backpressure strategies
- **debounce, sample** → Rate limiting
### Quick Reference
| Operator | Use Case | Example |
|----------|----------|---------|
| **flatMapLatest** | Cancel previous, switch to new | Search (cancel old query) |
| **combine** | Latest from ALL flows | combine(account, settings, connectivity) |
| **merge** | Single stream from multiple | merge(relay1, relay2, relay3) |
| **shareIn** | Multiple collectors, single upstream | Share expensive computation |
| **stateIn** | StateFlow from Flow | ViewModel state |
| **buffer(DROP_OLDEST)** | High-frequency streams | Real-time event feed |
| **conflate** | Latest only | UI updates |
| **debounce** | Wait for quiet period | Search input |
## Nostr Relay Patterns
For complete relay-specific patterns:
→ See [relay-patterns.md](references/relay-patterns.md)
Covers:
- Multi-relay subscription management
- Connection lifecycle and reconnection
- Event deduplication strategies
- Backpressure for high-frequency events
- EOSE handling patterns
## Testing
For comprehensive testing patterns:
→ See [testing-coroutines.md](references/testing-coroutines.md)
**Quick testing pattern:**
```kotlin
@Test
fun `relay subscription receives events`() = runTest {
val client = FakeNostrClient()
client.reqAsFlow(relay, filters).test {
assertEquals(emptyList(), awaitItem())
client.sendEvent(event1)
assertEquals(listOf(event1), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
**Testing tools:**
- `runTest` - Virtual time, auto cleanup
- Turbine `.test {}` - Flow assertions
- `advanceTimeBy()` - Control time
- Fake implementations over mocks
## Common Scenarios
### Scenario: Implement New Relay Feature
**Steps:**
1. callbackFlow for subscription
2. Deduplication (Set of event IDs)
3. awaitClose for cleanup
4. Test with FakeNostrClient
**Example:** Add subscription for specific event kind
```kotlin
fun observeKind(kind: Int): Flow<Event> = callbackFlow {
val listener = object : IRequestListener {
override fun onEvent(event: Event, ...) {
if (event.kind == kind) {
trySend(event)
}
}
}
client.subscribe(listener)
awaitClose { client.unsubscribe(listener) }
}
```
### Scenario: Handle Network Connectivity Changes
**Steps:**
1. callbackFlow for connectivity
2. flatMapLatest to reconnect
3. debounce to stabilize
4. Exception handling for failures
**Example:** Reconnect relays on connectivity
```kotlin
connectivityFlow
.flatMapLatest { status ->
when (status) {
Active -> relayPool.observeEvents()
else -> emptyFlow()
}
}
.catch { e -> Log.e("Error", e) }
.collect { event -> handleEvent(event) }
```
### Scenario: Optimize Multi-Collector Performance
**Steps:**
1. Use shareIn for expensive upstream
2. Configure SharingStarted strategy
3. Set replay buffer size
4. Test with multiple collectors
**Example:** Share relay subscription
```kotlin
val events: SharedFlow<Event> = client
.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
```
## Anti-Patterns
**Using GlobalScope**
```kotlin
GlobalScope.launch { /* Leaks, no structured concurrency */ }
```
**Use scoped coroutines**
```kotlin
viewModelScope.launch { /* Cancelled with ViewModel */ }
```
---
**Forgetting awaitClose**
```kotlin
callbackFlow {
registerCallback()
// Missing cleanup!
}
```
**Always cleanup**
```kotlin
callbackFlow {
registerCallback()
awaitClose { unregisterCallback() }
}
```
---
**Blocking in Flow**
```kotlin
flow.map { Thread.sleep(1000); process(it) }
```
**Suspend, don't block**
```kotlin
flow.map { delay(1000); process(it) }.flowOn(Dispatchers.Default)
```
---
**Ignoring backpressure**
```kotlin
fastProducer.collect { slowConsumer(it) } // Blocks producer!
```
**Handle backpressure**
```kotlin
fastProducer
.buffer(64, BufferOverflow.DROP_OLDEST)
.collect { slowConsumer(it) }
```
## Delegation
**Use kotlin-expert for:**
- Basic StateFlow/SharedFlow patterns
- viewModelScope.launch usage
- Simple MutableStateFlow → asStateFlow()
**Use nostr-expert for:**
- Nostr protocol details (NIPs, event structure)
- Event creation and signing
- Cryptographic operations
**This skill provides:**
- Advanced async patterns
- Structured concurrency
- Complex Flow operators
- Testing strategies
- Relay-specific async patterns
## Resources
- **references/advanced-flow-operators.md** - All Flow operators with examples
- **references/relay-patterns.md** - Nostr relay async patterns from codebase
- **references/testing-coroutines.md** - Complete testing guide
## Quick Decision Tree
```
Need async operation?
├─ Simple ViewModel state update → kotlin-expert (StateFlow)
├─ Android callback → This skill (callbackFlow)
├─ Multiple concurrent operations → This skill (supervisorScope)
├─ Complex Flow transformation → This skill (references/advanced-flow-operators.md)
├─ Relay subscription → This skill (references/relay-patterns.md)
└─ Testing async code → This skill (references/testing-coroutines.md)
```
@@ -0,0 +1,309 @@
# Advanced Flow Operators
Comprehensive guide to Flow operators for complex async patterns in Amethyst.
## Transformation Operators
### flatMapLatest - Cancel Previous, Switch to New
**Use when:** Latest value matters, previous operations should cancel
```kotlin
// User types in search box → cancel previous search
searchQuery
.flatMapLatest { query ->
repository.search(query) // Cancels previous search
}
.collect { results -> updateUI(results) }
```
**Amethyst pattern:**
```kotlin
// Switch relays based on latest account
accountFlow
.flatMapLatest { account ->
relayPool.observeEvents(account.relays)
}
```
### flatMapConcat - Sequential Processing
**Use when:** Order matters, process one at a time
```kotlin
eventIds
.flatMapConcat { id ->
repository.fetchEvent(id)
}
.collect { event -> process(event) }
```
### flatMapMerge - Concurrent Processing
**Use when:** Process multiple simultaneously, order doesn't matter
```kotlin
relays
.flatMapMerge(concurrency = 10) { relay ->
relay.subscribe(filters)
}
.collect { event -> handleEvent(event) }
```
## Combination Operators
### combine - Latest from Multiple Flows
**Use when:** Need latest value from ALL flows
```kotlin
combine(
accountFlow,
settingsFlow,
connectivityFlow
) { account, settings, connectivity ->
AppState(account, settings, connectivity)
}.collect { state -> render(state) }
```
**Pattern:** Re-emits whenever ANY source emits
### zip - Pair Values in Order
**Use when:** Need corresponding values from flows
```kotlin
zip(requestFlow, responseFlow) { req, res ->
Pair(req, res)
}
```
**Pattern:** Waits for BOTH to emit before pairing
### merge - Combine Multiple Flows
**Use when:** Treat multiple flows as single stream
```kotlin
merge(
relay1.events,
relay2.events,
relay3.events
).collect { event -> handleEvent(event) }
```
## Backpressure & Buffering
### shareIn - Hot Flow from Cold
**Use when:** Multiple collectors should share single upstream
```kotlin
val sharedEvents = repository.observeEvents()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
// Multiple collectors share same upstream
sharedEvents.collect { /* collector 1 */ }
sharedEvents.collect { /* collector 2 */ }
```
**SharingStarted strategies:**
- `Eagerly` - Start immediately, never stop
- `Lazily` - Start on first subscriber, never stop
- `WhileSubscribed(stopTimeout)` - Stop after last unsubscribe + timeout
### stateIn - StateFlow from Cold Flow
**Use when:** Convert Flow to StateFlow (always has value)
```kotlin
val uiState: StateFlow<UiState> = repository.observeData()
.map { data -> UiState.Success(data) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading
)
```
**Amethyst pattern:**
```kotlin
// Connectivity status as StateFlow
val connectivity: StateFlow<ConnectivityStatus> =
connectivityFlow.status
.stateIn(
scope = serviceScope,
started = SharingStarted.Eagerly,
initialValue = ConnectivityStatus.Off
)
```
### buffer - Control Backpressure
**Use when:** Producer faster than consumer
```kotlin
eventFlow
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.collect { event -> slowProcessor(event) }
```
**Strategies:**
- `SUSPEND` - Slow down producer (default)
- `DROP_OLDEST` - Drop oldest in buffer
- `DROP_LATEST` - Drop newest emission
### conflate - Keep Only Latest
**Use when:** Only latest value matters, skip intermediate
```kotlin
locationFlow
.conflate() // Skip intermediate locations
.collect { location -> updateMap(location) }
```
## Debouncing & Throttling
### debounce - Wait for Quiet Period
**Use when:** Wait for user to stop typing
```kotlin
searchQuery
.debounce(300) // Wait 300ms after last emission
.flatMapLatest { query -> search(query) }
```
**Amethyst pattern:**
```kotlin
// ConnectivityFlow.kt:87
connectivityFlow
.distinctUntilChanged()
.debounce(200) // Wait 200ms for network to stabilize
.flowOn(Dispatchers.IO)
```
### sample - Periodic Sampling
**Use when:** Rate-limit high-frequency emissions
```kotlin
sensorData
.sample(1000) // Sample every 1 second
.collect { data -> process(data) }
```
## Error Handling
### catch - Handle Upstream Errors
**Use when:** Graceful degradation needed
```kotlin
repository.fetchData()
.catch { e ->
Log.e("Error", e)
emit(emptyList()) // Fallback value
}
.collect { data -> updateUI(data) }
```
**Pattern:** Only catches UPSTREAM errors, not in collect block
### retry/retryWhen - Automatic Retry
```kotlin
relayConnection
.retry(3) { cause ->
cause is IOException // Only retry on network errors
}
```
## Context Switching
### flowOn - Change Upstream Dispatcher
**Use when:** Offload work from current context
```kotlin
repository.fetchData()
.map { heavyProcessing(it) }
.flowOn(Dispatchers.Default) // Heavy work on Default
.collect { updateUI(it) } // Collect on Main
```
**Critical:** Only affects UPSTREAM operators
**Amethyst pattern:**
```kotlin
// ConnectivityFlow.kt:87
callbackFlow { /* ... */ }
.distinctUntilChanged()
.debounce(200)
.flowOn(Dispatchers.IO) // All upstream on IO
```
## Common Patterns
### Pattern: Multi-Relay Subscription
```kotlin
fun observeFromMultipleRelays(relays: List<Relay>, filters: List<Filter>): Flow<Event> =
relays.map { relay ->
relay.subscribe(filters)
}.merge()
.distinctBy { it.id }
```
### Pattern: Load + Cache + Observe
```kotlin
fun observeWithCache(id: String): Flow<Data> = flow {
// Emit cached value immediately
cache[id]?.let { emit(it) }
// Then observe updates
emitAll(repository.observe(id))
}.distinctUntilChanged()
```
### Pattern: Retry with Exponential Backoff
```kotlin
fun <T> Flow<T>.retryWithBackoff(
maxRetries: Int = 3,
initialDelay: Long = 1000
): Flow<T> = retryWhen { cause, attempt ->
if (attempt >= maxRetries || cause !is IOException) {
false
} else {
delay(initialDelay * (1L shl attempt.toInt()))
true
}
}
```
## Performance Tips
1. **Use shareIn for expensive operations**
- Compute once, share with multiple collectors
2. **Choose right backpressure strategy**
- UI updates: `conflate()` or `DROP_OLDEST`
- Events: `buffer()` with appropriate size
3. **flowOn placement matters**
- Place after expensive operators to offload them
4. **Avoid unnecessary emissions**
- Use `distinctUntilChanged()` when appropriate
- Consider `debounce()` for high-frequency sources
5. **StateFlow vs SharedFlow**
- StateFlow: Always has value, conflates
- SharedFlow: Optional replay, configurable buffering
@@ -0,0 +1,480 @@
# Nostr Relay Async Patterns
Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst.
## Core Pattern: callbackFlow for Relay Subscriptions
### Pattern: Subscription as Flow
**Real implementation from NostrClientStaticReqAsStateFlow.kt:**
```kotlin
fun INostrClient.reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> =
callbackFlow {
val subId = RandomInstance.randomChars(10)
var hasBeenLive = false
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener = object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.id !in eventIds) {
if (hasBeenLive) {
// After EOSE: prepend new events
val list = ArrayList<Event>(1 + currentEvents.size)
list.add(event)
list.addAll(currentEvents)
currentEvents = list
} else {
// Before EOSE: append events
currentEvents = currentEvents + event
}
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
hasBeenLive = true
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose {
close(subId)
}
}
```
**Key techniques:**
1. **callbackFlow** - Bridge callback API to Flow
2. **Deduplication** - `eventIds` set prevents duplicates
3. **EOSE handling** - Changes insertion strategy (append → prepend)
4. **awaitClose** - Cleanup when flow cancelled
5. **trySend** - Non-blocking emission from callback
## Multi-Relay Patterns
### Pattern: Merge Events from Multiple Relays
```kotlin
fun observeFromRelays(
relays: List<NormalizedRelayUrl>,
filters: List<Filter>
): Flow<Event> =
relays.map { relay ->
client.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
}.merge()
.distinctBy { it.id }
```
**Explanation:**
- Each relay produces `Flow<List<Event>>`
- `flatMapConcat` flattens to `Flow<Event>`
- `merge()` combines all relay flows
- `distinctBy` deduplicates across relays
### Pattern: Concurrent Relay Operations with supervisorScope
```kotlin
suspend fun subscribeToRelays(
relays: List<Relay>,
filters: List<Filter>
) = supervisorScope {
relays.forEach { relay ->
launch {
relay.subscribe(filters).collect { event ->
eventChannel.send(event)
}
}
}
}
```
**Why supervisorScope:**
- If one relay fails, others continue
- All children cancelled when scope cancelled
- Structured concurrency maintained
## Backpressure Handling
### Pattern: Buffer with Drop Strategy
**For high-frequency event streams:**
```kotlin
relayFlow
.buffer(
capacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
.collect { event -> processEvent(event) }
```
**Strategy selection:**
- `DROP_OLDEST` - For real-time feeds (lose old events OK)
- `DROP_LATEST` - For priority queues (lose new events OK)
- `SUSPEND` - For critical events (slow down producer)
### Pattern: Conflate for UI Updates
```kotlin
val uiEvents: Flow<UiEvent> = relayEvents
.map { event -> toUiEvent(event) }
.conflate() // Skip intermediate, show latest
.flowOn(Dispatchers.Default)
```
## Connection Management
### Pattern: Network Connectivity as Flow
**Real implementation from ConnectivityFlow.kt:**
```kotlin
@OptIn(FlowPreview::class)
val status = callbackFlow {
trySend(ConnectivityStatus.StartingService)
val connectivityManager = context.getConnectivityManager()
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(
network.networkHandle,
it.isMeteredOrMobileData()
))
}
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isMobile = networkCapabilities.isMeteredOrMobileData()
trySend(ConnectivityStatus.Active(
network.networkHandle,
isMobile
))
}
override fun onLost(network: Network) {
trySend(ConnectivityStatus.Off)
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
// Send initial state
connectivityManager.activeNetwork?.let { network ->
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(
network.networkHandle,
it.isMeteredOrMobileData()
))
}
}
awaitClose {
connectivityManager.unregisterNetworkCallback(networkCallback)
trySend(ConnectivityStatus.Off)
}
}
.distinctUntilChanged()
.debounce(200) // Stabilize rapid changes
.flowOn(Dispatchers.IO)
```
**Key patterns:**
1. **Initial state** - Emit current connectivity immediately
2. **Callback registration** - Register listener in flow body
3. **Cleanup** - Unregister in `awaitClose`
4. **Stabilization** - `debounce(200)` prevents flapping
5. **Deduplication** - `distinctUntilChanged()` skips redundant updates
### Pattern: Reconnect on Connectivity Change
```kotlin
connectivityFlow
.flatMapLatest { status ->
when (status) {
is ConnectivityStatus.Active -> {
relayPool.connectAll()
relayPool.observeEvents()
}
else -> emptyFlow()
}
}
.collect { event -> handleEvent(event) }
```
## Exception Handling in Async Operations
### Pattern: CoroutineExceptionHandler + SupervisorJob
**Real implementation from PushNotificationReceiverService.kt:**
```kotlin
class PushNotificationReceiverService : FirebaseMessagingService() {
// Catch all uncaught exceptions
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
// Children fail independently, handler catches all
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + exceptionHandler
)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
scope.launch(Dispatchers.IO) {
parseMessage(remoteMessage.data)?.let { receiveIfNew(it) }
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
```
**Why this pattern:**
- **SupervisorJob** - One failure doesn't cancel others
- **ExceptionHandler** - Log exceptions, don't crash
- **Scoped lifecycle** - Cancel all on destroy
### Pattern: Retry with Backoff for Relay Connections
```kotlin
fun connectWithRetry(relay: Relay): Flow<ConnectionStatus> = flow {
var attempt = 0
val maxRetries = 5
val baseDelay = 1000L
while (attempt < maxRetries) {
try {
emit(ConnectionStatus.Connecting)
relay.connect()
emit(ConnectionStatus.Connected)
return@flow
} catch (e: Exception) {
attempt++
emit(ConnectionStatus.Error(e, attempt))
if (attempt < maxRetries) {
val delay = baseDelay * (1L shl attempt) // Exponential backoff
delay(delay)
}
}
}
emit(ConnectionStatus.Failed)
}
```
## Subscription Lifecycle
### Pattern: Auto-Cleanup Subscription
```kotlin
@Composable
fun ObserveRelayEvents(
filters: List<Filter>,
onEvent: (Event) -> Unit
) {
val scope = rememberCoroutineScope()
DisposableEffect(filters) {
val job = scope.launch {
relayClient.reqAsFlow(filters).collect { events ->
events.forEach { onEvent(it) }
}
}
onDispose {
job.cancel() // Cancels flow, triggers awaitClose
}
}
}
```
**Lifecycle:**
1. Composable enters → subscribe
2. filters change → cancel + re-subscribe
3. Composable leaves → cancel + cleanup
### Pattern: Multiple Concurrent Subscriptions
```kotlin
fun observeMultipleFeeds(
account: Account
): Flow<Event> = channelFlow {
supervisorScope {
// Home feed
launch {
client.reqAsFlow(filters = homeFeedFilters)
.collect { events -> events.forEach { send(it) } }
}
// Notifications
launch {
client.reqAsFlow(filters = notificationFilters)
.collect { events -> events.forEach { send(it) } }
}
// DMs
launch {
client.reqAsFlow(filters = dmFilters)
.collect { events -> events.forEach { send(it) } }
}
}
}
```
**Benefits:**
- All subscriptions run concurrently
- One failure doesn't affect others (supervisorScope)
- Single output channel for all events
## Performance Optimization
### Pattern: Shared Upstream for Multiple Collectors
```kotlin
class RelayViewModel(private val client: INostrClient) : ViewModel() {
val events: SharedFlow<Event> = client
.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
}
// Multiple collectors share single relay subscription
events.collect { /* UI 1 */ }
events.collect { /* UI 2 */ }
```
### Pattern: Event Deduplication Cache
```kotlin
class EventCache {
private val seen = mutableSetOf<HexKey>()
fun filterNew(events: List<Event>): List<Event> =
events.filter { event ->
if (event.id in seen) {
false
} else {
seen.add(event.id)
true
}
}
}
val deduplicatedEvents = relayEvents
.map { events -> cache.filterNew(events) }
.filter { it.isNotEmpty() }
```
## Testing Relay Flows
### Pattern: Test with Fake Relay
```kotlin
@Test
fun `subscription receives events`() = runTest {
val fakeRelay = FakeRelay()
val client = NostrClient(fakeRelay)
val events = mutableListOf<Event>()
val job = launch {
client.reqAsFlow(relay, filters).collect { list ->
events.addAll(list)
}
}
// Simulate relay responses
fakeRelay.sendEvent(testEvent1)
advanceTimeBy(100)
fakeRelay.sendEvent(testEvent2)
advanceTimeBy(100)
assertEquals(2, events.size)
job.cancel()
}
```
## Common Pitfalls
### ❌ Forgetting awaitClose
```kotlin
// BAD: Subscription never cleaned up
callbackFlow {
relay.subscribe(listener)
// Missing awaitClose!
}
```
```kotlin
// GOOD: Proper cleanup
callbackFlow {
relay.subscribe(listener)
awaitClose {
relay.unsubscribe(listener)
}
}
```
### ❌ Using GlobalScope
```kotlin
// BAD: Unstructured, leaks
GlobalScope.launch {
relay.connect()
}
```
```kotlin
// GOOD: Scoped to lifecycle
viewModelScope.launch {
relay.connect()
}
```
### ❌ Blocking in Flow Operators
```kotlin
// BAD: Blocks collector
flow.map { event ->
Thread.sleep(1000) // Blocks!
process(event)
}
```
```kotlin
// GOOD: Use flowOn to offload
flow
.map { event ->
delay(1000) // Suspends, doesn't block
process(event)
}
.flowOn(Dispatchers.Default)
```
@@ -0,0 +1,493 @@
# Testing Coroutines
Comprehensive guide for testing async code with runTest, Turbine, and best practices.
## runTest - Standard Testing
### Basic Pattern
```kotlin
@Test
fun `test suspend function`() = runTest {
val result = repository.fetchData()
assertEquals(expected, result)
}
```
**What runTest does:**
- Skips delays automatically
- Provides TestScope
- Advances virtual time
- Waits for all coroutines to complete
### Testing StateFlow
```kotlin
@Test
fun `stateflow updates correctly`() = runTest {
val viewModel = MyViewModel()
// Initial state
assertEquals(UiState.Loading, viewModel.state.value)
// Trigger action
viewModel.loadData()
advanceUntilIdle() // Run all pending coroutines
// Verify final state
assertEquals(UiState.Success(data), viewModel.state.value)
}
```
### Testing with Time Control
```kotlin
@Test
fun `debounce works correctly`() = runTest {
val viewModel = SearchViewModel()
viewModel.search("a")
advanceTimeBy(100) // 100ms passed
viewModel.search("ab")
advanceTimeBy(100)
viewModel.search("abc")
advanceTimeBy(300) // Debounce completes
// Only "abc" should have triggered search
assertEquals(listOf("abc"), viewModel.searchQueries)
}
```
**Time control functions:**
- `advanceTimeBy(millis)` - Move virtual time forward
- `advanceUntilIdle()` - Run all pending work
- `runCurrent()` - Run currently scheduled tasks only
## Turbine - Flow Testing Library
### Basic Collection Testing
```kotlin
@Test
fun `flow emits expected values`() = runTest {
repository.observeData().test {
assertEquals(Item1, awaitItem())
assertEquals(Item2, awaitItem())
assertEquals(Item3, awaitItem())
awaitComplete()
}
}
```
### Testing Flow Transformations
```kotlin
@Test
fun `map transforms correctly`() = runTest {
val source = flowOf(1, 2, 3)
source
.map { it * 2 }
.test {
assertEquals(2, awaitItem())
assertEquals(4, awaitItem())
assertEquals(6, awaitItem())
awaitComplete()
}
}
```
### Testing Relay Subscriptions
```kotlin
@Test
fun `relay subscription receives events`() = runTest {
val fakeClient = FakeNostrClient()
fakeClient.reqAsFlow(relay, filters).test {
// Initially empty
assertEquals(emptyList(), awaitItem())
// Send event
fakeClient.sendEvent(event1)
assertEquals(listOf(event1), awaitItem())
// Send another
fakeClient.sendEvent(event2)
assertEquals(listOf(event1, event2), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
### Testing Error Handling
```kotlin
@Test
fun `catch handles errors gracefully`() = runTest {
val errorFlow = flow {
emit(1)
throw IOException("Network error")
}.catch { emit(-1) } // Fallback value
errorFlow.test {
assertEquals(1, awaitItem())
assertEquals(-1, awaitItem())
awaitComplete()
}
}
```
### Testing StateFlow with Turbine
```kotlin
@Test
fun `stateflow emits updates`() = runTest {
val viewModel = MyViewModel()
viewModel.state.test {
// Skip initial value
assertEquals(UiState.Loading, awaitItem())
// Trigger update
viewModel.loadData()
assertEquals(UiState.Success(data), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
**Turbine assertions:**
- `awaitItem()` - Get next emission or fail
- `awaitComplete()` - Verify flow completed
- `awaitError()` - Verify flow threw exception
- `expectNoEvents()` - Assert no emissions in timeframe
- `cancelAndIgnoreRemainingEvents()` - Stop test
## Testing Patterns for Amethyst
### Pattern: Test Relay Connection Flow
```kotlin
@Test
fun `reconnects on connectivity change`() = runTest {
val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off)
val relayPool = FakeRelayPool()
connectivityFlow
.flatMapLatest { status ->
when (status) {
is ConnectivityStatus.Active -> relayPool.connectAll()
else -> emptyFlow()
}
}
.test {
// Initially offline
expectNoEvents()
// Go online
connectivityFlow.value = ConnectivityStatus.Active(1L, false)
assertTrue(relayPool.connected)
cancelAndIgnoreRemainingEvents()
}
}
```
### Pattern: Test Event Deduplication
```kotlin
@Test
fun `deduplicates events across relays`() = runTest {
val relay1 = FakeRelay()
val relay2 = FakeRelay()
merge(relay1.events, relay2.events)
.distinctBy { it.id }
.test {
// Both relays send same event
relay1.send(event1)
relay2.send(event1)
// Only one emission
assertEquals(event1, awaitItem())
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
```
### Pattern: Test Backpressure Handling
```kotlin
@Test
fun `drops oldest events when buffer full`() = runTest {
val fastProducer = flow {
repeat(100) { emit(it) }
}
fastProducer
.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.test {
// Slow consumer
delay(100)
// Should have dropped oldest, kept newest
val items = mutableListOf<Int>()
repeat(10) {
items.add(awaitItem())
}
// Newest items present
assertTrue(90 in items)
assertTrue(99 in items)
awaitComplete()
}
}
```
### Pattern: Test Concurrent Subscriptions
```kotlin
@Test
fun `multiple subscriptions run concurrently`() = runTest {
val client = FakeNostrClient()
val feed1 = async { client.reqAsFlow(relay1, filters1).first() }
val feed2 = async { client.reqAsFlow(relay2, filters2).first() }
client.sendTo(relay1, event1)
client.sendTo(relay2, event2)
assertEquals(listOf(event1), feed1.await())
assertEquals(listOf(event2), feed2.await())
}
```
## Fakes and Mocks
### Fake NostrClient
```kotlin
class FakeNostrClient : INostrClient {
private val subscriptions = mutableMapOf<String, MutableSharedFlow<Event>>()
override fun reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>
): Flow<List<Event>> = callbackFlow {
val subId = RandomInstance.randomChars(10)
val flow = MutableSharedFlow<Event>()
subscriptions[subId] = flow
val events = mutableListOf<Event>()
flow.collect { event ->
events.add(event)
send(events.toList())
}
awaitClose {
subscriptions.remove(subId)
}
}
fun sendEvent(event: Event) {
subscriptions.values.forEach { it.tryEmit(event) }
}
fun sendTo(relay: NormalizedRelayUrl, event: Event) {
subscriptions[relay.url]?.tryEmit(event)
}
}
```
### Fake Relay Pool
```kotlin
class FakeRelayPool {
var connected = false
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
fun connectAll(): Flow<Unit> = flow {
connected = true
emit(Unit)
}
fun disconnect() {
connected = false
}
suspend fun sendEvent(event: Event) {
_events.emit(event)
}
}
```
## Testing Exception Handling
### Test CoroutineExceptionHandler
```kotlin
@Test
fun `exception handler catches errors`() = runTest {
val errors = mutableListOf<Throwable>()
val handler = CoroutineExceptionHandler { _, throwable ->
errors.add(throwable)
}
val scope = CoroutineScope(
Dispatchers.Unconfined + SupervisorJob() + handler
)
scope.launch {
throw IOException("Test error")
}
advanceUntilIdle()
assertEquals(1, errors.size)
assertTrue(errors[0] is IOException)
}
```
### Test Retry Logic
```kotlin
@Test
fun `retries failed connections`() = runTest {
var attempts = 0
val maxRetries = 3
flow {
attempts++
if (attempts < maxRetries) {
throw IOException("Connection failed")
}
emit("Success")
}
.retry(maxRetries)
.test {
assertEquals("Success", awaitItem())
awaitComplete()
assertEquals(3, attempts)
}
}
```
## Common Testing Patterns
### Pattern: Verify No Emissions After Cancellation
```kotlin
@Test
fun `no emissions after cancellation`() = runTest {
val flow = flow {
emit(1)
delay(1000)
emit(2) // Should not emit
}
flow.test {
assertEquals(1, awaitItem())
cancel()
// Verify no more emissions
expectNoEvents()
}
}
```
### Pattern: Test Time-Based Operations
```kotlin
@Test
fun `periodic emission works`() = runTest {
flow {
repeat(3) {
emit(it)
delay(1000)
}
}.test {
assertEquals(0, awaitItem())
advanceTimeBy(1000)
assertEquals(1, awaitItem())
advanceTimeBy(1000)
assertEquals(2, awaitItem())
awaitComplete()
}
}
```
### Pattern: Test Hot Flow Conversion
```kotlin
@Test
fun `shareIn creates hot flow`() = runTest {
var emissions = 0
val source = flow {
repeat(3) {
emissions++
emit(it)
}
}
val shared = source.shareIn(
scope = this,
started = SharingStarted.Eagerly,
replay = 1
)
// First collector
shared.take(2).collect()
assertEquals(2, emissions) // Emitted 0, 1
// Second collector - shares upstream
shared.take(1).collect()
assertEquals(3, emissions) // Only emitted 2, not restarted
cancel()
}
```
## Best Practices
1. **Use runTest for all coroutine tests**
- Provides virtual time
- Automatic cleanup
2. **Use Turbine for Flow testing**
- Clearer assertions
- Better error messages
3. **Test both success and error paths**
- Normal flow
- Exception handling
- Edge cases
4. **Control virtual time explicitly**
- Don't rely on real delays
- Use `advanceTimeBy()` and `advanceUntilIdle()`
5. **Create fakes, not mocks**
- Simpler to maintain
- More realistic behavior
- Easier to debug
6. **Test cancellation behavior**
- Verify cleanup happens
- Check no emissions after cancel
7. **Test concurrent operations**
- Use `async` to spawn concurrent work
- Verify independence with SupervisorJob