Skip to content

Module 10: Legacy Project Guide & Kotlin Migration

Objective: Get a systematic guide for taking over legacy Java Android projects, learn to identify tech stacks, understand common "technical debt," and plan incremental Kotlin migration.

1. Your First Week Taking Over a Legacy Project

1.1 Project Health Checklist

After receiving the project, follow this sequence:

Step 1: Can it compile and run?

[] Clone the code
[] Check JDK version requirements (gradle-wrapper.properties -> Gradle version -> JDK version)
[] Configure local.properties (SDK path)
[] Run Gradle Sync
[] Try building a debug version: ./gradlew assembleDebug
[] Run on an emulator or physical device
[] Record all compilation errors and warnings

Step 2: Basic project information

ItemFile / LocationWhat to Look For
Package nameAndroidManifest.xmlApp's unique identifier
Minimum SDKbuild.gradle -> minSdkRange of supported devices
Target SDKbuild.gradle -> targetSdkBehavior adaptation
Version numberbuild.gradle -> versionCode/NameCurrent version
Build typesbuild.gradle -> buildTypesdebug/release configuration
Dependency listbuild.gradle -> dependenciesThird-party libraries
Permission listAndroidManifest.xmlWhich permissions are requested
ProGuard rulesproguard-rules.proWhich classes are kept
Signing configbuild.gradle -> signingConfigsRelease keystore location
Build variantsbuild.gradle -> productFlavorsChannel/brand packages

Step 3: Tech stack identification

Run the following command to get a quick overview of dependencies:

bash
./gradlew app:dependencies --configuration releaseRuntimeClasspath

1.2 Identifying the Tech Stack Era

Determine the project's era and tech stack from its dependencies and code style:

IndicatorEraDescription
android.support.* package namesPre-2018Uses Support Library (outdated)
androidx.* package names2018+Migrated to AndroidX
com.jakewharton:butterknife2015-2019ButterKnife view binding
io.reactivex / rxjava2016-2020RxJava reactive programming
dagger / com.google.dagger2016+Dagger dependency injection
com.google.dagger:hilt2020+Hilt dependency injection
MVP pattern (Contract + Presenter)2016-2019Common architecture
MVVM + LiveData + ViewModel2019+Modern architecture
com.squareup.okhttp2014-2016OkHttp 2.x (outdated)
com.squareup.okhttp32016+OkHttp 3.x/4.x (modern)
retrofit2016+Retrofit (mainstream)
com.loopj.android:android-async-http2012-2015No longer maintained
com.android.volley2013+Volley
greenrobot:eventbus2015-2019EventBus event bus
com.j256.ormlite2013-2017OrmLite (outdated)
android.arch.persistence.room2017-2018Early Room version
androidx.room2018+Room (AndroidX version)

1.3 Support Library vs AndroidX

This is the first big issue you'll encounter when taking over a legacy project. In 2018, Google refactored the Support Library into AndroidX:

java
// Old Support Library (pre-2018)
import android.support.v7.app.AppCompatActivity;
import android.support.v4.app.Fragment;
import android.support.v7.widget.RecyclerView;
import android.support.design.widget.Snackbar;

// New AndroidX (post-2018)
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.snackbar.Snackbar;

Package name mapping (common ones):

Support LibraryAndroidX
android.support.v7.app.AppCompatActivityandroidx.appcompat.app.AppCompatActivity
android.support.v4.app.Fragmentandroidx.fragment.app.Fragment
android.support.v7.widget.RecyclerViewandroidx.recyclerview.widget.RecyclerView
android.support.v7.widget.LinearLayoutManagerandroidx.recyclerview.widget.LinearLayoutManager
android.support.design.widget.*com.google.android.material.*
android.support.constraint.ConstraintLayoutandroidx.constraintlayout.widget.ConstraintLayout
android.support.v4.widget.SwipeRefreshLayoutandroidx.swiperefreshlayout.widget.SwipeRefreshLayout

Migration: Android Studio provides automatic migration: Refactor -> Migrate to AndroidX.

Recommendation: If the project is still using the Support Library, prioritize migrating to AndroidX. But make sure you have regression testing in place.

2. Common "Legacy Baggage" and How to Handle It

2.1 Dealing with Outdated Libraries

Outdated LibraryReplacementMigration Difficulty
ButterKnifeViewBindingLow (replace one by one)
EventBusLiveData / SharedFlowMedium (requires refactoring communication)
RxJava 1.xRxJava 3.x or Kotlin CoroutinesHigh
OrmLiteRoomHigh (data models differ)
PicassoGlide or CoilLow (similar APIs)
VolleyOkHttp + RetrofitMedium
Android AnnotationsStandard annotations + HiltHigh
android-percent-supportConstraintLayout / FlexboxLayoutLow
com.android.support:multidexandroidx.multidex:multidexLow

