Skip to main content

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.

  1. 01

    Enable Kotlin in the module

    apply plugin: "org.jetbrains.kotlin.android" — adds the Kotlin stdlib and compiler.

  2. 02

    Pick a file with few callers

    Models, sealed enums, utility classes, and POJOs are perfect starters.

  3. 03

    Right-click → Convert Java File to Kotlin File

    Android Studio runs J2K. Review every TODO comment it leaves.

  4. 04

    Re-run unit tests for the module

    Catches J2K mistakes around platform types (T! → expected non-null vs nullable).

  5. 05

    Hand-tune the result

    J2K is conservative: replace getter/setter with property syntax, lift companion objects, use data class for POJOs.

  6. 06

    Update Java callers if needed

    @JvmStatic, @JvmField, @JvmOverloads keep Java call sites tidy.

  7. 07

    Commit one file per PR while learning

    Once your team is fluent, bundle them by feature.

Pitfalls

PitfallFix
Platform type T! causes runtime NPEExplicitly annotate with @NonNull / @Nullable in Java, or check at the Kotlin boundary
Java code calls getName() after Kotlin migrated to propertyAdd @get:JvmName("getName") or just keep the method name
Static fields become companion const valAdd @JvmField for direct field access from Java
Java callers of vararg fn(args: String) breakAdd @JvmName or wrap with overload
Default arguments lost from JavaUse @JvmOverloads on the Kotlin function

J2K cleanup checklist

After every conversion, scan for:

  • !! operators (J2K loves them — replace with safe calls or explicit nullability).
  • Companion blocks containing only constants → const val at file level.
  • Manual equals/hashCodedata class.
  • Object declarations passed as listeners → fun interface + lambda.
  • Singleton patterns → 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.

  1. 01

    Add Compose to the module

    buildFeatures.compose = true, composeOptions, compose-bom + ui + material3 + tooling dependencies.

  2. 02

    Identify a leaf view

    Custom Views or small reusable layouts (badges, cards, empty states) are ideal first conversions.

  3. 03

    Wrap with ComposeView in the existing XML

    Embed Compose into the current Fragment without a full rewrite.

  4. 04

    Convert one row / cell / card at a time

    A RecyclerView item with 3-5 views is the canonical first target.

  5. 05

    Migrate the list to LazyColumn

    Once items are composable, replace RecyclerView with LazyColumn — typically a 70% LoC reduction.

  6. 06

    Replace the host Fragment with a setContent { }

    Once every child is composable, drop the XML host entirely.

  7. 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

ViewsCompose
LinearLayout(vertical)Column
LinearLayout(horizontal)Row
FrameLayoutBox
ConstraintLayoutConstraintLayout (or just Row/Column for simple layouts)
RecyclerView + AdapterLazyColumn / LazyRow with items()
ViewPager2HorizontalPager (Compose Foundation)
Fragment@Composable screen + NavHost route
findViewByIdhoisted state + parameters
View.OnClickListenerModifier.clickable { }
TextViewText
EditTextTextField
ToolbarTopAppBar (material3)
BottomNavigationViewNavigationBar (material3)
DialogFragmentDialog + AlertDialog
Snackbar.makeSnackbarHostState.showSnackbar

Gotchas

  • Theme bridging: Material Views themes don't auto-flow into Compose. Use MdcTheme() (Accompanist, deprecated) or hand-map XML attrs to a MaterialTheme { } 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 XML fitsSystemWindows with Compose insets.
  • Saved state: rememberSaveable for everything that should survive process death. View state restoration via BundleSavedStateRegistry works 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

LibraryKSP 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

  1. 01

    Add KSP plugin to root build

    id("com.google.devtools.ksp") version "2.0.21-1.0.27" — match Kotlin version.

  2. 02

    Apply ksp plugin to feature modules

    plugins { id("com.google.devtools.ksp") }

  3. 03

    Replace kapt(...) with ksp(...) in deps

    kapt("androidx.room:room-compiler:X") → ksp("androidx.room:room-compiler:X")

  4. 04

    Remove kapt configuration

    Delete the kapt { } block and the kotlin-kapt plugin once nothing remains.

  5. 05

    Run a clean build

    ./gradlew clean assembleDebug — compare build time before/after.

  6. 06

    Verify generated code

    Look in build/generated/ksp/ — generated classes should match what kapt produced (in build/generated/source/kapt/).

Common errors

ErrorCauseFix
unresolved reference: Dao_ImplOne module still uses kapt for Room, another uses kspMigrate all Room consumers together
Slow first build after migrationKSP rebuilds cacheNormal — subsequent builds will be fast
Cannot find symbol …_FactoryHilt processor not migratedReplace 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.

  1. 01

    Add Hilt plugin and dependencies

    hilt-android, hilt-android-gradle-plugin, ksp(hilt-android-compiler), hilt-navigation-compose.

  2. 02

    Annotate Application with @HiltAndroidApp

    Generates the top-level component, replacing your custom AppComponent.

  3. 03

    Migrate one feature Activity / Fragment

    Replace AndroidInjection with @AndroidEntryPoint. ViewModels get @HiltViewModel.

  4. 04

    Port Modules

    Add @InstallIn(SingletonComponent::class) (or the right scope). Most @Module classes work unchanged.

  5. 05

    Replace custom @Components with predefined scopes

    AppComponent → SingletonComponent, ActivityComponent → ActivityComponent, etc.

  6. 06

    Migrate tests to @HiltAndroidTest

    Use TestInstallIn to swap modules. Replace custom test components.

