Android Kotlin Skill
SkillDev toolsAndroid Kotlin development with Coroutines, Jetpack Compose, Hilt, and MockK testing
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Android Kotlin Skill skill
What this skill tells your AI
The instructions your AI receives, as published by alinaqi/maggy in skills/android-kotlin/SKILL.md and read by ahel’s review.
Project Structure
project/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── kotlin/com/example/app/
│ │ │ │ ├── data/ # Data layer
│ │ │ │ │ ├── local/ # Room database
│ │ │ │ │ ├── remote/ # Retrofit/Ktor services
│ │ │ │ │ └── repository/ # Repository implementations
│ │ │ │ ├── di/ # Hilt modules
│ │ │ │ ├── domain/ # Business logic
│ │ │ │ │ ├── model/ # Domain models
│ │ │ │ │ ├── repository/ # Repository interfaces
│ │ │ │ │ └── usecase/ # Use cases
│ │ │ │ ├── ui/ # Presentation layer
│ │ │ │ │ ├── feature/ # Feature screens
│ │ │ │ │ │ ├── FeatureScreen.kt # Compose UI
│ │ │ │ │ │ └── FeatureViewModel.kt
│ │ │ │ │ ├── components/ # Reusable Compose components
│ │ │ │ │ └── theme/ # Material theme
│ │ │ │ └── App.kt # Application class
│ │ │ ├── res/
│ │ │ └── AndroidManifest.xml
│ │ ├── test/ # Unit tests
│ │ └── androidTest/ # Instrumentation tests
│ └── build.gradle.kts
├── build.gradle.kts # Project-level build file
├── gradle.properties
├── settings.gradle.kts
└── CLAUDE.md
Gradle Configuration (Kotlin DSL)
App-level build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.example.app"
compileSdk = 34
defaultConfig {
applicationId = "com.example.app"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
}
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Hilt
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Kotlin Coroutines & Flow
ViewModel with StateFlow
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserUseCase: GetUserUseCase,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
private val userId: String = checkNotNull(savedStateHandle["userId"])
init {
loadUser()
}
fun loadUser() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getUserUseCase(userId)
.catch { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
.collect { user ->
_uiState.update {
it.copy(isLoading = false, user = user, error = null)
}
}
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
}
data class UserUiState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
Repository with Flow
interface UserRepository {
fun getUser(userId: String): Flow<User>
fun observeUsers(): Flow<List<User>>
suspend fun saveUser(user: User)
}
class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dao: UserDao,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserRepository {
override fun getUser(userId: String): Flow<User> = flow {
// Emit cached data first
dao.getUserById(userId)?.let { emit(it) }
// Fetch from network and update cache
val remoteUser = api.getUser(userId)
dao.insert(remoteUser)
emit(remoteUser)
}.flowOn(dispatcher)
override fun observeUsers(): Flow<List<User>> =
dao.observeAllUsers().flowOn(dispatcher)
override suspend fun saveUser(user: User) = withContext(dispatcher) {
api.saveUser(user)
dao.insert(user)
}
}
Jetpack Compose
Screen with ViewModel
@Composable
fun UserScreen(
viewModel: UserViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
UserScreenContent(
uiState = uiState,
onRefresh = viewModel::loadUser,
onErrorDismiss = viewModel::clearError,
onNavigateBack = onNavigateBack
)
}
@Composable
private fun UserScreenContent(
uiState: UserUiState,
onRefresh: () -> Unit,
onErrorDismiss: () -> Unit,
onNavigateBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("User Profile") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
uiState.user != null -> {
UserContent(user = uiState.user)
}
}
uiState.error?.let { error ->
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter),
action = {
TextButton(onClick = onErrorDismiss) {
Text("Dismiss")
}
}
) {
Text(error)
}
}
}
}
}
Sealed Classes for State
Result Wrapper
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data
inline fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
is Result.Success -> Result.Success(transform(data))
is Result.Error -> this
is Result.Loading -> this
}
Testing with MockK & Turbine
ViewModel Tests
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val getUserUseCase: GetUserUseCase = mockk()
private val savedStateHandle = SavedStateHandle(mapOf("userId" to "123"))
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
viewModel = UserViewModel(getUserUseCase, savedStateHandle)
}
@Test
fun `loadUser success updates state with user`() = runTest {
val user = User("123", "John Doe", "john@example.com")
coEvery { getUserUseCase("123") } returns flowOf(user)
viewModel.uiState.test {
val initial = awaitItem()
assertFalse(initial.isLoading)
viewModel.loadUser()
val loading = awaitItem()
assertTrue(loading.isLoading)
val success = awaitItem()
assertFalse(success.isLoading)
assertEquals(user, success.user)
}
}
}
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
GitHub Actions
name: Android Kotlin CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Run Detekt
run: ./gradlew detekt
- name: Run Ktlint
run: ./gradlew ktlintCheck
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest
- name: Build Debug APK
run: ./gradlew assembleDebug
Lint Configuration
detekt.yml
build:
maxIssues: 0
complexity:
LongMethod:
threshold: 20
LongParameterList:
functionThreshold: 4
TooManyFunctions:
thresholdInFiles: 10
style:
MaxLineLength:
maxLineLength: 120
WildcardImport:
active: true
coroutines:
GlobalCoroutineUsage:
active: true
Kotlin Anti-Patterns
- ❌ Blocking coroutines on Main - Never use
runBlockingon main thread - ❌ GlobalScope usage - Use structured concurrency with viewModelScope/lifecycleScope
- ❌ Collecting flows in init - Use
repeatOnLifecycleorcollectAsStateWithLifecycle - ❌ Mutable state exposure - Expose
StateFlownotMutableStateFlow - ❌ Not handling exceptions in flows - Always use
catchoperator - ❌ Lateinit for nullable - Use
lazyor nullable with? - ❌ Hardcoded dispatchers - Inject dispatchers for testability
- ❌ Not using sealed classes - Prefer sealed for finite state sets
- ❌ Side effects in Composables - Use
LaunchedEffect/SideEffect - ❌ Unstable Compose parameters - Use stable/immutable types or
@Stable
Signals
- GitHub stars
- 707
- Forks
- 56
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
android-kotlin- Source
- github.com/alinaqi/maggy