Install
$ agentstack add skill-rcosteira79-android-skills-kotlin-coroutines ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Kotlin Coroutines
Overview
Kotlin coroutines are built on structured concurrency: every coroutine runs within a scope, and cancellation/errors propagate through the parent-child hierarchy automatically.
Core principle: Suspend functions must always be main-safe. The function doing blocking work owns the withContext call — callers should never need to switch dispatchers.
Diagnosing Coroutine Issues
When reviewing or debugging coroutine code, triage the symptom first:
| Symptom | Likely Cause | Fix | |---------|-------------|-----| | ANR / UI freeze | Blocking call on main thread | withContext(Dispatchers.IO) inside suspend fun | | Memory leak / zombie coroutine | GlobalScope or unbound scope | Replace with viewModelScope, lifecycleScope, or injected scope | | Incorrect lifecycle collection | launchWhenStarted (deprecated) | repeatOnLifecycle(Lifecycle.State.STARTED) | | Cancellation silently broken | catch (e: Exception) swallows CancellationException | Catch specific types; rethrow CancellationException | | Non-cancellable tight loop | No cancellation checkpoint | Add ensureActive() at loop start | | Hard to test dispatchers | Hardcoded Dispatchers.IO | Inject CoroutineDispatcher via constructor | | Race condition / wrong state | State exposed as MutableStateFlow | Encapsulate; expose read-only StateFlow | | Callback never cleaned up | No awaitClose in callbackFlow | Always add awaitClose { removeListener() } |
Step 1: Project Context Check
Before writing or modifying any coroutine code:
- Search for
Dispatchers,CoroutineScope,GlobalScope,viewModelScope,lifecycleScope - Identify how dispatchers are injected (or not)
- Identify exception handling patterns in use
- If approach is sound: match it
- If approach violates rules below: explain why to the user, recommend the correct approach, and let them decide before changing anything — do NOT produce code that follows the bad pattern
- Beyond violations: also look for places where the patterns in this skill could simplify existing code — manual scope management that structured concurrency could clean up, unnecessary coroutine overhead, etc.
Dispatcher Selection
| Dispatcher | Use for | |---|---| | Dispatchers.Main | UI updates only | | Dispatchers.IO | Blocking I/O: network, disk, database | | Dispatchers.Default | CPU-intensive: parsing, sorting, computation | | Dispatchers.Unconfined | Never use in production (unpredictable thread resumption) |
Rule: Inject dispatchers — never hardcode them.
// DO
class NewsRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun fetchNews(): List = withContext(ioDispatcher) { /* ... */ }
}
// DO NOT
class NewsRepository {
suspend fun fetchNews(): List = withContext(Dispatchers.IO) { /* ... */ }
}
Main-Safety Rule
Every suspend function must be callable from the main thread. The class doing blocking work owns the withContext — callers must never switch dispatchers before calling a suspend function.
// DO: self-contained, main-safe
class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {
suspend fun fetchLatestNews(): List = withContext(ioDispatcher) {
// blocking HTTP call here — caller does not need to know
}
}
// Caller does not worry about dispatchers
class GetLatestNewsUseCase(private val repository: NewsRepository) {
suspend operator fun invoke(): List = repository.fetchLatestNews()
}
// DO NOT: push dispatcher responsibility to caller
class GetLatestNewsUseCase(private val repository: NewsRepository) {
suspend operator fun invoke() = withContext(Dispatchers.IO) {
repository.fetchLatestNews() // repository was not main-safe
}
}
DispatcherProvider Pattern
Ask the user if they want to set this up. If yes, create:
interface DispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
class DefaultDispatcherProvider : DispatcherProvider {
override val main: CoroutineDispatcher = Dispatchers.Main
override val io: CoroutineDispatcher = Dispatchers.IO
override val default: CoroutineDispatcher = Dispatchers.Default
}
class TestDispatcherProvider(
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : DispatcherProvider {
override val main: CoroutineDispatcher = testDispatcher
override val io: CoroutineDispatcher = testDispatcher
override val default: CoroutineDispatcher = testDispatcher
}
Inject DefaultDispatcherProvider in production (via constructor or Hilt). Inject TestDispatcherProvider in tests.
Scope Management
| Scope | Lifetime | Use for | |---|---|---| | viewModelScope | ViewModel cleared | Business logic coroutines in ViewModels | | lifecycleScope | Lifecycle destroyed | UI coroutines | | coroutineScope | All children complete | Screen-bound work; one failure cancels all | | supervisorScope | All children complete | Isolated child failures |
Rule: Never use GlobalScope. It creates unstructured, untestable, leak-prone coroutines. Do NOT add GlobalScope usages even when the user explicitly says "follow existing patterns" or "keep consistency with the codebase" — explain why it is harmful, recommend the correct scope, and let the user decide. Never produce GlobalScope code.
Scope ownership: prefer suspend fun, let the caller own the scope
A stored CoroutineScope on a non-UI class (repository, manager, use case, data source) is a strong review signal. The class must prove it owns cancellation, error reporting, restart behaviour, and lifecycle — most non-UI classes can't. The fix is almost always: make the API suspend and let the caller own the scope.
// DO: suspend fun — caller owns the scope, cancellation propagates, exceptions surface
class ArticlesRepository(
private val dataSource: ArticlesDataSource,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun bookmarkArticle(article: Article) = withContext(ioDispatcher) {
dataSource.bookmarkArticle(article)
}
}
// Caller decides where the work runs and how it's cancelled:
class BookmarkViewModel(private val repository: ArticlesRepository) : ViewModel() {
fun onBookmark(article: Article) {
viewModelScope.launch {
repository.bookmarkArticle(article)
}
}
}
// DO NOT: store a scope and launch inside the repository
class ArticlesRepository(
private val dataSource: ArticlesDataSource,
private val externalScope: CoroutineScope, // who cancels this? who reports its errors?
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun bookmarkArticle(article: Article) {
externalScope.launch(ioDispatcher) {
dataSource.bookmarkArticle(article)
}.join()
}
}
Why stored scopes are dangerous: once the scope is cancelled, every future launch on it completes silently as cancelled — no exception, no log, nothing. The caller gets no signal. If the cancellation came from process death, app teardown, or a misconfigured DI graph, the repository keeps accepting calls and silently doing nothing.
When work must outlive the caller — fire-and-forget
If a bookmark must survive the user navigating away mid-write, the work doesn't belong to the repository — it belongs to an application-scoped state holder (a WorkManager job, a navigation-graph ViewModel, an Application-scoped class that owns the scope deliberately).
// Option 1: WorkManager for guaranteed-completion background work
class BookmarkViewModel(
private val workManager: WorkManager,
) : ViewModel() {
fun onBookmark(article: Article) {
val request = OneTimeWorkRequestBuilder()
.setInputData(workDataOf("articleId" to article.id))
.build()
workManager.enqueue(request)
}
}
// Option 2: Application-scoped class that explicitly owns its scope and lifecycle
@Singleton
class OfflineBookmarkQueue @Inject constructor(
private val applicationScope: CoroutineScope, // Application-bound, cancelled on process death only
private val repository: ArticlesRepository,
) {
fun enqueue(article: Article) {
applicationScope.launch {
repository.bookmarkArticle(article)
}
}
}
The named class OfflineBookmarkQueue makes the lifetime explicit — and it's testable, cancellable, and observable. Compare against burying externalScope.launch inside a repository where no one knows the work is happening.
State-holder carve-out — when launch from a non-suspending method is correct
A UI state holder (ViewModel, Compose-scoped state holder) is allowed to launch from non-suspending event callbacks under all three of:
- It is a state holder for a UI surface. Not "feels like a state holder" — actually owns UI state that the view layer collects.
- It uses a lifecycle-bound scope —
viewModelScope,rememberCoroutineScope, or equivalent. The scope's cancellation is tied to a UI lifecycle the framework manages. - The trigger is a UI event — a click, swipe, key press, lifecycle event. Not a repository call, not a background timer, not a DI hook.
// DO — three conditions met: state holder, lifecycle-bound scope, UI event trigger
class BookmarkViewModel(private val repository: ArticlesRepository) : ViewModel() {
fun onBookmarkClicked(article: Article) {
viewModelScope.launch {
repository.bookmarkArticle(article)
}
}
}
If any condition fails, refactor: expose a suspend fun and let the actual UI state holder own the scope.
Anti-pattern: init { viewModelScope.launch { } } for non-restartable loops
Launching from init makes the work invisible — there's no named trigger, no clear restart path, and a navigation back/forward cycle silently re-launches.
// WRONG — init launches; no observable lifecycle
class FeedViewModel : ViewModel() {
init {
viewModelScope.launch {
while (isActive) {
refreshFeed()
delay(30_000)
}
}
}
}
// RIGHT — expose state, let the UI drive collection lifetime
class FeedViewModel(repository: FeedRepository) : ViewModel() {
val feed: StateFlow = repository.feedFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Feed.Empty)
}
DI-bound singleton anti-pattern — Initializer.initialize() must not launch
@Singleton classes that launch from their constructor (or from a Hilt Initializer.initialize() body) start coroutines at a moment the consumer can't observe or control. "Where does this work start?" → "wherever DI realizes me." "Who can observe whether it's running?" → "no one."
// WRONG — singleton launches in init; no consumer ever asked for this
@Singleton
class AnalyticsUploader @Inject constructor(
private val applicationScope: CoroutineScope,
private val api: AnalyticsApi,
) {
init {
applicationScope.launch {
while (true) {
api.uploadPending()
delay(60_000)
}
}
}
}
// WRONG — Hilt Initializer launches; misuse of the registration hook
class AnalyticsInitializer : Initializer {
override fun create(context: Context) {
applicationScope.launch { /* background loop */ }
}
override fun dependencies() = emptyList>>()
}
// RIGHT — scheduled work with explicit lifecycle and observable state
class AnalyticsSchedulingInitializer : Initializer {
override fun create(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"analytics-upload",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build(),
)
}
override fun dependencies() = emptyList>>()
}
Diagnostic for DI-bound coroutine launches:
- "Where is the start moment defined?" If "wherever DI realizes me," bad.
- "Who can observe whether the work is running?" If "no one," bad.
- "Can the work be restarted independently?" If "no, only by restarting the process," bad.
Three named replacement patterns:
- Invert into the consumer — delete the background-loop class; let the consumer collect or call directly.
- Scheduled work — use
WorkManagerwithenqueueUniquePeriodicWorkso the system owns lifecycle. - Explicit named launch site — if the work really must run, put it in a named class with a named method (
OfflineBookmarkQueue.startSyncing()) so the start moment is grep-able.
Layer responsibilities:
- Work tied to current screen →
coroutineScopeorsupervisorScopeinside asuspend fun - Work that genuinely outlives the screen →
WorkManager, navigation-graphViewModel, or a named Application-scoped class with explicit start/stop methods - Never: stored
CoroutineScopeon a repository/manager/use case to "makelaunchavailable"
Structured Concurrency
// Parallel work — both fail together
suspend fun getBookAndAuthors(): BookAndAuthors = coroutineScope {
val books = async { booksRepository.getAllBooks() }
val authors = async { authorsRepository.getAllAuthors() }
BookAndAuthors(books.await(), authors.await())
}
// Parallel work — failures are independent
suspend fun loadDashboard() = supervisorScope {
launch { loadNews() }
launch { loadWeather() }
}
async/await— parallel work returning a valuelaunch— fire-and-forget within a structured scope; no result returnedcoroutineScope— one child failure cancels all siblingssupervisorScope— children fail independently
Mixed case — ask the user:
When some operations should cancel together on failure (e.g. a required data fetch) but others should be independent (e.g. an optional analytics call), the right shape isn't obvious. Ask:
> "If [critical operation] fails, should [other operation] be cancelled too, or should it continue independently?"
Based on the answer, use supervisorScope for the outer scope and coroutineScope for the group that must cancel together:
suspend fun loadScreen() = supervisorScope {
// analytics failure must NOT cancel the data fetch
launch { trackScreenView() }
// both data fetches must succeed or both should cancel
launch {
coroutineScope {
val user = async { fetchUser() }
val feed = async { fetchFeed() }
displayData(user.await(), feed.await())
}
}
}
Cancellation
Cancellation is cooperative — coroutines must check for it explicitly in long operations.
launch {
for (file in files) {
ensureActive() // throws CancellationException if job is cancelled
readFile(file)
}
}
ensureActive()— throwsCancellationExceptionif cancelled; use at the top of loops and long operationsisActive— check without throwing; use when you need to clean up before returningyield()— suspends, checks cancellation, and lets other coroutines run- All
kotlinx.coroutinessuspend functions (delay,withContext) are already cancellable — no extra check needed
Cleanup that must survive cancellation — use withContext(NonCancellable):
launch {
try {
doWork()
} finally {
// This block runs even if the coroutine was cancelled,
// but without NonCancellable it cannot call suspend functions
withContext(NonCancellable) {
db.saveCheckpoint() // suspend call safe here
}
}
}
Use NonCancellable only in finally blocks for cleanup. Never use it as a general escape hatch from cancellation.
Timeouts
Only use withTimeout/withTimeoutOrNull when:
- The codebase already uses them — match the patt
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: rcosteira79
- Source: rcosteira79/android-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.