2.2 Replacing ButterKnife with ViewBinding

java
// Old code: ButterKnife
public class MainActivity extends AppCompatActivity {
    @BindView(R.id.tv_title) TextView titleView;
    @BindView(R.id.btn_submit) Button submitBtn;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.bind(this);

        titleView.setText("Hello");
        submitBtn.setOnClickListener(v -> { /* ... */ });
    }

    @OnClick(R.id.btn_submit)
    void onSubmit() {
        // ...
    }
}
java
// New code: ViewBinding
public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        binding.tvTitle.setText("Hello");
        binding.btnSubmit.setOnClickListener(v -> { /* ... */ });
    }
}

2.3 Replacing AsyncTask with Executor / Kotlin Coroutines

AsyncTask was deprecated in API 30 and removed on newer versions. Legacy projects often contain:

java
// ❌ Old code: AsyncTask
new AsyncTask<String, Void, String>() {
    @Override protected String doInBackground(String... params) {
        return networkRequest(params[0]);
    }
    @Override protected void onPostExecute(String result) {
        textView.setText(result);
    }
}.execute(url);
java
// ✅ New code: ExecutorService + Handler (Java)
ExecutorService executor = Executors.newSingleThreadExecutor();
Handler handler = new Handler(Looper.getMainLooper());

executor.execute(() -> {
    final String result = networkRequest(url);
    handler.post(() -> textView.setText(result));
});
kotlin
// ✅ New code: Kotlin Coroutines
lifecycleScope.launch {
    val result = withContext(Dispatchers.IO) { networkRequest(url) }
    textView.text = result
}

2.4 Replacing EventBus with LiveData

java
// Old code: EventBus
// Post event
EventBus.getDefault().post(new UserLoginEvent(user));

// Receive event
@Subscribe(threadMode = ThreadMode.MAIN)
public void onUserLogin(UserLoginEvent event) {
    updateUI(event.getUser());
}

// Register/unregister
EventBus.getDefault().register(this);
EventBus.getDefault().unregister(this);
java
// New code: LiveData (via shared ViewModel)
// Shared ViewModel
public class AppViewModel extends ViewModel {
    private final MutableLiveData<User> currentUser = new MutableLiveData<>();

    public void login(User user) {
        currentUser.setValue(user);
    }

    public LiveData<User> getCurrentUser() {
        return currentUser;
    }
}

// Observe (in any Fragment/Activity)
appViewModel.getCurrentUser().observe(this, user -> {
    updateUI(user);
});

2.5 Replacing findViewById with ViewBinding

Legacy projects are full of findViewById, a common source of null-pointer and cast errors:

java
// ❌ Old code
TextView tvTitle = findViewById(R.id.tv_title);
Button btnSubmit = findViewById(R.id.btn_submit);
java
// ✅ New code
ActivityMainBinding binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
binding.tvTitle.setText("Hello");
binding.btnSubmit.setOnClickListener(v -> { /* ... */ });

Migration tip: Android Studio offers Refactor -> Migrate to View Binding to bulk-replace simple cases, but complex custom views still need manual work.

3. Incremental Modernization Strategy

3.1 Priority Matrix

High Impact  | Upgrade targetSdk     Fix crashes
             | Security patches      Performance optimization
             |-------------------------------------------
Low Impact   | Replace outdated      Code style unification
             |   UI components       Add comments
             | Upgrade dependency versions
             +-------------------------------------------
               Low Risk                High Risk

Recommended order:

  1. First priority: Get it compiling and running, fix crashes
  2. Second priority: Upgrade targetSdk (Google Play requires this)
  3. Third priority: Security patches, critical dependency updates
  4. Fourth priority: Architecture improvements, code quality enhancements
  5. Fifth priority: Kotlin migration (if you decide to migrate)

3.2 targetSdk Upgrade Considerations

Each targetSdk upgrade requires checking for behavior changes:

Upgrade toKey Changes
API 26 (Android 8)Background service restrictions, notification channels required
API 28 (Android 9)Non-SDK interface restrictions, foreground service permissions
API 29 (Android 10)Scoped Storage
API 30 (Android 11)Enforced Scoped Storage, package visibility restrictions
API 31 (Android 12)Must declare android:exported, exact alarm restrictions
API 33 (Android 13)Granular media permissions, notification runtime permission
API 34 (Android 14)Foreground service type declaration, exact alarms disabled by default
API 35 (Android 15)16 KB page size support (on some new devices)

3.3 Gradle / AGP / JDK Compatibility Matrix

AGP version   Required Gradle   Compatible JDK
8.1.x         8.0+              17
8.2.x         8.2+              17
8.3.x         8.4+              17
8.4.x         8.6+              17
8.5.x         8.7+              17-21

