Android Development with Kotlin
A comprehensive technical guide covering Kotlin fundamentals, Android Jetpack, Material Design 3, dependency injection, networking, Gradle build system, Fastlane automation, testing, ProGuard/R8, white-label app builds, and Play Store deployment.
Table of Contents
- Kotlin Fundamentals for Android
- Activities, Fragments, and Navigation
- Jetpack: Compose, ViewModel, LiveData, Room, WorkManager, DataStore
- Material Design 3
- Dependency Injection: Hilt and Koin
- Networking: Retrofit and OkHttp
- Gradle Build System
- Fastlane Automation
- Testing: JUnit, Espresso, and Compose Testing
- ProGuard and R8 Optimization
- White-Label App Builds
- Play Store Deployment
- Latest Features (2025-2026)
1. Kotlin Fundamentals for Android
Null Safety and Type System
Kotlin's type system distinguishes nullable and non-nullable types at compile time, eliminating the majority of NullPointerExceptions. The ? operator, safe calls (?.), and the Elvis operator (?:) provide concise null handling.
// Nullable type declaration
var name: String? = null
// Safe call chain with Elvis default
val length = name?.trim()?.length ?: 0
// Smart cast after null check
if (name != null) {
println(name.length) // compiler knows it's non-null
}
// let scope function for nullable
name?.let { nonNullName ->
println("Name is $nonNullName")
}
Coroutines and Structured Concurrency
Kotlin coroutines provide lightweight concurrency. Android uses viewModelScope and lifecycleScope to tie coroutine lifecycle to UI components, preventing leaks.
// ViewModel with coroutine scope
class UserViewModel : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
userRepository.fetchAll()
}
_users.value = result
}
}
}
// Structured concurrency with supervisorScope
suspend fun fetchDashboard() = supervisorScope {
val profile = async { api.getProfile() }
val stats = async { api.getStats() }
DashboardData(profile.await(), stats.await())
}
Extension Functions and DSLs
Extension functions let you add behavior to existing classes without inheritance. Combined with lambdas and receivers, they enable expressive domain-specific languages (DSLs).
// Extension function on View
fun View.visible() { visibility = View.VISIBLE }
fun View.gone() { visibility = View.GONE }
// Extension with generic constraint
fun <T : Comparable<T>> List<T>.isSorted(): Boolean =
zipWithNext().all { (a, b) -> a <= b }
// DSL-style builder
val dialog = alertDialog(context) {
title = "Confirm"
message = "Delete this item?"
positiveButton("Delete") { deleteItem() }
negativeButton("Cancel") { dismiss() }
}
Sealed Classes and Data Classes
Sealed classes restrict class hierarchies to a finite set of subtypes, enabling exhaustive when expressions. Data classes auto-generate equals, hashCode, copy, and toString, making them ideal for state modeling and DTOs.
// Sealed class for navigation events
sealed class NavigationEvent {
data class GoToDetail(val itemId: Long) : NavigationEvent()
data class GoToProfile(val userId: String) : NavigationEvent()
object GoBack : NavigationEvent()
object GoHome : NavigationEvent()
}
// Exhaustive when — compiler enforces all branches
fun handleNavigation(event: NavigationEvent) = when (event) {
is NavigationEvent.GoToDetail -> navigateToDetail(event.itemId)
is NavigationEvent.GoToProfile -> navigateToProfile(event.userId)
NavigationEvent.GoBack -> popBackStack()
NavigationEvent.GoHome -> navigateToHome()
}
// Data class with copy for immutable updates
data class UserProfile(
val name: String,
val email: String,
val avatarUrl: String? = null,
val isPremium: Boolean = false
)
val updated = profile.copy(isPremium = true)
2. Activities, Fragments, and Navigation
Activity and Fragment Lifecycle
Understanding the lifecycle is critical for preventing memory leaks and data loss. Activities go through onCreate → onStart → onResume → onPause → onStop → onDestroy. Fragments add onCreateView and onDestroyView.
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Lifecycle-aware observer
lifecycle.addObserver(AnalyticsObserver())
}
}
// Fragment with ViewBinding
class ProfileFragment : Fragment(R.layout.fragment_profile) {
private var _binding: FragmentProfileBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
_binding = FragmentProfileBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // prevent memory leak
}
}
Jetpack Navigation Component
The Navigation component provides a framework for in-app navigation with a visual graph editor, safe args for type-safe argument passing, and deep link support.
<!-- nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:startDestination="@id/homeFragment">
<fragment android:id="@+id/homeFragment"
android:name="com.example.HomeFragment">
<action android:id="@+id/action_home_to_detail"
app:destination="@id/detailFragment">
<argument android:name="itemId" app:argType="long" />
</action>
</fragment>
</navigation>
// Navigate with safe args
val action = HomeFragmentDirections
.actionHomeToDetail(itemId = 42L)
findNavController().navigate(action)
3. Jetpack: Compose, ViewModel, LiveData, Room, WorkManager, DataStore
Jetpack Compose
Compose is Android's modern declarative UI toolkit. It replaces XML layouts with composable functions that describe the UI as a function of state. Recomposition automatically updates only the parts of the UI that changed.
@Composable
fun UserList(viewModel: UserViewModel = viewModel()) {
val users by viewModel.users.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp)
) {
items(users, key = { it.id }) { user ->
UserCard(user = user, onClick = { viewModel.select(user) })
}
}
}
@Composable
fun UserCard(user: User, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(user.name, style = MaterialTheme.typography.titleMedium)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}
ViewModel and StateFlow
ViewModel survives configuration changes (rotation, dark mode toggle). Combined with Kotlin StateFlow, it provides a reactive, lifecycle-safe data pipeline from repository to UI.
class ProductViewModel(
private val repository: ProductRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchQuery = savedStateHandle.getStateFlow("query", "")
val products: StateFlow<UiState<List<Product>>> =
searchQuery.flatMapLatest { query ->
repository.search(query)
.map<List<Product>, UiState<List<Product>>> { UiState.Success(it) }
.catch { emit(UiState.Error(it.message ?: "Unknown error")) }
.onStart { emit(UiState.Loading) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
fun updateQuery(query: String) {
savedStateHandle["query"] = query
}
}
sealed interface UiState<out T> {
object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String) : UiState<Nothing>
}
Room Database
Room provides an abstraction layer over SQLite with compile-time query verification, Flow-based reactive queries, and automatic migration support.
@Entity(tableName = "workouts")
data class Workout(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "duration_min") val durationMin: Int,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
@Dao
interface WorkoutDao {
@Query("SELECT * FROM workouts ORDER BY created_at DESC")
fun getAll(): Flow<List<Workout>>
@Query("SELECT * FROM workouts WHERE id = :id")
suspend fun getById(id: Long): Workout?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(workout: Workout): Long
@Delete
suspend fun delete(workout: Workout)
}
@Database(entities = [Workout::class], version = 2, exportSchema = true)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun workoutDao(): WorkoutDao
}
WorkManager
WorkManager handles deferrable, guaranteed background work that persists through app restarts and device reboots. It supports constraints (network, battery, storage), chaining, periodic work, and exponential backoff.
// Define a Worker
class SyncWorker(
context: Context,
params: WorkerParameters,
private val repository: SyncRepository
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
repository.syncPendingChanges()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry()
else Result.failure()
}
}
}
// Schedule periodic sync with constraints
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 1, repeatIntervalTimeUnit = TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS
).build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, syncRequest)
DataStore
DataStore replaces SharedPreferences with a coroutine-based, type-safe API. Preferences DataStore stores key-value pairs, while Proto DataStore uses Protocol Buffers for typed objects with schema evolution.
// Preferences DataStore
val Context.settingsDataStore by preferencesDataStore(name = "settings")
object PrefsKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val LOCALE = stringPreferencesKey("locale")
val ONBOARDING_DONE = booleanPreferencesKey("onboarding_done")
}
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
val darkMode: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[PrefsKeys.DARK_MODE] ?: false
}
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PrefsKeys.DARK_MODE] = enabled
}
}
}
// Proto DataStore for complex typed settings
val Context.userProtoDataStore by dataStore(
fileName = "user_prefs.pb",
serializer = UserPreferencesSerializer
)
4. Material Design 3
Dynamic Color and Theming
Material Design 3 (Material You) introduces dynamic color, which extracts a color scheme from the user's wallpaper. The Compose Material 3 library provides a full theming system with color roles, typography scales, and shape tokens.
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme(
primary = Color(0xFF6E56CF),
secondary = Color(0xFF0EA5E9),
background = Color(0xFF0B0F1A)
)
else -> lightColorScheme(
primary = Color(0xFF6E56CF),
secondary = Color(0xFF0EA5E9),
background = Color(0xFFF8FAFC)
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Material 3 Components in Compose
Material 3 provides updated components including top app bars with scroll behavior, navigation bars, search bars, and date/time pickers. Each component adapts to the active color scheme automatically.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(navController: NavHostController) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyApp") },
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentRoute == "home",
onClick = { navController.navigate("home") },
icon = { Icon(Icons.Filled.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = currentRoute == "workouts",
onClick = { navController.navigate("workouts") },
icon = { Icon(Icons.Filled.FitnessCenter, contentDescription = "Workouts") },
label = { Text("Workouts") }
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { innerPadding ->
NavHost(navController, startDestination = "home",
modifier = Modifier.padding(innerPadding)) {
composable("home") { HomeScreen() }
composable("workouts") { WorkoutsScreen() }
}
}
}
5. Dependency Injection: Hilt and Koin
Hilt (Dagger-based)
Hilt is the recommended DI framework for Android. Built on Dagger, it provides compile-time dependency validation, Android-specific scopes (@Singleton, @ViewModelScoped, @ActivityScoped), and seamless integration with Jetpack components.
@HiltAndroidApp
class MyApp : Application()
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE)
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
@HiltViewModel
class WorkoutViewModel @Inject constructor(
private val repository: WorkoutRepository
) : ViewModel() {
val workouts = repository.getAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
Koin (Lightweight Alternative)
Koin is a pragmatic, lightweight DI framework that uses Kotlin DSL instead of annotation processing. It has faster build times than Hilt but validates dependencies at runtime instead of compile time.
// Koin module definitions
val networkModule = module {
single {
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(get()))
.build()
}
single {
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE)
.client(get())
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
single { get<Retrofit>().create(ApiService::class.java) }
}
val repositoryModule = module {
single { WorkoutRepository(get(), get()) }
single { UserRepository(get()) }
}
val viewModelModule = module {
viewModel { WorkoutViewModel(get()) }
viewModel { params -> DetailViewModel(get(), params.get()) }
}
// Start Koin in Application
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApp)
modules(networkModule, repositoryModule, viewModelModule)
}
}
}
6. Networking: Retrofit and OkHttp
Retrofit API Layer
Retrofit turns HTTP APIs into Kotlin interfaces with annotation-based endpoint definitions. Combined with Moshi or Kotlin Serialization for JSON parsing and coroutine support, it provides a type-safe, concise networking layer.
interface ApiService {
@GET("v2/workouts")
suspend fun getWorkouts(
@Query("page") page: Int = 1,
@Query("per_page") perPage: Int = 20
): PaginatedResponse<Workout>
@GET("v2/workouts/{id}")
suspend fun getWorkout(@Path("id") id: Long): Workout
@POST("v2/workouts")
suspend fun createWorkout(@Body workout: CreateWorkoutRequest): Workout
@Multipart
@PUT("v2/users/avatar")
suspend fun uploadAvatar(@Part image: MultipartBody.Part): UserProfile
@Streaming
@GET
suspend fun downloadFile(@Url url: String): ResponseBody
}
// Repository with error handling
class WorkoutRepository(
private val api: ApiService,
private val dao: WorkoutDao
) {
fun getAll(): Flow<List<Workout>> = flow {
// Emit cached data first
emitAll(dao.getAll())
}.onStart {
// Refresh from network in background
try {
val remote = api.getWorkouts()
dao.upsertAll(remote.data)
} catch (_: Exception) { /* use cache */ }
}
}
OkHttp Interceptors and Configuration
OkHttp interceptors handle cross-cutting concerns such as authentication, logging, caching, and retry logic. Application interceptors run once per logical call, while network interceptors run per physical request including redirects.
class AuthInterceptor(
private val tokenProvider: TokenProvider
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.accessToken
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("Accept", "application/json")
.build()
val response = chain.proceed(request)
// Handle 401 with token refresh
if (response.code == 401) {
response.close()
val newToken = runBlocking { tokenProvider.refreshToken() }
val retryRequest = request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
return chain.proceed(retryRequest)
}
return response
}
}
// OkHttp cache configuration
val client = OkHttpClient.Builder()
.cache(Cache(cacheDir, 10L * 1024 * 1024)) // 10 MB
.addInterceptor(AuthInterceptor(tokenProvider))
.addNetworkInterceptor(HttpLoggingInterceptor())
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAA...")
.build()
)
.build()
7. Gradle Build System
Build Variants and Product Flavors
Gradle's build variant system combines build types (debug, release) with product flavors to produce multiple APKs from a single codebase. This is the foundation for white-label builds.
// build.gradle.kts (app module)
android {
namespace = "com.example.app"
compileSdk = 34
defaultConfig {
applicationId = "com.example.app"
minSdk = 24
targetSdk = 34
versionCode = 120
versionName = "3.4.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
flavorDimensions += "brand"
productFlavors {
create("main") {
applicationId = "com.example.app"
resValue("string", "app_name", "MyApp")
buildConfigField("String", "API_BASE", "\"https://api.example.com\"")
}
create("gymplus") {
applicationId = "com.gymplus.app"
resValue("string", "app_name", "GymPlus")
buildConfigField("String", "API_BASE", "\"https://api.gymplus.com\"")
}
}
}
Dependency Management with Version Catalogs
Gradle Version Catalogs (libs.versions.toml) centralize dependency versions across multi-module projects, replacing the old ext block pattern.
# gradle/libs.versions.toml
[versions]
kotlin = "1.9.22"
compose-bom = "2024.02.00"
room = "2.6.1"
hilt = "2.50"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version = "8.2.2" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
Custom Gradle Plugins and Convention Plugins
Convention plugins extract shared build logic into reusable precompiled script plugins. In multi-module projects, they eliminate duplicated configuration across feature modules and enforce consistent build settings.
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
extensions.configure<LibraryExtension> {
compileSdk = 34
defaultConfig.minSdk = 24
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
}
// build-logic/convention/build.gradle.kts
gradlePlugin {
plugins {
register("androidLibrary") {
id = "app.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
}
}
// feature/workouts/build.gradle.kts — minimal config
plugins {
id("app.android.library")
id("app.android.hilt")
}
8. Fastlane Automation
Fastfile Configuration
Fastlane automates building, testing, screenshot capture, and Play Store deployment. Lanes define reusable workflows that can be triggered from CI/CD pipelines.
# fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run unit tests"
lane :test do
gradle(task: "testDebugUnitTest")
end
desc "Build release AAB for specific brand"
lane :build do |options|
brand = options[:brand] || "main"
gradle(
task: "bundle",
flavor: brand,
build_type: "Release",
properties: {
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
}
)
end
desc "Deploy to Play Store internal track"
lane :deploy_internal do |options|
brand = options[:brand] || "main"
build(brand: brand)
upload_to_play_store(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
package_name: brand_package_name(brand),
json_key: ENV["PLAY_STORE_JSON_KEY"]
)
end
desc "Promote internal to production"
lane :promote_production do |options|
upload_to_play_store(
track: "internal",
track_promote_to: "production",
package_name: brand_package_name(options[:brand]),
json_key: ENV["PLAY_STORE_JSON_KEY"]
)
end
end
9. Testing: JUnit, Espresso, and Compose Testing
Unit Testing with JUnit and Turbine
Unit tests validate business logic in isolation. Use JUnit 5 for test structure, MockK or Mockito-Kotlin for mocking, and Turbine for testing Kotlin Flow emissions. The kotlinx-coroutines-test library provides runTest for deterministic coroutine testing.
@ExtendWith(MockKExtension::class)
class WorkoutViewModelTest {
@MockK lateinit var repository: WorkoutRepository
private lateinit var viewModel: WorkoutViewModel
@BeforeEach
fun setup() {
every { repository.getAll() } returns flowOf(
listOf(Workout(1, "Push Day", 45), Workout(2, "Pull Day", 50))
)
viewModel = WorkoutViewModel(repository)
}
@Test
fun `loadWorkouts emits expected list`() = runTest {
viewModel.workouts.test {
val result = awaitItem()
assertEquals(2, result.size)
assertEquals("Push Day", result[0].name)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `deleteWorkout calls repository`() = runTest {
coEvery { repository.delete(any()) } just Runs
viewModel.delete(Workout(1, "Push Day", 45))
coVerify { repository.delete(match { it.id == 1L }) }
}
}
UI Testing with Espresso
Espresso tests run on a real device or emulator and verify UI interactions. They use a synchronization mechanism that waits for the UI thread and async tasks to idle before making assertions.
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class WorkoutListTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun workoutList_displaysItems() {
onView(withId(R.id.recycler_workouts))
.check(matches(isDisplayed()))
onView(withText("Push Day"))
.check(matches(isDisplayed()))
}
@Test
fun clickWorkout_navigatesToDetail() {
onView(withText("Push Day")).perform(click())
onView(withId(R.id.workout_detail_title))
.check(matches(withText("Push Day")))
}
@Test
fun swipeToDelete_removesItem() {
onView(withText("Push Day"))
.perform(swipeLeft())
onView(withText("Push Day"))
.check(doesNotExist())
}
}
Compose UI Testing
Compose provides its own testing framework with semantic-based node matching. Tests use ComposeTestRule to set composable content and interact via semantic matchers, making tests resilient to layout changes.
class UserCardTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun userCard_displaysNameAndEmail() {
val user = User(id = 1, name = "Ana Garcia", email = "ana@example.com")
composeRule.setContent {
AppTheme {
UserCard(user = user, onClick = {})
}
}
composeRule.onNodeWithText("Ana Garcia").assertIsDisplayed()
composeRule.onNodeWithText("ana@example.com").assertIsDisplayed()
}
@Test
fun userCard_clickTriggersCallback() {
var clicked = false
val user = User(id = 1, name = "Ana Garcia", email = "ana@example.com")
composeRule.setContent {
AppTheme {
UserCard(user = user, onClick = { clicked = true })
}
}
composeRule.onNodeWithText("Ana Garcia").performClick()
assertTrue(clicked)
}
}
10. ProGuard and R8 Optimization
R8 Configuration and Keep Rules
R8 is Android's default code shrinker, obfuscator, and optimizer that replaces ProGuard. It removes unused code (tree shaking), renames symbols (obfuscation), and applies optimizations. Keep rules preserve classes needed at runtime via reflection, serialization, or JNI.
# proguard-rules.pro
# Keep Retrofit interfaces (reflection-based)
-keep,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn retrofit2.**
# Keep Moshi JSON adapters
-keep class * extends com.squareup.moshi.JsonAdapter
-keepclassmembers class * {
@com.squareup.moshi.Json <fields>;
}
# Keep data classes used with serialization
-keepclassmembers class com.example.app.data.model.** {
<init>(...);
<fields>;
}
# Keep WorkManager worker classes
-keep class * extends androidx.work.Worker
-keep class * extends androidx.work.ListenableWorker
# Keep enum values (used by Gson/Moshi)
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Remove log statements in release
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
Debugging Minified Builds
R8 generates a mapping file (mapping.txt) that maps obfuscated names back to original names. Upload this file to Google Play Console and Firebase Crashlytics to deobfuscate stack traces from production crashes.
// build.gradle.kts — generate mapping for each build
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
// Upload mapping to Firebase Crashlytics
// The Google Services plugin handles this automatically:
// app/build/outputs/mapping/{variant}/mapping.txt
# Verify what R8 removed (useful for debugging ClassNotFoundException)
# Run with -printusage to see removed classes:
# ./gradlew assembleMainRelease --info | grep "R8"
# Deobfuscate a stack trace locally
retrace mapping.txt stacktrace.txt
11. White-Label App Builds
Architecture for Multi-Brand Apps
White-label apps share a core codebase but vary in branding, API endpoints, feature flags, and assets. The combination of Gradle product flavors, resource overlays, and a brand configuration system enables building dozens of distinct apps from one repository.
// Brand configuration loaded at runtime
data class BrandConfig(
val brandId: String,
val displayName: String,
val primaryColor: Long,
val apiBaseUrl: String,
val features: Set<Feature>
)
enum class Feature {
LIVE_CLASSES, NUTRITION_TRACKING, WEARABLE_SYNC,
SOCIAL_FEED, IN_APP_PURCHASE, PUSH_NOTIFICATIONS
}
// Directory structure for brand overlays
// app/src/main/res/ -> brand-specific resources
// app/src/gymplus/res/ -> GymPlus brand resources
// app/src/main/res/ -> shared default resources
// CI/CD loop for 41 brands
# build-all-brands.sh
BRANDS=$(cat brands.json | jq -r '.[].id')
for brand in $BRANDS; do
echo "Building $brand..."
bundle exec fastlane build brand:$brand
bundle exec fastlane deploy_internal brand:$brand
done
12. Play Store Deployment
Release Tracks and Staged Rollouts
Google Play supports multiple release tracks: internal testing (up to 100 testers), closed testing (specific groups), open testing (anyone can join), and production. Staged rollouts let you release to a percentage of users.
# Staged rollout: 10% -> 25% -> 50% -> 100%
lane :staged_rollout do |options|
percentage = options[:percentage] || 0.1
upload_to_play_store(
track: "production",
rollout: percentage.to_s,
package_name: options[:package],
json_key: ENV["PLAY_STORE_JSON_KEY"],
skip_upload_apk: true,
skip_upload_aab: true,
version_code: options[:version_code]
)
end
App Signing and Security
Google Play App Signing manages the app signing key in Google's infrastructure. You sign the upload with an upload key, and Google re-signs with the app signing key. This protects against key loss and enables key rotation.
# Generate upload keystore
keytool -genkeypair -v \
-keystore upload-keystore.jks \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-alias upload-key \
-storepass "${STORE_PASS}" \
-keypass "${KEY_PASS}"
# Gradle signing config (from environment)
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH"))
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
}
CI/CD Pipeline Integration
A complete GitLab CI/CD pipeline for Android builds, tests, and deployment. Each stage runs in a Docker container with the Android SDK pre-installed.
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
test:
stage: test
image: cimg/android:2024.01
script:
- ./gradlew testDebugUnitTest
- ./gradlew lintDebug
artifacts:
reports:
junit: app/build/test-results/**/*.xml
build:release:
stage: build
image: cimg/android:2024.01
script:
- bundle exec fastlane build brand:${BRAND}
artifacts:
paths:
- app/build/outputs/bundle/
parallel:
matrix:
- BRAND: [brand-a, brand-b, brand-c]
deploy:internal:
stage: deploy
image: cimg/android:2024.01
script:
- bundle exec fastlane deploy_internal brand:${BRAND}
when: manual
only:
- main
13. Latest Features (2025-2026)
Kotlin Multiplatform (KMP)
Share business logic across Android, iOS, desktop, and web from a single Kotlin codebase. KMP is now production-stable and officially supported by Google for Android development. Write shared networking, data, and domain layers once, with platform-specific UI. Swift export is enabled by default in Kotlin 2.2, making iOS interop seamless. Expect/actual declarations handle platform differences at compile time.
Compose Multiplatform
Compose Multiplatform v1.10.3 is stable for Android, iOS, and desktop. Share component-level UI across platforms using the same Compose APIs you already know. Hot Reload support enables real-time preview of UI changes during development. iOS rendering uses native UIKit integration under the hood, delivering native performance and feel.
Kotlin 2.3.20 and 2.4.0 Beta
Kotlin 2.3.20 (March 2026) is the latest stable release. Key features include an unused return value checker enabled by default, explicit backing fields for properties, support for Java 25, and improved Swift export for KMP. Kotlin 2.4.0-Beta1 is already available with stable context parameters, support for Java 26, Kotlin/Native Swift package dependencies, and more consistent inline function behavior. Kotlin 2.4.0 is planned for June-July 2026.
Android 16
Android 16 introduces adaptive layout requirements for large screens, requiring apps to support responsive layouts on tablets and foldables. Live Updates are a new class of notifications with a ProgressStyle template for ongoing activities like rideshare tracking, delivery, and navigation. Health Connect now supports FHIR medical records for interoperable health data. Wi-Fi 6 (802.11az) location gets AES-256 encryption and MITM protection on supported devices. System-triggered profiling captures real-world performance data from user devices, replacing synthetic benchmarks with production metrics.
Jetpack Compose Updates
Compose 1.11.0 RC is in beta as of April 2026, building on the stable 1.10 release. Pausable composition is now enabled by default, allowing the framework to interrupt and resume composition work for smoother UI. Scroll performance now matches Views in benchmarks. New features include native autofill support for form fields, auto-sizing text that adapts to container bounds, visibility tracking for high-performance composable positioning, and Material 3 v1.4 with updated components. These updates reduce boilerplate and improve performance without requiring code changes.
Gemini Nano On-Device AI
Android 16 expands the AICore system service for on-device inference with Gemini Nano. The ML Kit Inference API enables apps to run LLM inference locally without internet connectivity or API keys. The model runs as a system-level singleton — apps request access through a structured API rather than bundling model weights. Devices with the latest chipsets ship with Gemini Nano 2.0 or equivalent small language models as standard, opening new possibilities for offline-capable AI features.
Material 3 Expressive
Material 3 Expressive is a major redesign of Android's visual language, rolled out via Android 16 QPR1. It introduces physics-based spring animations for more fluid interactions, richer dynamic color palettes with clearer separation between primary, secondary, and tertiary tones, and 15 new or refreshed UI components including button groups, split buttons, toolbars, and updated loading indicators. Haptic feedback is now coupled with visual transitions — dismissing a notification produces both a visual spring effect and a satisfying haptic rumble. Developers should update Material 3 Compose dependencies to v1.4+ to access the new component library.
Desktop Mode for Tablets
Android 16 introduces a desktop mode for tablets and connected displays, built on the foundation of Samsung DeX. When a device is connected to an external monitor, it presents a desktop-like interface with a taskbar, status bar, freeform window support, and mouse movement between screens. Android 16 QPR1 adds flexible window tiling, multi-instance management, and desktop persistence. Apps that already support adaptive layouts work out of the box; apps targeting large screens should test freeform window resize behavior and multi-instance scenarios.