Mapping

Dagger 2Hilt
@Component(modules = [...])@HiltAndroidApp on Application
@Subcomponent per Activity@AndroidEntryPoint
AndroidInjector boilerplateAuto-generated
Custom ViewModelFactory@HiltViewModel + hiltViewModel() in Compose
DispatchingAndroidInjectorRemoved 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

  1. 01

    Replace MutableLiveData with MutableStateFlow

    val state = MutableStateFlow(initial) — same .value getter, .value setter.

  2. 02

    Replace .observe(this) { } in Fragments

    Use viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(STARTED) { vm.state.collect { ... } } }

  3. 03

    Replace .observeAsState() in Compose

    val state by vm.state.collectAsStateWithLifecycle()

  4. 04

    Replace LiveData transforms

    Transformations.map { x -> y } → state.map { it.y }.stateIn(scope, SharingStarted.Eagerly, initial)

  5. 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

RxJavaCoroutines / Flow
Single<T>suspend fun foo(): T
Maybe<T>suspend fun foo(): T?
Completablesuspend fun foo(): Unit
Observable<T>Flow<T>
Flowable<T> (backpressure)Flow<T> (cooperative cancellation)
BehaviorSubjectMutableStateFlow
PublishSubjectMutableSharedFlow(replay = 0)
ReplaySubjectMutableSharedFlow(replay = N)
subscribeOn(Schedulers.io())withContext(Dispatchers.IO) { }
observeOn(AndroidSchedulers.mainThread())Dispatchers.Main (already main for collect)
map { }map { }
flatMap { }flatMapConcat { } / flatMapMerge { }
switchMap { }flatMapLatest { }
combineLatestcombine
zipzip
throttleFirstsample / custom
debouncedebounce
DisposableJob (cancel with .cancel())
CompositeDisposableCoroutineScope + structured concurrency

Strategy

Migrate from the edges inward:

  1. Convert public ViewModel APIs that return Single<T> to suspend fun. The Compose UI calls them inside LaunchedEffect / lifecycleScope.
  2. Convert Repository methods next.
  3. Leave deepest Rx chains in place initially — wrap them with await() / asFlow() from kotlinx-coroutines-rx3.
  4. Finally, replace the Rx core: when no Single/Observable remain 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

  • AsyncTask was 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

  1. 01

    Rename settings.gradle → settings.gradle.kts

    And convert Groovy syntax: rootProject.name = "..." (no semicolons), include(":app").

  2. 02

    Convert top-level build.gradle next

    plugins { id("com.android.application") version "X" apply false } — version goes in strings.

  3. 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.

  4. 04

    Convert dependencies to function calls

    implementation "androidx.core:core-ktx:1.13.1" → implementation("androidx.core:core-ktx:1.13.1")

  5. 05

    Move versions to libs.versions.toml

    Use the Version Catalog — works identically from Groovy and Kotlin DSL, and is required for convention plugins.

  6. 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

GroovyKotlin DSL
apply plugin: 'kotlin-android'plugins { id("org.jetbrains.kotlin.android") }
compileSdkVersion 35compileSdk = 35
minSdkVersion 24minSdk = 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

  1. 01

    Add datastore-preferences dependency

    androidx.datastore:datastore-preferences:1.1.X

  2. 02

    Create a single Context.dataStore

    val Context.prefs by preferencesDataStore(name = "settings")

  3. 03

    Read flows for each key

    val theme: Flow<String> = ctx.prefs.data.map { it[stringPreferencesKey("theme")] ?: "system" }

  4. 04

    Write with edit { }

    ctx.prefs.edit { it[stringPreferencesKey("theme")] = "dark" }

  5. 05

    Use SharedPreferencesMigration during transition

    preferencesDataStore(name = "settings", produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "legacy_prefs")) })

  6. 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 APIWorkManager replacement
JobScheduler + JobServiceWorkManager + Worker / CoroutineWorker
AlarmManager (deferrable)WorkManager with setInitialDelay
AlarmManager (exact, user-facing)Stays as AlarmManager (setExactAndAllowWhileIdle)
SyncAdapterWorkManager periodic + Constraints.requiredNetworkType
Service (background, deferrable)WorkManager
Service (user-facing)Foreground service (with WorkManager setForeground)

Steps

  1. 01

    Identify the work

    Is it user-visible (foreground service), exact-time (AlarmManager), or deferrable (WorkManager)?

  2. 02

    Rewrite the job as CoroutineWorker

    override suspend fun doWork(): Result — return success/retry/failure.

  3. 03

    Define constraints

    NetworkType, requiresCharging, requiresBatteryNotLow — chained constraints replace lots of legacy checks.

  4. 04

    Enqueue

    WorkManager.getInstance(ctx).enqueueUniqueWork(name, ExistingWorkPolicy.KEEP, request)

  5. 05

    Migrate state

    Cancel any old JobScheduler / AlarmManager jobs from the old codepath on first launch after upgrade.


Universal migration principles

  1. Lock baselines first. You cannot prove a migration was worth it without before/after numbers.
  2. Migrate one module at a time. Reversibility beats velocity.
  3. Bridge with adapters during the long tail. asFlow(), asLiveData(), await(), rxSingle { } — there's an interop bridge for every transition.
  4. Don't mix concerns. Migrate Java→Kotlin or Views→Compose in a single PR, never both.
  5. 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.
  6. 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.

Where to next