| name | coroutines-reference |
| description | Nebula-specific Kotlin coroutines patterns — stateIn WhileSubscribed, CancellationException rules, withTimeout for widget APIs, supervisorScope for parallel refresh, and CoroutineName for debugging. Loaded automatically when writing ViewModel or Flow code. |
| user-invocable | false |
Nebula Coroutines Reference
Distilled from official Kotlin docs and battle-tested patterns. All examples are Nebula-context (widget refresh, browser, app grid).
1. stateIn(WhileSubscribed) — The Standard ViewModel Pattern
Always use this when exposing StateFlow from a ViewModel:
class NasaApodViewModel(private val repo: NasaApodRepository) : ViewModel() {
val state: StateFlow<ApodState> = repo.apodFlow()
.map { data -> ApodState.Success(data, Instant.now()) }
.catch { e -> emit(ApodState.Error(e.message ?: "Failed")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
initialValue = ApodState.Loading
)
}
Why WhileSubscribed(5000)?
- Upstream Flow stops collecting 5 seconds after the last subscriber (UI) disappears
- 5-second grace period survives Android screen rotations without restarting the upstream
- Automatically resumes when a new subscriber arrives (launcher comes back to foreground)
- Saves CPU + battery when Nebula launcher is backgrounded
- ❌
SharingStarted.Eagerly — keeps collecting forever, even when backgrounded
- ❌
SharingStarted.Lazily — never stops collecting once started
2. CancellationException — NEVER Catch It Silently
This is the most dangerous coroutine mistake. CancellationException extends Exception,
so catch (e: Exception) will eat it — breaking structured concurrency.
try {
val data = withTimeout(30.seconds) { api.fetchApod(key) }
_state.value = ApodState.Success(data, Instant.now())
} catch (e: TimeoutCancellationException) {
_state.value = ApodState.Error("Request timed out after 30s")
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_state.value = ApodState.Error("Network error: ${e.message}")
} catch (e: Exception) {
_state.value = ApodState.Error(e.message ?: "Unknown error")
}
Rule of thumb:
catch (e: CancellationException) { throw e } — always, when catching broad exceptions
catch (e: TimeoutCancellationException) — safe to handle, it's your own timeout
runCatching { }.getOrElse { } — also catches CancellationException! Use only for truly throwaway operations
3. withTimeout — Mandatory for External API Calls
Every network call in Nebula (NASA, OpenMeteo, RSS, FxA) must have a timeout.
Without it, a slow server stalls the widget indefinitely.
val apod = withTimeoutOrNull(30.seconds) {
nasaApi.fetchApod(BuildConfig.NASA_API_KEY)
} ?: return@launch
val weather = withTimeout(15.seconds) {
openMeteoApi.getCurrent(lat, lon)
}
val feed = withTimeout(20.seconds) {
rssParser.fetch(feedUrl)
}
withTimeout vs withTimeoutOrNull:
withTimeout — throws TimeoutCancellationException on timeout (catch it explicitly)
withTimeoutOrNull — returns null on timeout (no exception, simpler null check)
4. supervisorScope — Parallel Widget Refresh (Failure Isolation)
When loading multiple widgets in parallel, one failure must NOT cancel the others.
fun refreshAll() {
viewModelScope.launch(CoroutineName("HomeRefreshAll")) {
supervisorScope {
launch(CoroutineName("RefreshNasaApod")) { nasaApodRepo.refresh() }
launch(CoroutineName("RefreshWeather")) { weatherRepo.refresh() }
launch(CoroutineName("RefreshRss")) { rssRepo.refresh() }
}
}
}
supervisorScope vs coroutineScope:
supervisorScope — child failures are isolated, siblings continue
coroutineScope — one child failure cancels ALL siblings (good for "all-or-nothing" operations)
Use supervisorScope for widget refresh. Use coroutineScope for transactions.
5. CoroutineName — Debugging Production Crashes
Name your coroutines so stack traces and crash reports are readable.
viewModelScope.launch(CoroutineName("NasaApodPeriodicRefresh")) {
while (true) {
}
}
viewModelScope.launch(Dispatchers.IO + CoroutineName("RssFeedParse")) {
rssParser.fetch(url)
}
6. flowOn — Move Heavy Work Off Main Thread
private fun periodicFetchFlow(): Flow<ApodState> = flow {
while (true) {
val data = api.fetchApod(key)
emit(ApodState.Success(data, Instant.now()))
delay(24.hours)
}
}.flowOn(Dispatchers.IO)
val state = periodicFetchFlow().stateIn(viewModelScope, WhileSubscribed(5.seconds), ApodState.Loading)
7. Periodic Refresh Pattern — Full Example
The canonical Nebula widget refresh loop:
class WeatherViewModel(private val repo: WeatherRepository) : ViewModel() {
val state: StateFlow<WeatherState> = flow {
var lastSuccess: WeatherState.Success? = null
while (true) {
val newState = try {
withTimeout(15.seconds) {
WeatherState.Success(repo.fetchCurrent(), Instant.now())
.also { lastSuccess = it }
}
} catch (e: TimeoutCancellationException) {
lastSuccess?.let { WeatherState.Stale(it.data, it.updatedAt, "Timed out") }
?: WeatherState.Error("Weather unavailable")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
lastSuccess?.let { WeatherState.Stale(it.data, it.updatedAt, e.message ?: "Error") }
?: WeatherState.Error(e.message ?: "Failed to load weather")
}
emit(newState)
delay(30.minutes)
}
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = WeatherState.Loading
)
}
8. What NOT to Do
GlobalScope.launch { api.fetch() }
runBlocking { api.fetch() }
try { } catch (e: Exception) { log(e) }
val data = api.fetch()
stateIn(viewModelScope, SharingStarted.Eagerly, Loading)
lifecycleScope.launch { }