Migration Playbooks
Real migrations are rarely "rip and replace" — they're incremental, verified at every step, and reversible if production breaks. Each playbook below has been used at scale; together they cover the legacy debt every Android team eventually faces.
1. Java → Kotlin
Why migrate
- Null safety eliminates ~30% of NPEs.
- Data classes, smart casts, extension functions cut boilerplate dramatically.
- Coroutines / Flow are Kotlin-native — bolt-on for Java.
- Google's first-party SDKs (Compose, Room KTX, Lifecycle) ship Kotlin-first APIs.
Strategy: file-by-file, leaf-up
Kotlin and Java interoperate seamlessly. Convert leaf classes (no inbound Java callers) first, then percolate up.
- 01
Enable Kotlin in the module
apply plugin: "org.jetbrains.kotlin.android" — adds the Kotlin stdlib and compiler.
- 02
Pick a file with few callers
Models, sealed enums, utility classes, and POJOs are perfect starters.
- 03
Right-click → Convert Java File to Kotlin File
Android Studio runs J2K. Review every TODO comment it leaves.
- 04
Re-run unit tests for the module
Catches J2K mistakes around platform types (T! → expected non-null vs nullable).
- 05
Hand-tune the result
J2K is conservative: replace getter/setter with property syntax, lift companion objects, use data class for POJOs.
- 06
Update Java callers if needed
@JvmStatic, @JvmField, @JvmOverloads keep Java call sites tidy.
- 07
Commit one file per PR while learning
Once your team is fluent, bundle them by feature.
Pitfalls
| Pitfall | Fix |
|---|---|
Platform type T! causes runtime NPE | Explicitly annotate with @NonNull / @Nullable in Java, or check at the Kotlin boundary |
Java code calls getName() after Kotlin migrated to property | Add @get:JvmName("getName") or just keep the method name |
Static fields become companion const val | Add @JvmField for direct field access from Java |
Java callers of vararg fn(args: String) break | Add @JvmName or wrap with overload |
| Default arguments lost from Java | Use @JvmOverloads on the Kotlin function |
J2K cleanup checklist
After every conversion, scan for:
!!operators (J2K loves them — replace with safe calls or explicit nullability).Companionblocks containing only constants →const valat file level.- Manual
equals/hashCode→data class. Objectdeclarations passed as listeners →fun interface+ lambda.Singletonpatterns →object.
Verification
# Ensure no regressions per module
./gradlew :feature-x:test :feature-x:assembleDebug
# Track Kotlin coverage
./gradlew :feature-x:kotlinFilesReport
2. XML Views → Jetpack Compose
Why migrate
- Compose is 30-50% less code for typical screens.
- State-driven UI eliminates a whole class of "view out of sync with model" bugs.
- Modern Android features (Predictive Back, edge-to-edge, large-screen windowing) are easier in Compose.
- Compose Multiplatform unlocks shared UI with iOS/desktop.
Strategy: bottom-up, screen-by-screen
Start with leaf composables, then build up to whole screens, then replace the host Fragment/Activity.
- 01
Add Compose to the module
buildFeatures.compose = true, composeOptions, compose-bom + ui + material3 + tooling dependencies.
- 02
Identify a leaf view
Custom Views or small reusable layouts (badges, cards, empty states) are ideal first conversions.
- 03
Wrap with ComposeView in the existing XML
Embed Compose into the current Fragment without a full rewrite.
- 04
Convert one row / cell / card at a time
A RecyclerView item with 3-5 views is the canonical first target.
- 05
Migrate the list to LazyColumn
Once items are composable, replace RecyclerView with LazyColumn — typically a 70% LoC reduction.
- 06
Replace the host Fragment with a setContent { }
Once every child is composable, drop the XML host entirely.
- 07
Move navigation to Navigation Compose
After 60-80% of screens are converted, swap NavController.
Interop primitives
Compose inside Views
- ComposeView in your XML
- AbstractComposeView for fully custom views
- setContent { } sets the composition
- Use viewModels() / Hilt as normal
Views inside Compose
- AndroidView(factory = { CustomView(it) }) { /* update */ }
- AndroidViewBinding for ViewBinding-generated classes
- remember an updateBlock to flow state into the view
- Useful for Google Maps, WebView, AdView
Mapping cheat sheet
| Views | Compose |
|---|---|
LinearLayout(vertical) | Column |
LinearLayout(horizontal) | Row |
FrameLayout | Box |
ConstraintLayout | ConstraintLayout (or just Row/Column for simple layouts) |
RecyclerView + Adapter | LazyColumn / LazyRow with items() |
ViewPager2 | HorizontalPager (Compose Foundation) |
Fragment | @Composable screen + NavHost route |
findViewById | hoisted state + parameters |
View.OnClickListener | Modifier.clickable { } |
TextView | Text |
EditText | TextField |
Toolbar | TopAppBar (material3) |
BottomNavigationView | NavigationBar (material3) |
DialogFragment | Dialog + AlertDialog |
Snackbar.make | SnackbarHostState.showSnackbar |
Gotchas
- Theme bridging: Material Views themes don't auto-flow into Compose.
Use
MdcTheme()(Accompanist, deprecated) or hand-map XML attrs to aMaterialTheme { }block during transition. - System back: Compose uses
BackHandler { }. If your Fragment had custom back behavior, port it. - Insets:
WindowCompat.setDecorFitsSystemWindows(window, false)+Modifier.windowInsetsPadding(...). Don't mix XMLfitsSystemWindowswith Compose insets. - Saved state:
rememberSaveablefor everything that should survive process death. View state restoration viaBundleSavedStateRegistryworks but is rarely needed if you adopt ViewModel +rememberSaveable.
Validation
- Visual regression: run Paparazzi/Roborazzi screenshots on both versions and diff.
- Macrobenchmark: cold-start and scroll-jank must not regress.
- TalkBack: re-test accessibility — Compose semantics need explicit
contentDescription/Modifier.semantics.
3. kapt → KSP
Why migrate
- KSP is 2-3× faster than kapt for the same processors.
- kapt is in maintenance mode; new Jetpack libraries ship KSP only.
- KSP plays nicer with incremental compilation and K2.
Compatibility check
| Library | KSP support |
|---|---|
| Room | ✅ (since 2.5) |
| Hilt | ✅ (since 2.48) |
| Moshi | ✅ |
| Glide | ✅ |
| Dagger | ✅ (since 2.48) |
| Realm Kotlin | ✅ |
| AutoValue | ❌ (kapt only — but rarely needed in Kotlin) |
| AndroidAnnotations | ❌ (deprecated anyway) |
Steps
- 01
Add KSP plugin to root build
id("com.google.devtools.ksp") version "2.0.21-1.0.27" — match Kotlin version.
- 02
Apply ksp plugin to feature modules
plugins { id("com.google.devtools.ksp") }
- 03
Replace kapt(...) with ksp(...) in deps
kapt("androidx.room:room-compiler:X") → ksp("androidx.room:room-compiler:X")
- 04
Remove kapt configuration
Delete the kapt { } block and the kotlin-kapt plugin once nothing remains.
- 05
Run a clean build
./gradlew clean assembleDebug — compare build time before/after.
- 06
Verify generated code
Look in build/generated/ksp/ — generated classes should match what kapt produced (in build/generated/source/kapt/).
Common errors
| Error | Cause | Fix |
|---|---|---|
unresolved reference: Dao_Impl | One module still uses kapt for Room, another uses ksp | Migrate all Room consumers together |
| Slow first build after migration | KSP rebuilds cache | Normal — subsequent builds will be fast |
Cannot find symbol …_Factory | Hilt processor not migrated | Replace kapt("com.google.dagger:hilt-android-compiler") with ksp(...) |
4. Dagger 2 → Hilt
Why migrate
- Hilt removes ~80% of Dagger boilerplate (no manual component graph).
- Standard scopes (
@Singleton,@ActivityRetainedScoped,@ViewModelScoped) cover real-world cases without custom wiring. - Hilt has Compose, WorkManager, and testing integrations out of the box.
Strategy: bridge with hiltViewModel
You can migrate one feature module at a time. Dagger and Hilt coexist
because Hilt is Dagger under the hood — same @Module, @Inject,
@Provides.
- 01
Add Hilt plugin and dependencies
hilt-android, hilt-android-gradle-plugin, ksp(hilt-android-compiler), hilt-navigation-compose.
- 02
Annotate Application with @HiltAndroidApp
Generates the top-level component, replacing your custom AppComponent.
- 03
Migrate one feature Activity / Fragment
Replace AndroidInjection with @AndroidEntryPoint. ViewModels get @HiltViewModel.
- 04
Port Modules
Add @InstallIn(SingletonComponent::class) (or the right scope). Most @Module classes work unchanged.
- 05
Replace custom @Components with predefined scopes
AppComponent → SingletonComponent, ActivityComponent → ActivityComponent, etc.
- 06
Migrate tests to @HiltAndroidTest
Use TestInstallIn to swap modules. Replace custom test components.
Mapping
| Dagger 2 | Hilt |
|---|---|
@Component(modules = [...]) | @HiltAndroidApp on Application |
@Subcomponent per Activity | @AndroidEntryPoint |
AndroidInjector boilerplate | Auto-generated |
Custom ViewModelFactory | @HiltViewModel + hiltViewModel() in Compose |
DispatchingAndroidInjector | Removed entirely |
| Manual scope annotations | @Singleton, @ActivityRetainedScoped, @ViewModelScoped, etc. |
5. LiveData → StateFlow
Why migrate
- StateFlow is part of
kotlinx.coroutines— no Android dependency, works in KMP common code. - Operators (
map,combine,flatMapLatest) compose better than LiveData transforms. - Lifecycle-aware collection (
collectAsStateWithLifecycle,repeatOnLifecycle) gives you the same safety as LiveData.
Steps
- 01
Replace MutableLiveData with MutableStateFlow
val state = MutableStateFlow(initial) — same .value getter, .value setter.
- 02
Replace .observe(this) { } in Fragments
Use viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(STARTED) { vm.state.collect { ... } } }
- 03
Replace .observeAsState() in Compose
val state by vm.state.collectAsStateWithLifecycle()
- 04
Replace LiveData transforms
Transformations.map { x -> y } → state.map { it.y }.stateIn(scope, SharingStarted.Eagerly, initial)
- 05
Drop LiveData KTX dependencies
Once no LiveData remains, remove androidx.lifecycle:lifecycle-livedata-ktx
Interop bridges
While migrating, you'll need both APIs:
// StateFlow → LiveData (for legacy Fragment Java)
val stateLive: LiveData<UiState> = state.asLiveData()
// LiveData → Flow (rare; usually go straight to StateFlow)
val flow: Flow<UiState> = legacyLiveData.asFlow()
Single-shot events: SharedFlow
LiveData → StateFlow is straightforward for state. For events
(navigate, show toast), use Channel.receiveAsFlow() or SharedFlow
with extraBufferCapacity and replay = 0. See
Compose & State.
6. RxJava → Coroutines & Flow
Why migrate
- Coroutines are Kotlin-native — better tooling, simpler stack traces.
- Flow has operators for every Rx primitive worth keeping.
- Structured concurrency makes cancellation deterministic.
- Halves the dependency footprint (no more
rxjava3-core,rxjava3-android,rxkotlin,rxbinding).
Mapping
| RxJava | Coroutines / Flow |
|---|---|
Single<T> | suspend fun foo(): T |
Maybe<T> | suspend fun foo(): T? |
Completable | suspend fun foo(): Unit |
Observable<T> | Flow<T> |
Flowable<T> (backpressure) | Flow<T> (cooperative cancellation) |
BehaviorSubject | MutableStateFlow |
PublishSubject | MutableSharedFlow(replay = 0) |
ReplaySubject | MutableSharedFlow(replay = N) |
subscribeOn(Schedulers.io()) | withContext(Dispatchers.IO) { } |
observeOn(AndroidSchedulers.mainThread()) | Dispatchers.Main (already main for collect) |
map { } | map { } |
flatMap { } | flatMapConcat { } / flatMapMerge { } |
switchMap { } | flatMapLatest { } |
combineLatest | combine |
zip | zip |
throttleFirst | sample / custom |
debounce | debounce |
Disposable | Job (cancel with .cancel()) |
CompositeDisposable | CoroutineScope + structured concurrency |
Strategy
Migrate from the edges inward:
- Convert public ViewModel APIs that return
Single<T>tosuspend fun. The Compose UI calls them insideLaunchedEffect/lifecycleScope. - Convert Repository methods next.
- Leave deepest Rx chains in place initially — wrap them with
await()/asFlow()fromkotlinx-coroutines-rx3. - Finally, replace the Rx core: when no
Single/Observableremain in public APIs, drop the RxJava dependency.
Interop
// Rx → Coroutine
suspend fun fetchUser(): User = userSingle.await()
// Coroutine → Rx
val userSingle: Single<User> = rxSingle { fetchUser() }
// Observable → Flow
val flow: Flow<Event> = observable.asFlow()
7. AsyncTask → Coroutines
Why migrate
AsyncTaskwas deprecated in API 30. It leaks the host activity, has no cancellation guarantees, and runs on a serial executor (slow).- One-line replacement with
lifecycleScope.launch { withContext(IO) { ... } }.
Replacement template
// Before
private class LoadTask(val view: WeakReference<TextView>) : AsyncTask<Void, Void, String>() {
override fun doInBackground(vararg params: Void?): String = api.fetch()
override fun onPostExecute(result: String) {
view.get()?.text = result
}
}
LoadTask(WeakReference(textView)).execute()
// After
lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { api.fetch() }
textView.text = result
}
In Compose:
LaunchedEffect(key) {
val result = withContext(Dispatchers.IO) { api.fetch() }
state.value = result
}
Things AsyncTask users forget
- Cancellation is automatic when the scope dies.
- No need for
WeakReference— the scope is tied to the lifecycle. - Exceptions are not swallowed — wrap with try/catch or use
CoroutineExceptionHandler.
8. Gradle Groovy DSL → Kotlin DSL
Why migrate
- Type-safe accessors and IDE auto-complete.
- Build script refactoring works with Find Usages / Rename.
- Convention plugins (best practice for multi-module setups) require Kotlin DSL.
Steps
- 01
Rename settings.gradle → settings.gradle.kts
And convert Groovy syntax: rootProject.name = "..." (no semicolons), include(":app").
- 02
Convert top-level build.gradle next
plugins { id("com.android.application") version "X" apply false } — version goes in strings.
- 03
Module by module: app first, then libs
plugins { id("com.android.application"); id("org.jetbrains.kotlin.android") }. android { } block uses Kotlin syntax — = assignments, lambdas, no parens for properties.
- 04
Convert dependencies to function calls
implementation "androidx.core:core-ktx:1.13.1" → implementation("androidx.core:core-ktx:1.13.1")
- 05
Move versions to libs.versions.toml
Use the Version Catalog — works identically from Groovy and Kotlin DSL, and is required for convention plugins.
- 06
Build convention plugins in buildSrc/ or build-logic/
Now your app modules apply id("myteam.android.application") with all defaults included.
Common syntax differences
| Groovy | Kotlin DSL |
|---|---|
apply plugin: 'kotlin-android' | plugins { id("org.jetbrains.kotlin.android") } |
compileSdkVersion 35 | compileSdk = 35 |
minSdkVersion 24 | minSdk = 24 |
buildConfigField "String", "X", "\"y\"" | buildConfigField("String", "X", "\"y\"") |
implementation 'a:b:1' | implementation("a:b:1") |
signingConfigs { release { ... } } | signingConfigs { create("release") { ... } } |
buildTypes { release { ... } } | buildTypes { getByName("release") { ... } } or release { ... } |
9. SharedPreferences → DataStore
Why migrate
- DataStore uses Flow — read latest values reactively, no callbacks.
- Asynchronous writes avoid main-thread I/O.
- Type-safe with Proto variant.
- SharedPreferences is on the way out for new code.
Steps
- 01
Add datastore-preferences dependency
androidx.datastore:datastore-preferences:1.1.X
- 02
Create a single Context.dataStore
val Context.prefs by preferencesDataStore(name = "settings")
- 03
Read flows for each key
val theme: Flow<String> = ctx.prefs.data.map { it[stringPreferencesKey("theme")] ?: "system" }
- 04
Write with edit { }
ctx.prefs.edit { it[stringPreferencesKey("theme")] = "dark" }
- 05
Use SharedPreferencesMigration during transition
preferencesDataStore(name = "settings", produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "legacy_prefs")) })
- 06
Drop the legacy file once migration runs in production
Wait at least 2 releases before removing the SharedPreferences migration.
10. WorkManager from JobScheduler / AlarmManager / SyncAdapter
Why migrate
- WorkManager is the unified, future-proof background scheduler.
- Handles Doze, Battery Saver, network constraints, retry, and cross-device sync via the same API.
- Survives reboots and process death; persistent queue stored in Room.
Mapping
| Old API | WorkManager replacement |
|---|---|
JobScheduler + JobService | WorkManager + Worker / CoroutineWorker |
AlarmManager (deferrable) | WorkManager with setInitialDelay |
AlarmManager (exact, user-facing) | Stays as AlarmManager (setExactAndAllowWhileIdle) |
SyncAdapter | WorkManager periodic + Constraints.requiredNetworkType |
Service (background, deferrable) | WorkManager |
Service (user-facing) | Foreground service (with WorkManager setForeground) |
Steps
- 01
Identify the work
Is it user-visible (foreground service), exact-time (AlarmManager), or deferrable (WorkManager)?
- 02
Rewrite the job as CoroutineWorker
override suspend fun doWork(): Result — return success/retry/failure.
- 03
Define constraints
NetworkType, requiresCharging, requiresBatteryNotLow — chained constraints replace lots of legacy checks.
- 04
Enqueue
WorkManager.getInstance(ctx).enqueueUniqueWork(name, ExistingWorkPolicy.KEEP, request)
- 05
Migrate state
Cancel any old JobScheduler / AlarmManager jobs from the old codepath on first launch after upgrade.
Universal migration principles
- Lock baselines first. You cannot prove a migration was worth it without before/after numbers.
- Migrate one module at a time. Reversibility beats velocity.
- Bridge with adapters during the long tail.
asFlow(),asLiveData(),await(),rxSingle { }— there's an interop bridge for every transition. - Don't mix concerns. Migrate Java→Kotlin or Views→Compose in a single PR, never both.
- Update CI gates before code. Add lint rules / detekt config / Konsist tests that forbid the old API for new code, so the migration only ever moves forward.
- Communicate the cost. Migrations slow down feature work for weeks. Stakeholders need to understand why (build time, crash rate, developer productivity gains) and when they'll see returns.