diff --git a/agents/kotlin-reviewer.md b/agents/kotlin-reviewer.md new file mode 100644 index 000000000..84ac896bd --- /dev/null +++ b/agents/kotlin-reviewer.md @@ -0,0 +1,159 @@ +--- +name: kotlin-reviewer +description: Kotlin and Android/KMP code reviewer. Reviews Kotlin code for idiomatic patterns, coroutine safety, Compose best practices, clean architecture violations, and common Android pitfalls. +tools: ["Read", "Grep", "Glob", "Bash"] +model: sonnet +--- + +You are a senior Kotlin and Android/KMP code reviewer ensuring idiomatic, safe, and maintainable code. + +## Your Role + +- Review Kotlin code for idiomatic patterns and Android/KMP best practices +- Detect coroutine misuse, Flow anti-patterns, and lifecycle bugs +- Enforce clean architecture module boundaries +- Identify Compose performance issues and recomposition traps +- You DO NOT refactor or rewrite code — you report findings only + +## Workflow + +### Step 1: Gather Context + +Run `git diff --staged` and `git diff` to see changes. If no diff, check `git log --oneline -5`. Identify Kotlin/KTS files that changed. + +### Step 2: Understand Project Structure + +Check for: +- `build.gradle.kts` or `settings.gradle.kts` to understand module layout +- `CLAUDE.md` for project-specific conventions +- Whether this is Android-only, KMP, or Compose Multiplatform + +### Step 2b: Security Review + +Apply the Kotlin/Android security guidance before continuing: +- exported Android components, deep links, and intent filters +- insecure crypto, WebView, and network configuration usage +- keystore, token, and credential handling +- platform-specific storage and permission risks + +If you find a CRITICAL security issue, stop the review and hand off to `security-reviewer` before doing any further analysis. + +### Step 3: Read and Review + +Read changed files fully. Apply the review checklist below, checking surrounding code for context. + +### Step 4: Report Findings + +Use the output format below. Only report issues with >80% confidence. + +## Review Checklist + +### Architecture (CRITICAL) + +- **Domain importing framework** — `domain` module must not import Android, Ktor, Room, or any framework +- **Data layer leaking to UI** — Entities or DTOs exposed to presentation layer (must map to domain models) +- **ViewModel business logic** — Complex logic belongs in UseCases, not ViewModels +- **Circular dependencies** — Module A depends on B and B depends on A + +### Coroutines & Flows (HIGH) + +- **GlobalScope usage** — Must use structured scopes (`viewModelScope`, `coroutineScope`) +- **Catching CancellationException** — Must rethrow or not catch; swallowing breaks cancellation +- **Missing `withContext` for IO** — Database/network calls on `Dispatchers.Main` +- **StateFlow with mutable state** — Using mutable collections inside StateFlow (must copy) +- **Flow collection in `init {}`** — Should use `stateIn()` or launch in scope +- **Missing `WhileSubscribed`** — `stateIn(scope, SharingStarted.Eagerly)` when `WhileSubscribed` is appropriate + +```kotlin +// BAD — swallows cancellation +try { fetchData() } catch (e: Exception) { log(e) } + +// GOOD — preserves cancellation +try { fetchData() } catch (e: CancellationException) { throw e } catch (e: Exception) { log(e) } +// or use runCatching and check +``` + +### Compose (HIGH) + +- **Unstable parameters** — Composables receiving mutable types cause unnecessary recomposition +- **Side effects outside LaunchedEffect** — Network/DB calls must be in `LaunchedEffect` or ViewModel +- **NavController passed deep** — Pass lambdas instead of `NavController` references +- **Missing `key()` in LazyColumn** — Items without stable keys cause poor performance +- **`remember` with missing keys** — Computation not recalculated when dependencies change +- **Object allocation in parameters** — Creating objects inline causes recomposition + +```kotlin +// BAD — new lambda every recomposition +Button(onClick = { viewModel.doThing(item.id) }) + +// GOOD — stable reference +val onClick = remember(item.id) { { viewModel.doThing(item.id) } } +Button(onClick = onClick) +``` + +### Kotlin Idioms (MEDIUM) + +- **`!!` usage** — Non-null assertion; prefer `?.`, `?:`, `requireNotNull`, or `checkNotNull` +- **`var` where `val` works** — Prefer immutability +- **Java-style patterns** — Static utility classes (use top-level functions), getters/setters (use properties) +- **String concatenation** — Use string templates `"Hello $name"` instead of `"Hello " + name` +- **`when` without exhaustive branches** — Sealed classes/interfaces should use exhaustive `when` +- **Mutable collections exposed** — Return `List` not `MutableList` from public APIs + +### Android Specific (MEDIUM) + +- **Context leaks** — Storing `Activity` or `Fragment` references in singletons/ViewModels +- **Missing ProGuard rules** — Serialized classes without `@Keep` or ProGuard rules +- **Hardcoded strings** — User-facing strings not in `strings.xml` or Compose resources +- **Missing lifecycle handling** — Collecting Flows in Activities without `repeatOnLifecycle` + +### Security (CRITICAL) + +- **Exported component exposure** — Activities, services, or receivers exported without proper guards +- **Insecure crypto/storage** — Homegrown crypto, plaintext secrets, or weak keystore usage +- **Unsafe WebView/network config** — JavaScript bridges, cleartext traffic, permissive trust settings +- **Sensitive logging** — Tokens, credentials, PII, or secrets emitted to logs + +If any CRITICAL security issue is present, stop and escalate to `security-reviewer`. + +### Gradle & Build (LOW) + +- **Version catalog not used** — Hardcoded versions instead of `libs.versions.toml` +- **Unnecessary dependencies** — Dependencies added but not used +- **Missing KMP source sets** — Declaring `androidMain` code that could be `commonMain` + +## Output Format + +``` +[CRITICAL] Domain module imports Android framework +File: domain/src/main/kotlin/com/app/domain/UserUseCase.kt:3 +Issue: `import android.content.Context` — domain must be pure Kotlin with no framework dependencies. +Fix: Move Context-dependent logic to data or platforms layer. Pass data via repository interface. + +[HIGH] StateFlow holding mutable list +File: presentation/src/main/kotlin/com/app/ui/ListViewModel.kt:25 +Issue: `_state.value.items.add(newItem)` mutates the list inside StateFlow — Compose won't detect the change. +Fix: Use `_state.update { it.copy(items = it.items + newItem) }` +``` + +## Summary Format + +End every review with: + +``` +## Review Summary + +| Severity | Count | Status | +|----------|-------|--------| +| CRITICAL | 0 | pass | +| HIGH | 1 | block | +| MEDIUM | 2 | info | +| LOW | 0 | note | + +Verdict: BLOCK — HIGH issues must be fixed before merge. +``` + +## Approval Criteria + +- **Approve**: No CRITICAL or HIGH issues +- **Block**: Any CRITICAL or HIGH issues — must fix before merge diff --git a/commands/gradle-build.md b/commands/gradle-build.md new file mode 100644 index 000000000..541ca1b68 --- /dev/null +++ b/commands/gradle-build.md @@ -0,0 +1,70 @@ +--- +description: Fix Gradle build errors for Android and KMP projects +--- + +# Gradle Build Fix + +Incrementally fix Gradle build and compilation errors for Android and Kotlin Multiplatform projects. + +## Step 1: Detect Build Configuration + +Identify the project type and run the appropriate build: + +| Indicator | Build Command | +|-----------|---------------| +| `build.gradle.kts` + `composeApp/` (KMP) | `./gradlew composeApp:compileKotlinMetadata 2>&1` | +| `build.gradle.kts` + `app/` (Android) | `./gradlew app:compileDebugKotlin 2>&1` | +| `settings.gradle.kts` with modules | `./gradlew assemble 2>&1` | +| Detekt configured | `./gradlew detekt 2>&1` | + +Also check `gradle.properties` and `local.properties` for configuration. + +## Step 2: Parse and Group Errors + +1. Run the build command and capture output +2. Separate Kotlin compilation errors from Gradle configuration errors +3. Group by module and file path +4. Sort: configuration errors first, then compilation errors by dependency order + +## Step 3: Fix Loop + +For each error: + +1. **Read the file** — Full context around the error line +2. **Diagnose** — Common categories: + - Missing import or unresolved reference + - Type mismatch or incompatible types + - Missing dependency in `build.gradle.kts` + - Expect/actual mismatch (KMP) + - Compose compiler error +3. **Fix minimally** — Smallest change that resolves the error +4. **Re-run build** — Verify fix and check for new errors +5. **Continue** — Move to next error + +## Step 4: Guardrails + +Stop and ask the user if: +- Fix introduces more errors than it resolves +- Same error persists after 3 attempts +- Error requires adding new dependencies or changing module structure +- Gradle sync itself fails (configuration-phase error) +- Error is in generated code (Room, SQLDelight, KSP) + +## Step 5: Summary + +Report: +- Errors fixed (module, file, description) +- Errors remaining +- New errors introduced (should be zero) +- Suggested next steps + +## Common Gradle/KMP Fixes + +| Error | Fix | +|-------|-----| +| Unresolved reference in `commonMain` | Check if the dependency is in `commonMain.dependencies {}` | +| Expect declaration without actual | Add `actual` implementation in each platform source set | +| Compose compiler version mismatch | Align Kotlin and Compose compiler versions in `libs.versions.toml` | +| Duplicate class | Check for conflicting dependencies with `./gradlew dependencies` | +| KSP error | Run `./gradlew kspCommonMainKotlinMetadata` to regenerate | +| Configuration cache issue | Check for non-serializable task inputs | diff --git a/rules/kotlin/coding-style.md b/rules/kotlin/coding-style.md new file mode 100644 index 000000000..5c5ee30cd --- /dev/null +++ b/rules/kotlin/coding-style.md @@ -0,0 +1,86 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Kotlin-specific content. + +## Formatting + +- **ktlint** or **Detekt** for style enforcement +- Official Kotlin code style (`kotlin.code.style=official` in `gradle.properties`) + +## Immutability + +- Prefer `val` over `var` — default to `val` and only use `var` when mutation is required +- Use `data class` for value types; use immutable collections (`List`, `Map`, `Set`) in public APIs +- Copy-on-write for state updates: `state.copy(field = newValue)` + +## Naming + +Follow Kotlin conventions: +- `camelCase` for functions and properties +- `PascalCase` for classes, interfaces, objects, and type aliases +- `SCREAMING_SNAKE_CASE` for constants (`const val` or `@JvmStatic`) +- Prefix interfaces with behavior, not `I`: `Clickable` not `IClickable` + +## Null Safety + +- Never use `!!` — prefer `?.`, `?:`, `requireNotNull()`, or `checkNotNull()` +- Use `?.let {}` for scoped null-safe operations +- Return nullable types from functions that can legitimately have no result + +```kotlin +// BAD +val name = user!!.name + +// GOOD +val name = user?.name ?: "Unknown" +val name = requireNotNull(user) { "User must be set before accessing name" }.name +``` + +## Sealed Types + +Use sealed classes/interfaces to model closed state hierarchies: + +```kotlin +sealed interface UiState { + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error(val message: String) : UiState +} +``` + +Always use exhaustive `when` with sealed types — no `else` branch. + +## Extension Functions + +Use extension functions for utility operations, but keep them discoverable: +- Place in a file named after the receiver type (`StringExt.kt`, `FlowExt.kt`) +- Keep scope limited — don't add extensions to `Any` or overly generic types + +## Scope Functions + +Use the right scope function: +- `let` — null check + transform: `user?.let { greet(it) }` +- `run` — compute a result using receiver: `service.run { fetch(config) }` +- `apply` — configure an object: `builder.apply { timeout = 30 }` +- `also` — side effects: `result.also { log(it) }` +- Avoid deep nesting of scope functions (max 2 levels) + +## Error Handling + +- Use `Result` or custom sealed types +- Use `runCatching {}` for wrapping throwable code +- Never catch `CancellationException` — always rethrow it +- Avoid `try-catch` for control flow + +```kotlin +// BAD — using exceptions for control flow +val user = try { repository.getUser(id) } catch (e: NotFoundException) { null } + +// GOOD — nullable return +val user: User? = repository.findUser(id) +``` diff --git a/rules/kotlin/patterns.md b/rules/kotlin/patterns.md new file mode 100644 index 000000000..1a09e6b7d --- /dev/null +++ b/rules/kotlin/patterns.md @@ -0,0 +1,146 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Kotlin and Android/KMP-specific content. + +## Dependency Injection + +Prefer constructor injection. Use Koin (KMP) or Hilt (Android-only): + +```kotlin +// Koin — declare modules +val dataModule = module { + single { ItemRepositoryImpl(get(), get()) } + factory { GetItemsUseCase(get()) } + viewModelOf(::ItemListViewModel) +} + +// Hilt — annotations +@HiltViewModel +class ItemListViewModel @Inject constructor( + private val getItems: GetItemsUseCase +) : ViewModel() +``` + +## ViewModel Pattern + +Single state object, event sink, one-way data flow: + +```kotlin +data class ScreenState( + val items: List = emptyList(), + val isLoading: Boolean = false +) + +class ScreenViewModel(private val useCase: GetItemsUseCase) : ViewModel() { + private val _state = MutableStateFlow(ScreenState()) + val state = _state.asStateFlow() + + fun onEvent(event: ScreenEvent) { + when (event) { + is ScreenEvent.Load -> load() + is ScreenEvent.Delete -> delete(event.id) + } + } +} +``` + +## Repository Pattern + +- `suspend` functions return `Result` or custom error type +- `Flow` for reactive streams +- Coordinate local + remote data sources + +```kotlin +interface ItemRepository { + suspend fun getById(id: String): Result + suspend fun getAll(): Result> + fun observeAll(): Flow> +} +``` + +## UseCase Pattern + +Single responsibility, `operator fun invoke`: + +```kotlin +class GetItemUseCase(private val repository: ItemRepository) { + suspend operator fun invoke(id: String): Result { + return repository.getById(id) + } +} + +class GetItemsUseCase(private val repository: ItemRepository) { + suspend operator fun invoke(): Result> { + return repository.getAll() + } +} +``` + +## expect/actual (KMP) + +Use for platform-specific implementations: + +```kotlin +// commonMain +expect fun platformName(): String +expect class SecureStorage { + fun save(key: String, value: String) + fun get(key: String): String? +} + +// androidMain +actual fun platformName(): String = "Android" +actual class SecureStorage { + actual fun save(key: String, value: String) { /* EncryptedSharedPreferences */ } + actual fun get(key: String): String? = null /* ... */ +} + +// iosMain +actual fun platformName(): String = "iOS" +actual class SecureStorage { + actual fun save(key: String, value: String) { /* Keychain */ } + actual fun get(key: String): String? = null /* ... */ +} +``` + +## Coroutine Patterns + +- Use `viewModelScope` in ViewModels, `coroutineScope` for structured child work +- Use `stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue)` for StateFlow from cold Flows +- Use `supervisorScope` when child failures should be independent + +## Builder Pattern with DSL + +```kotlin +class HttpClientConfig { + var baseUrl: String = "" + var timeout: Long = 30_000 + private val interceptors = mutableListOf() + + fun interceptor(block: () -> Interceptor) { + interceptors.add(block()) + } +} + +fun httpClient(block: HttpClientConfig.() -> Unit): HttpClient { + val config = HttpClientConfig().apply(block) + return HttpClient(config) +} + +// Usage +val client = httpClient { + baseUrl = "https://api.example.com" + timeout = 15_000 + interceptor { AuthInterceptor(tokenProvider) } +} +``` + +## References + +See skill: `kotlin-coroutines-flows` for detailed coroutine patterns. +See skill: `android-clean-architecture` for module and layer patterns. diff --git a/rules/kotlin/security.md b/rules/kotlin/security.md new file mode 100644 index 000000000..a212211d2 --- /dev/null +++ b/rules/kotlin/security.md @@ -0,0 +1,82 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Security + +> This file extends [common/security.md](../common/security.md) with Kotlin and Android/KMP-specific content. + +## Secrets Management + +- Never hardcode API keys, tokens, or credentials in source code +- Use `local.properties` (git-ignored) for local development secrets +- Use `BuildConfig` fields generated from CI secrets for release builds +- Use `EncryptedSharedPreferences` (Android) or Keychain (iOS) for runtime secret storage + +```kotlin +// BAD +val apiKey = "sk-abc123..." + +// GOOD — from BuildConfig (generated at build time) +val apiKey = BuildConfig.API_KEY + +// GOOD — from secure storage at runtime +val token = secureStorage.get("auth_token") +``` + +## Network Security + +- Use HTTPS exclusively — configure `network_security_config.xml` to block cleartext +- Pin certificates for sensitive endpoints using OkHttp `CertificatePinner` or Ktor equivalent +- Set timeouts on all HTTP clients — never leave defaults (which may be infinite) +- Validate and sanitize all server responses before use + +```xml + + + + +``` + +## Input Validation + +- Validate all user input before processing or sending to API +- Use parameterized queries for Room/SQLDelight — never concatenate user input into SQL +- Sanitize file paths from user input to prevent path traversal + +```kotlin +// BAD — SQL injection +@Query("SELECT * FROM items WHERE name = '$input'") + +// GOOD — parameterized +@Query("SELECT * FROM items WHERE name = :input") +fun findByName(input: String): List +``` + +## Data Protection + +- Use `EncryptedSharedPreferences` for sensitive key-value data on Android +- Use `@Serializable` with explicit field names — don't leak internal property names +- Clear sensitive data from memory when no longer needed +- Use `@Keep` or ProGuard rules for serialized classes to prevent name mangling + +## Authentication + +- Store tokens in secure storage, not in plain SharedPreferences +- Implement token refresh with proper 401/403 handling +- Clear all auth state on logout (tokens, cached user data, cookies) +- Use biometric authentication (`BiometricPrompt`) for sensitive operations + +## ProGuard / R8 + +- Keep rules for all serialized models (`@Serializable`, Gson, Moshi) +- Keep rules for reflection-based libraries (Koin, Retrofit) +- Test release builds — obfuscation can break serialization silently + +## WebView Security + +- Disable JavaScript unless explicitly needed: `settings.javaScriptEnabled = false` +- Validate URLs before loading in WebView +- Never expose `@JavascriptInterface` methods that access sensitive data +- Use `WebViewClient.shouldOverrideUrlLoading()` to control navigation diff --git a/rules/kotlin/testing.md b/rules/kotlin/testing.md new file mode 100644 index 000000000..cdf973345 --- /dev/null +++ b/rules/kotlin/testing.md @@ -0,0 +1,128 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Testing + +> This file extends [common/testing.md](../common/testing.md) with Kotlin and Android/KMP-specific content. + +## Test Framework + +- **kotlin.test** for multiplatform (KMP) — `@Test`, `assertEquals`, `assertTrue` +- **JUnit 4/5** for Android-specific tests +- **Turbine** for testing Flows and StateFlow +- **kotlinx-coroutines-test** for coroutine testing (`runTest`, `TestDispatcher`) + +## ViewModel Testing with Turbine + +```kotlin +@Test +fun `loading state emitted then data`() = runTest { + val repo = FakeItemRepository() + repo.addItem(testItem) + val viewModel = ItemListViewModel(GetItemsUseCase(repo)) + + viewModel.state.test { + assertEquals(ItemListState(), awaitItem()) // initial state + viewModel.onEvent(ItemListEvent.Load) + assertTrue(awaitItem().isLoading) // loading + assertEquals(listOf(testItem), awaitItem().items) // loaded + } +} +``` + +## Fakes Over Mocks + +Prefer hand-written fakes over mocking frameworks: + +```kotlin +class FakeItemRepository : ItemRepository { + private val items = mutableListOf() + var fetchError: Throwable? = null + + override suspend fun getAll(): Result> { + fetchError?.let { return Result.failure(it) } + return Result.success(items.toList()) + } + + override fun observeAll(): Flow> = flowOf(items.toList()) + + fun addItem(item: Item) { items.add(item) } +} +``` + +## Coroutine Testing + +```kotlin +@Test +fun `parallel operations complete`() = runTest { + val repo = FakeRepository() + val result = loadDashboard(repo) + advanceUntilIdle() + assertNotNull(result.items) + assertNotNull(result.stats) +} +``` + +Use `runTest` — it auto-advances virtual time and provides `TestScope`. + +## Ktor MockEngine + +```kotlin +val mockEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/api/items" -> respond( + content = Json.encodeToString(testItems), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + else -> respondError(HttpStatusCode.NotFound) + } +} + +val client = HttpClient(mockEngine) { + install(ContentNegotiation) { json() } +} +``` + +## Room/SQLDelight Testing + +- Room: Use `Room.inMemoryDatabaseBuilder()` for in-memory testing +- SQLDelight: Use `JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)` for JVM tests + +```kotlin +@Test +fun `insert and query items`() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + Database.Schema.create(driver) + val db = Database(driver) + + db.itemQueries.insert("1", "Sample Item", "description") + val items = db.itemQueries.getAll().executeAsList() + assertEquals(1, items.size) +} +``` + +## Test Naming + +Use backtick-quoted descriptive names: + +```kotlin +@Test +fun `search with empty query returns all items`() = runTest { } + +@Test +fun `delete item emits updated list without deleted item`() = runTest { } +``` + +## Test Organization + +``` +src/ +├── commonTest/kotlin/ # Shared tests (ViewModel, UseCase, Repository) +├── androidUnitTest/kotlin/ # Android unit tests (JUnit) +├── androidInstrumentedTest/kotlin/ # Instrumented tests (Room, UI) +└── iosTest/kotlin/ # iOS-specific tests +``` + +Minimum test coverage: ViewModel + UseCase for every feature. diff --git a/skills/android-clean-architecture/SKILL.md b/skills/android-clean-architecture/SKILL.md new file mode 100644 index 000000000..1b4963f5d --- /dev/null +++ b/skills/android-clean-architecture/SKILL.md @@ -0,0 +1,339 @@ +--- +name: android-clean-architecture +description: Clean Architecture patterns for Android and Kotlin Multiplatform projects — module structure, dependency rules, UseCases, Repositories, and data layer patterns. +origin: ECC +--- + +# Android Clean Architecture + +Clean Architecture patterns for Android and KMP projects. Covers module boundaries, dependency inversion, UseCase/Repository patterns, and data layer design with Room, SQLDelight, and Ktor. + +## When to Activate + +- Structuring Android or KMP project modules +- Implementing UseCases, Repositories, or DataSources +- Designing data flow between layers (domain, data, presentation) +- Setting up dependency injection with Koin or Hilt +- Working with Room, SQLDelight, or Ktor in a layered architecture + +## Module Structure + +### Recommended Layout + +``` +project/ +├── app/ # Android entry point, DI wiring, Application class +├── core/ # Shared utilities, base classes, error types +├── domain/ # UseCases, domain models, repository interfaces (pure Kotlin) +├── data/ # Repository implementations, DataSources, DB, network +├── presentation/ # Screens, ViewModels, UI models, navigation +├── design-system/ # Reusable Compose components, theme, typography +└── feature/ # Feature modules (optional, for larger projects) + ├── auth/ + ├── settings/ + └── profile/ +``` + +### Dependency Rules + +``` +app → presentation, domain, data, core +presentation → domain, design-system, core +data → domain, core +domain → core (or no dependencies) +core → (nothing) +``` + +**Critical**: `domain` must NEVER depend on `data`, `presentation`, or any framework. It contains pure Kotlin only. + +## Domain Layer + +### UseCase Pattern + +Each UseCase represents one business operation. Use `operator fun invoke` for clean call sites: + +```kotlin +class GetItemsByCategoryUseCase( + private val repository: ItemRepository +) { + suspend operator fun invoke(category: String): Result> { + return repository.getItemsByCategory(category) + } +} + +// Flow-based UseCase for reactive streams +class ObserveUserProgressUseCase( + private val repository: UserRepository +) { + operator fun invoke(userId: String): Flow { + return repository.observeProgress(userId) + } +} +``` + +### Domain Models + +Domain models are plain Kotlin data classes — no framework annotations: + +```kotlin +data class Item( + val id: String, + val title: String, + val description: String, + val tags: List, + val status: Status, + val category: String +) + +enum class Status { DRAFT, ACTIVE, ARCHIVED } +``` + +### Repository Interfaces + +Defined in domain, implemented in data: + +```kotlin +interface ItemRepository { + suspend fun getItemsByCategory(category: String): Result> + suspend fun saveItem(item: Item): Result + fun observeItems(): Flow> +} +``` + +## Data Layer + +### Repository Implementation + +Coordinates between local and remote data sources: + +```kotlin +class ItemRepositoryImpl( + private val localDataSource: ItemLocalDataSource, + private val remoteDataSource: ItemRemoteDataSource +) : ItemRepository { + + override suspend fun getItemsByCategory(category: String): Result> { + return runCatching { + val remote = remoteDataSource.fetchItems(category) + localDataSource.insertItems(remote.map { it.toEntity() }) + localDataSource.getItemsByCategory(category).map { it.toDomain() } + } + } + + override suspend fun saveItem(item: Item): Result { + return runCatching { + localDataSource.insertItems(listOf(item.toEntity())) + } + } + + override fun observeItems(): Flow> { + return localDataSource.observeAll().map { entities -> + entities.map { it.toDomain() } + } + } +} +``` + +### Mapper Pattern + +Keep mappers as extension functions near the data models: + +```kotlin +// In data layer +fun ItemEntity.toDomain() = Item( + id = id, + title = title, + description = description, + tags = tags.split("|"), + status = Status.valueOf(status), + category = category +) + +fun ItemDto.toEntity() = ItemEntity( + id = id, + title = title, + description = description, + tags = tags.joinToString("|"), + status = status, + category = category +) +``` + +### Room Database (Android) + +```kotlin +@Entity(tableName = "items") +data class ItemEntity( + @PrimaryKey val id: String, + val title: String, + val description: String, + val tags: String, + val status: String, + val category: String +) + +@Dao +interface ItemDao { + @Query("SELECT * FROM items WHERE category = :category") + suspend fun getByCategory(category: String): List + + @Upsert + suspend fun upsert(items: List) + + @Query("SELECT * FROM items") + fun observeAll(): Flow> +} +``` + +### SQLDelight (KMP) + +```sql +-- Item.sq +CREATE TABLE ItemEntity ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + tags TEXT NOT NULL, + status TEXT NOT NULL, + category TEXT NOT NULL +); + +getByCategory: +SELECT * FROM ItemEntity WHERE category = ?; + +upsert: +INSERT OR REPLACE INTO ItemEntity (id, title, description, tags, status, category) +VALUES (?, ?, ?, ?, ?, ?); + +observeAll: +SELECT * FROM ItemEntity; +``` + +### Ktor Network Client (KMP) + +```kotlin +class ItemRemoteDataSource(private val client: HttpClient) { + + suspend fun fetchItems(category: String): List { + return client.get("api/items") { + parameter("category", category) + }.body() + } +} + +// HttpClient setup with content negotiation +val httpClient = HttpClient { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + install(Logging) { level = LogLevel.HEADERS } + defaultRequest { url("https://api.example.com/") } +} +``` + +## Dependency Injection + +### Koin (KMP-friendly) + +```kotlin +// Domain module +val domainModule = module { + factory { GetItemsByCategoryUseCase(get()) } + factory { ObserveUserProgressUseCase(get()) } +} + +// Data module +val dataModule = module { + single { ItemRepositoryImpl(get(), get()) } + single { ItemLocalDataSource(get()) } + single { ItemRemoteDataSource(get()) } +} + +// Presentation module +val presentationModule = module { + viewModelOf(::ItemListViewModel) + viewModelOf(::DashboardViewModel) +} +``` + +### Hilt (Android-only) + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + @Binds + abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository +} + +@HiltViewModel +class ItemListViewModel @Inject constructor( + private val getItems: GetItemsByCategoryUseCase +) : ViewModel() +``` + +## Error Handling + +### Result/Try Pattern + +Use `Result` or a custom sealed type for error propagation: + +```kotlin +sealed interface Try { + data class Success(val value: T) : Try + data class Failure(val error: AppError) : Try +} + +sealed interface AppError { + data class Network(val message: String) : AppError + data class Database(val message: String) : AppError + data object Unauthorized : AppError +} + +// In ViewModel — map to UI state +viewModelScope.launch { + when (val result = getItems(category)) { + is Try.Success -> _state.update { it.copy(items = result.value, isLoading = false) } + is Try.Failure -> _state.update { it.copy(error = result.error.toMessage(), isLoading = false) } + } +} +``` + +## Convention Plugins (Gradle) + +For KMP projects, use convention plugins to reduce build file duplication: + +```kotlin +// build-logic/src/main/kotlin/kmp-library.gradle.kts +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +kotlin { + androidTarget() + iosX64(); iosArm64(); iosSimulatorArm64() + sourceSets { + commonMain.dependencies { /* shared deps */ } + commonTest.dependencies { implementation(kotlin("test")) } + } +} +``` + +Apply in modules: + +```kotlin +// domain/build.gradle.kts +plugins { id("kmp-library") } +``` + +## Anti-Patterns to Avoid + +- Importing Android framework classes in `domain` — keep it pure Kotlin +- Exposing database entities or DTOs to the UI layer — always map to domain models +- Putting business logic in ViewModels — extract to UseCases +- Using `GlobalScope` or unstructured coroutines — use `viewModelScope` or structured concurrency +- Fat repository implementations — split into focused DataSources +- Circular module dependencies — if A depends on B, B must not depend on A + +## References + +See skill: `compose-multiplatform-patterns` for UI patterns. +See skill: `kotlin-coroutines-flows` for async patterns. diff --git a/skills/compose-multiplatform-patterns/SKILL.md b/skills/compose-multiplatform-patterns/SKILL.md new file mode 100644 index 000000000..f4caec1e0 --- /dev/null +++ b/skills/compose-multiplatform-patterns/SKILL.md @@ -0,0 +1,299 @@ +--- +name: compose-multiplatform-patterns +description: Compose Multiplatform and Jetpack Compose patterns for KMP projects — state management, navigation, theming, performance, and platform-specific UI. +origin: ECC +--- + +# Compose Multiplatform Patterns + +Patterns for building shared UI across Android, iOS, Desktop, and Web using Compose Multiplatform and Jetpack Compose. Covers state management, navigation, theming, and performance. + +## When to Activate + +- Building Compose UI (Jetpack Compose or Compose Multiplatform) +- Managing UI state with ViewModels and Compose state +- Implementing navigation in KMP or Android projects +- Designing reusable composables and design systems +- Optimizing recomposition and rendering performance + +## State Management + +### ViewModel + Single State Object + +Use a single data class for screen state. Expose it as `StateFlow` and collect in Compose: + +```kotlin +data class ItemListState( + val items: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val searchQuery: String = "" +) + +class ItemListViewModel( + private val getItems: GetItemsUseCase +) : ViewModel() { + private val _state = MutableStateFlow(ItemListState()) + val state: StateFlow = _state.asStateFlow() + + fun onSearch(query: String) { + _state.update { it.copy(searchQuery = query) } + loadItems(query) + } + + private fun loadItems(query: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + getItems(query).fold( + onSuccess = { items -> _state.update { it.copy(items = items, isLoading = false) } }, + onFailure = { e -> _state.update { it.copy(error = e.message, isLoading = false) } } + ) + } + } +} +``` + +### Collecting State in Compose + +```kotlin +@Composable +fun ItemListScreen(viewModel: ItemListViewModel = koinViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + + ItemListContent( + state = state, + onSearch = viewModel::onSearch + ) +} + +@Composable +private fun ItemListContent( + state: ItemListState, + onSearch: (String) -> Unit +) { + // Stateless composable — easy to preview and test +} +``` + +### Event Sink Pattern + +For complex screens, use a sealed interface for events instead of multiple callback lambdas: + +```kotlin +sealed interface ItemListEvent { + data class Search(val query: String) : ItemListEvent + data class Delete(val itemId: String) : ItemListEvent + data object Refresh : ItemListEvent +} + +// In ViewModel +fun onEvent(event: ItemListEvent) { + when (event) { + is ItemListEvent.Search -> onSearch(event.query) + is ItemListEvent.Delete -> deleteItem(event.itemId) + is ItemListEvent.Refresh -> loadItems(_state.value.searchQuery) + } +} + +// In Composable — single lambda instead of many +ItemListContent( + state = state, + onEvent = viewModel::onEvent +) +``` + +## Navigation + +### Type-Safe Navigation (Compose Navigation 2.8+) + +Define routes as `@Serializable` objects: + +```kotlin +@Serializable data object HomeRoute +@Serializable data class DetailRoute(val id: String) +@Serializable data object SettingsRoute + +@Composable +fun AppNavHost(navController: NavHostController = rememberNavController()) { + NavHost(navController, startDestination = HomeRoute) { + composable { + HomeScreen(onNavigateToDetail = { id -> navController.navigate(DetailRoute(id)) }) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + DetailScreen(id = route.id) + } + composable { SettingsScreen() } + } +} +``` + +### Dialog and Bottom Sheet Navigation + +Use `dialog()` and overlay patterns instead of imperative show/hide: + +```kotlin +NavHost(navController, startDestination = HomeRoute) { + composable { /* ... */ } + dialog { backStackEntry -> + val route = backStackEntry.toRoute() + ConfirmDeleteDialog( + itemId = route.itemId, + onConfirm = { navController.popBackStack() }, + onDismiss = { navController.popBackStack() } + ) + } +} +``` + +## Composable Design + +### Slot-Based APIs + +Design composables with slot parameters for flexibility: + +```kotlin +@Composable +fun AppCard( + modifier: Modifier = Modifier, + header: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, + actions: @Composable RowScope.() -> Unit = {} +) { + Card(modifier = modifier) { + Column { + header() + Column(content = content) + Row(horizontalArrangement = Arrangement.End, content = actions) + } + } +} +``` + +### Modifier Ordering + +Modifier order matters — apply in this sequence: + +```kotlin +Text( + text = "Hello", + modifier = Modifier + .padding(16.dp) // 1. Layout (padding, size) + .clip(RoundedCornerShape(8.dp)) // 2. Shape + .background(Color.White) // 3. Drawing (background, border) + .clickable { } // 4. Interaction +) +``` + +## KMP Platform-Specific UI + +### expect/actual for Platform Composables + +```kotlin +// commonMain +@Composable +expect fun PlatformStatusBar(darkIcons: Boolean) + +// androidMain +@Composable +actual fun PlatformStatusBar(darkIcons: Boolean) { + val systemUiController = rememberSystemUiController() + SideEffect { systemUiController.setStatusBarColor(Color.Transparent, darkIcons) } +} + +// iosMain +@Composable +actual fun PlatformStatusBar(darkIcons: Boolean) { + // iOS handles this via UIKit interop or Info.plist +} +``` + +## Performance + +### Stable Types for Skippable Recomposition + +Mark classes as `@Stable` or `@Immutable` when all properties are stable: + +```kotlin +@Immutable +data class ItemUiModel( + val id: String, + val title: String, + val description: String, + val progress: Float +) +``` + +### Use `key()` and Lazy Lists Correctly + +```kotlin +LazyColumn { + items( + items = items, + key = { it.id } // Stable keys enable item reuse and animations + ) { item -> + ItemRow(item = item) + } +} +``` + +### Defer Reads with `derivedStateOf` + +```kotlin +val listState = rememberLazyListState() +val showScrollToTop by remember { + derivedStateOf { listState.firstVisibleItemIndex > 5 } +} +``` + +### Avoid Allocations in Recomposition + +```kotlin +// BAD — new lambda and list every recomposition +items.filter { it.isActive }.forEach { ActiveItem(it, onClick = { handle(it) }) } + +// GOOD — key each item so callbacks stay attached to the right row +val activeItems = remember(items) { items.filter { it.isActive } } +activeItems.forEach { item -> + key(item.id) { + ActiveItem(item, onClick = { handle(item) }) + } +} +``` + +## Theming + +### Material 3 Dynamic Theming + +```kotlin +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(LocalContext.current) + else dynamicLightColorScheme(LocalContext.current) + } + darkTheme -> darkColorScheme() + else -> lightColorScheme() + } + + MaterialTheme(colorScheme = colorScheme, content = content) +} +``` + +## Anti-Patterns to Avoid + +- Using `mutableStateOf` in ViewModels when `MutableStateFlow` with `collectAsStateWithLifecycle` is safer for lifecycle +- Passing `NavController` deep into composables — pass lambda callbacks instead +- Heavy computation inside `@Composable` functions — move to ViewModel or `remember {}` +- Using `LaunchedEffect(Unit)` as a substitute for ViewModel init — it re-runs on configuration change in some setups +- Creating new object instances in composable parameters — causes unnecessary recomposition + +## References + +See skill: `android-clean-architecture` for module structure and layering. +See skill: `kotlin-coroutines-flows` for coroutine and Flow patterns. diff --git a/skills/kotlin-coroutines-flows/SKILL.md b/skills/kotlin-coroutines-flows/SKILL.md new file mode 100644 index 000000000..4108aaccf --- /dev/null +++ b/skills/kotlin-coroutines-flows/SKILL.md @@ -0,0 +1,284 @@ +--- +name: kotlin-coroutines-flows +description: Kotlin Coroutines and Flow patterns for Android and KMP — structured concurrency, Flow operators, StateFlow, error handling, and testing. +origin: ECC +--- + +# Kotlin Coroutines & Flows + +Patterns for structured concurrency, Flow-based reactive streams, and coroutine testing in Android and Kotlin Multiplatform projects. + +## When to Activate + +- Writing async code with Kotlin coroutines +- Using Flow, StateFlow, or SharedFlow for reactive data +- Handling concurrent operations (parallel loading, debounce, retry) +- Testing coroutines and Flows +- Managing coroutine scopes and cancellation + +## Structured Concurrency + +### Scope Hierarchy + +``` +Application + └── viewModelScope (ViewModel) + └── coroutineScope { } (structured child) + ├── async { } (concurrent task) + └── async { } (concurrent task) +``` + +Always use structured concurrency — never `GlobalScope`: + +```kotlin +// BAD +GlobalScope.launch { fetchData() } + +// GOOD — scoped to ViewModel lifecycle +viewModelScope.launch { fetchData() } + +// GOOD — scoped to composable lifecycle +LaunchedEffect(key) { fetchData() } +``` + +### Parallel Decomposition + +Use `coroutineScope` + `async` for parallel work: + +```kotlin +suspend fun loadDashboard(): Dashboard = coroutineScope { + val items = async { itemRepository.getRecent() } + val stats = async { statsRepository.getToday() } + val profile = async { userRepository.getCurrent() } + Dashboard( + items = items.await(), + stats = stats.await(), + profile = profile.await() + ) +} +``` + +### SupervisorScope + +Use `supervisorScope` when child failures should not cancel siblings: + +```kotlin +suspend fun syncAll() = supervisorScope { + launch { syncItems() } // failure here won't cancel syncStats + launch { syncStats() } + launch { syncSettings() } +} +``` + +## Flow Patterns + +### Cold Flow — One-Shot to Stream Conversion + +```kotlin +fun observeItems(): Flow> = flow { + // Re-emits whenever the database changes + itemDao.observeAll() + .map { entities -> entities.map { it.toDomain() } } + .collect { emit(it) } +} +``` + +### StateFlow for UI State + +```kotlin +class DashboardViewModel( + observeProgress: ObserveUserProgressUseCase +) : ViewModel() { + val progress: StateFlow = observeProgress() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = UserProgress.EMPTY + ) +} +``` + +`WhileSubscribed(5_000)` keeps the upstream active for 5 seconds after the last subscriber leaves — survives configuration changes without restarting. + +### Combining Multiple Flows + +```kotlin +val uiState: StateFlow = combine( + itemRepository.observeItems(), + settingsRepository.observeTheme(), + userRepository.observeProfile() +) { items, theme, profile -> + HomeState(items = items, theme = theme, profile = profile) +}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState()) +``` + +### Flow Operators + +```kotlin +// Debounce search input +searchQuery + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { query -> repository.search(query) } + .catch { emit(emptyList()) } + .collect { results -> _state.update { it.copy(results = results) } } + +// Retry with exponential backoff +fun fetchWithRetry(): Flow = flow { emit(api.fetch()) } + .retryWhen { cause, attempt -> + if (cause is IOException && attempt < 3) { + delay(1000L * (1 shl attempt.toInt())) + true + } else { + false + } + } +``` + +### SharedFlow for One-Time Events + +```kotlin +class ItemListViewModel : ViewModel() { + private val _effects = MutableSharedFlow() + val effects: SharedFlow = _effects.asSharedFlow() + + sealed interface Effect { + data class ShowSnackbar(val message: String) : Effect + data class NavigateTo(val route: String) : Effect + } + + private fun deleteItem(id: String) { + viewModelScope.launch { + repository.delete(id) + _effects.emit(Effect.ShowSnackbar("Item deleted")) + } + } +} + +// Collect in Composable +LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message) + is Effect.NavigateTo -> navController.navigate(effect.route) + } + } +} +``` + +## Dispatchers + +```kotlin +// CPU-intensive work +withContext(Dispatchers.Default) { parseJson(largePayload) } + +// IO-bound work +withContext(Dispatchers.IO) { database.query() } + +// Main thread (UI) — default in viewModelScope +withContext(Dispatchers.Main) { updateUi() } +``` + +In KMP, use `Dispatchers.Default` and `Dispatchers.Main` (available on all platforms). `Dispatchers.IO` is JVM/Android only — use `Dispatchers.Default` on other platforms or provide via DI. + +## Cancellation + +### Cooperative Cancellation + +Long-running loops must check for cancellation: + +```kotlin +suspend fun processItems(items: List) = coroutineScope { + for (item in items) { + ensureActive() // throws CancellationException if cancelled + process(item) + } +} +``` + +### Cleanup with try/finally + +```kotlin +viewModelScope.launch { + try { + _state.update { it.copy(isLoading = true) } + val data = repository.fetch() + _state.update { it.copy(data = data) } + } finally { + _state.update { it.copy(isLoading = false) } // always runs, even on cancellation + } +} +``` + +## Testing + +### Testing StateFlow with Turbine + +```kotlin +@Test +fun `search updates item list`() = runTest { + val fakeRepository = FakeItemRepository().apply { emit(testItems) } + val viewModel = ItemListViewModel(GetItemsUseCase(fakeRepository)) + + viewModel.state.test { + assertEquals(ItemListState(), awaitItem()) // initial + + viewModel.onSearch("query") + val loading = awaitItem() + assertTrue(loading.isLoading) + + val loaded = awaitItem() + assertFalse(loaded.isLoading) + assertEquals(1, loaded.items.size) + } +} +``` + +### Testing with TestDispatcher + +```kotlin +@Test +fun `parallel load completes correctly`() = runTest { + val viewModel = DashboardViewModel( + itemRepo = FakeItemRepo(), + statsRepo = FakeStatsRepo() + ) + + viewModel.load() + advanceUntilIdle() + + val state = viewModel.state.value + assertNotNull(state.items) + assertNotNull(state.stats) +} +``` + +### Faking Flows + +```kotlin +class FakeItemRepository : ItemRepository { + private val _items = MutableStateFlow>(emptyList()) + + override fun observeItems(): Flow> = _items + + fun emit(items: List) { _items.value = items } + + override suspend fun getItemsByCategory(category: String): Result> { + return Result.success(_items.value.filter { it.category == category }) + } +} +``` + +## Anti-Patterns to Avoid + +- Using `GlobalScope` — leaks coroutines, no structured cancellation +- Collecting Flows in `init {}` without a scope — use `viewModelScope.launch` +- Using `MutableStateFlow` with mutable collections — always use immutable copies: `_state.update { it.copy(list = it.list + newItem) }` +- Catching `CancellationException` — let it propagate for proper cancellation +- Using `flowOn(Dispatchers.Main)` to collect — collection dispatcher is the caller's dispatcher +- Creating `Flow` in `@Composable` without `remember` — recreates the flow every recomposition + +## References + +See skill: `compose-multiplatform-patterns` for UI consumption of Flows. +See skill: `android-clean-architecture` for where coroutines fit in layers.