Recommended upgrade order: JDK -> Gradle -> AGP -> third-party dependencies. Upgrade one step at a time and verify the build before continuing.

4. Java and Kotlin Coexistence

4.1 Using Kotlin in a Java Project

Gradle natively supports mixed Java/Kotlin compilation:

groovy
// app/build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android' version '1.9.22'  // Add Kotlin plugin
}

android {
    // ...
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = '17'
    }
}

In mixed projects, Java and Kotlin files live in the same directory:

src/main/java/com/example/app/
+-- MainActivity.java          # Java file
+-- UserDetailActivity.kt      # Kotlin file (coexisting)
+-- model/
|   +-- User.java              # Java data class
|   +-- Product.kt             # Kotlin data class
+-- util/
    +-- DateUtils.java         # Java utility class
    +-- StringUtils.kt         # Kotlin extension functions

4.2 Calling Kotlin from Java

kotlin
// Kotlin file
class UserHelper {
    fun getUserName(): String = "John"

    @JvmStatic
    fun getDefaultUser(): User = User("Default User")
}

object AppConfig {
    const val API_URL = "https://api.example.com"
}
java
// Calling from Java
UserHelper helper = new UserHelper();
String name = helper.getUserName();  // Regular method

User defaultUser = UserHelper.getDefaultUser();  // @JvmStatic method

String apiUrl = AppConfig.API_URL;  // const in object

4.3 Calling Java from Kotlin

java
// Java file
public class UserManager {
    private String currentName;

    public String getCurrentName() { return currentName; }
    public void setCurrentName(String name) { this.currentName = name; }

    public static UserManager getInstance() {
        return instance;
    }
}
kotlin
// Calling from Kotlin
val manager = UserManager.getInstance()
manager.currentName = "John"  // Automatically recognizes getter/setter as properties
val name = manager.currentName

4.4 Java/Kotlin Interop Pitfalls

kotlin
// 1. Platform types (String! coming from Java)
// A Java method returning String is seen as String! in Kotlin and may NPE
val name: String = javaManager.name  // may NPE at runtime
// Fix: declare nullability explicitly on the Kotlin side,
//      or add @Nullable/@NotNull in Java

// 2. SAM conversion
// Java interfaces with a single abstract method auto-convert to lambdas
button.setOnClickListener { doSomething() }

// 3. Java generics appear invariant in Kotlin
val list: MutableList<String> = javaObject.strings
list.add("x")  // may fail at compile/run time if Java returns List<String>

Frontend analogy: Java/Kotlin interop is like TypeScript consuming a JavaScript library: TS introduces any/optional types, so you must pay extra attention to runtime behavior and nulls.

5. Kotlin Migration Guide

5.1 When to Migrate?

Good candidates for migration:

  • The project is planned for long-term maintenance
  • The team has Kotlin experience or is willing to learn
  • You need to adopt Jetpack Compose or other modern components
  • Google's official documentation and the community have fully shifted to Kotlin

Not good candidates for migration:

  • The project is about to be sunset
  • The team has no Kotlin knowledge at all
  • You're only fixing bugs, not adding new features
Step 1: New Kotlin files
    |  (write new features in Kotlin)
Step 2: Utility classes and constants
    |  (migrate simple, standalone files first)
Step 3: Data models (Model / Entity)
    |  (data class replaces POJO, significantly less code)
Step 4: Adapters and ViewHolders
    |  (lots of boilerplate, Kotlin simplifies noticeably)
Step 5: Fragments and Activities
    |  (migrate screen by screen)
Step 6: ViewModels and Repositories
    |  (business logic layer)
Step 7: Core framework classes (Application, BaseActivity, etc.)

5.3 Kotlin Quick Reference Cheat Sheet

kotlin
// Java POJO -> Kotlin data class
// Java: 50+ lines of code
public class User {
    private String name;
    private int age;
    // getter/setter/toString/equals/hashCode...
}

// Kotlin: 1 line
data class User(val name: String, val age: Int)


// Java findViewById -> Kotlin property access
// Java
TextView tv = findViewById(R.id.tv_title);
tv.setText("Hello");

// Kotlin (with ViewBinding)
binding.tvTitle.text = "Hello"


// Java anonymous inner class -> Kotlin lambda
// Java
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        doSomething();
    }
});

// Kotlin
button.setOnClickListener { doSomething() }


// Java null checks -> Kotlin safe calls
// Java
if (user != null && user.getAddress() != null) {
    String city = user.getAddress().getCity();
}

// Kotlin
val city = user?.address?.city


// Java switch -> Kotlin when
// Java
switch (status) {
    case 0: return "pending";
    case 1: return "active";
    case 2: return "completed";
    default: return "unknown";
}

// Kotlin
return when (status) {
    0 -> "pending"
    1 -> "active"
    2 -> "completed"
    else -> "unknown"
}


// Java for loop -> Kotlin collection operations
// Java
List<String> names = new ArrayList<>();
for (User user : users) {
    if (user.getAge() > 18) {
        names.add(user.getName());
    }
}

// Kotlin
val names = users.filter { it.age > 18 }.map { it.name }


// Java try-catch -> Kotlin runCatching
// Java
String result;
try {
    result = riskyOperation();
} catch (Exception e) {
    result = "default";
}

// Kotlin
val result = runCatching { riskyOperation() }.getOrDefault("default")

5.4 Android Studio Auto-Conversion

Android Studio includes a built-in Java -> Kotlin conversion tool:

  1. Open the Java file
  2. Code -> Convert Java File to Kotlin File (Ctrl+Alt+Shift+K)
  3. Review the converted code (not always accurate, manual adjustments needed)

Note: Auto-converted code may not follow idiomatic Kotlin style, but it's a useful starting point.

5.5 R8 / ProGuard After Kotlin Migration

proguard
# Common keep rules for Kotlin reflection and coroutines
-keep class kotlin.** { *; }
-keep class kotlinx.coroutines.** { *; }
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes Exceptions
-keepclassmembers class * implements android.os.Parcelable { *; }

Common Release crash:

java.lang.NoSuchMethodError: No virtual method ...
# Cause: R8 over-obfuscated Kotlin stdlib or reflection calls
# Fix: add -keep rules and test with R8 full mode during build

6. Dependency Injection in Legacy Projects

6.1 Identifying DI Frameworks

java
// Dagger 2 (most common)
@Component(modules = {AppModule.class, NetworkModule.class})
public interface AppComponent {
    void inject(MainActivity activity);
}

@Module
public class NetworkModule {
    @Provides
    @Singleton
    OkHttpClient provideOkHttpClient() {
        return new OkHttpClient.Builder().build();
    }

    @Provides
    Retrofit provideRetrofit(OkHttpClient client) {
        return new Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build();
    }
}

// Usage
@Inject Retrofit retrofit;

// Hilt (simplified Dagger, 2020+)
@HiltAndroidApp
public class MyApplication extends Application { }

@AndroidEntryPoint
public class MainActivity extends AppCompatActivity {
    @Inject UserRepository repository;
}

@Module
@InstallIn(SingletonComponent.class)
public class NetworkModule {
    @Provides
    @Singleton
    static OkHttpClient provideOkHttpClient() {
        return new OkHttpClient.Builder().build();
    }
}

7. Complete Takeover Workflow Summary

Day 1: Set up environment -> Compile and run -> Gather basic info
         |
Day 2: Identify tech stack -> Understand architecture -> List dependencies
         |
Day 3: Walk through core flows -> Map screen structure -> Trace data flow
         |
Week 1: Fix compilation issues -> Run tests -> Document technical debt
         |
Week 2: Create improvement plan -> Set priorities -> Begin incremental improvements

8. Useful Tools

ToolPurpose
Android Studio Layout InspectorInspect UI hierarchy in real time
Android Studio Database InspectorView database contents in real time
Stetho / FlipperNetwork request and database debugging
LeakCanaryMemory leak detection
Charles / mitmproxyNetwork traffic capture
jadxAPK decompilation to view source code
APK AnalyzerAnalyze APK size composition

Frontend Developer Cheat Sheet

Frontend Project TakeoverAndroid Project TakeoverNotes
Read package.jsonRead build.gradleUnderstand dependencies and configuration
Inspect node_modules./gradlew dependenciesDependency tree
npm auditManually check outdated dependenciesSecurity audit
Check ESLint configCheck Lint configCode quality
Read routing filesRead AndroidManifest.xmlScreen structure
Tech debt assessmentTech stack era identificationEvaluate modernization level
jQuery -> React migrationJava -> Kotlin migrationIncremental migration strategy
Webpack -> Vite upgradeGradle version upgradeBuild tool upgrade
TypeScript strict migrationKotlin null-safety migrationBoth need incremental rollout

Congratulations!

After completing all 10 modules, you now have:

  • The ability to set up an Android Java development environment
  • The ability to read and understand Java Android code
  • The ability to modify and maintain legacy project features (UI, networking, data storage, etc.)
  • The ability to identify project tech stacks and architecture patterns
  • The ability to troubleshoot issues using debugging tools
  • The ability to plan incremental modernization and Kotlin migration

Recommended next steps:

  1. Get your hands on a real legacy project
  2. Try to compile and run it from scratch, and document the issues you encounter
  3. Try fixing a small bug or adding a small feature
  4. Join Android developer communities (e.g., Stack Overflow, Reddit r/androiddev)

Good luck on your Android maintenance journey!

A Java Android development tutorial for frontend developers