Meshtastic-Android/docs/koin-migration-plan.md
James Rich d076361c55
refactor: migrate core UI and features to KMP, adopt Navigation 3 (#4750)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
2026-03-10 17:29:47 +00:00

122 lines
9 KiB
Markdown

# Koin Migration Implementation Plan (Annotations & K2 Compiler Plugin)
This document outlines the meticulous, step-by-step strategy for migrating Meshtastic-Android from Hilt (Dagger) to **Koin with Annotations**. This approach leverages the new native **Koin Compiler Plugin (K2)** to automatically generate Koin DSL at compile time, providing a developer experience nearly identical to Hilt/Dagger but with pure, boilerplate-free KMP compatibility. We are targeting Koin 4.2.0-RC1+ and the Koin Compiler Plugin for maximum Compose Multiplatform support and optimal build performance.
## 1. Goal & Objectives
- **Remove Hilt/Dagger completely** from the project.
- **Adopt Koin Annotations** for declarative, compile-time verified DI using the native K2 Compiler Plugin.
- **Eliminate Android*ViewModel Wrappers** by injecting KMP ViewModels (`@KoinViewModel`) directly.
- **Improve Build Times** by replacing Dagger KAPT/KSP with the lightweight, native Koin Compiler Plugin.
- **Maintain Incremental Progress** using the Strangler Fig Pattern.
## 2. Phase 1: Infrastructure Setup
**Objective:** Add Koin Annotations and Koin Compiler Plugin to the build system.
1. **Add Dependencies** in `gradle/libs.versions.toml`:
- Ensure versions are at least Koin `4.2.0-RC1` (or stable when available) and Koin Compiler Plugin.
- Dependencies: `koin-core`, `koin-android`, `koin-annotations`, `koin-compose-viewmodel`.
- Plugins: `io.insert-koin.compiler.plugin`.
2. **Configure Root Compiler Plugin** in `build.gradle.kts` (root or build-logic):
- Ensure the plugin is available and applied in KMP modules (`alias(libs.plugins.koin.compiler)`).
3. **Setup Koin Application** in `MeshUtilApplication.kt`:
- Initialize Koin with `startKoin { androidContext(this@MeshUtilApplication); modules(AppModule().module) }`.
- *Note:* `.module` is an extension property automatically generated by the compiler plugin for classes annotated with `@Module`.
- *Note:* In Koin 4.1+, standard native Context handling is unified, making explicit `androidContext` passing into KMP modules significantly simpler than in Koin 3.x.
## 3. Phase 2: Core Modules Migration (`core:*`)
**Objective:** Replace Hilt modules with Koin Annotated modules.
1. **Annotate Classes**:
- Replace `@Singleton` + `@Inject constructor` with just `@Single`.
- Koin automatically binds implementations to their interfaces if it's the only interface implemented.
- Standard constructor injection requires no explicit `@Inject` annotations—the compiler auto-detects constructors from the class-level scope annotation (`@Single`, `@Factory`, etc.).
2. **Define Koin Modules (`expect` / `actual` Pattern)**:
- KMP Best Practice: In `commonMain`, declare an `expect val platformModule: Module`.
- In each platform source set (e.g., `androidMain`, `iosMain`), implement this with `actual val platformModule: Module = module { includes(AndroidModule().module) }`.
- Use `@Module` and `@ComponentScan("org.meshtastic.core.module")` on these platform-specific classes so the plugin builds the platform dependency graphs correctly.
3. **Bridge Hilt/Koin (Incremental Step)**:
- If a Hilt class needs a Koin dependency, provide a temporary Hilt `@Provides` that fetches from `GlobalContext.get().get()`.
4. **`expect` / `actual` Class Injection**:
- When you have an `expect class` that you want to inject, do *not* annotate the `expect` declaration.
- Instead, annotate each platform's `actual class` with `@Single` or `@Factory`. The compiler plugin will automatically compile-time link the injected interface to the correct platform implementation.
## 4. Phase 3: Feature & ViewModel Migration [COMPLETED]
**Objective:** Migrate ViewModels and eliminate Android-specific wrappers using latest mapping features.
1. **Migrate ViewModels**:
- Replace `@HiltViewModel` with `@KoinViewModel`.
- Move ViewModels to `commonMain` where applicable to share logic across targets.
2. **Update Compose Navigation**:
- Replace `hiltViewModel()` with `koinViewModel()` in `app/navigation/`.
- *Nitty-Gritty:* If using nested Jetpack Navigation graphs, leverage Koin 4.1's `koinNavViewModel()` to replicate Hilt's graph-scoped ViewModels securely.
3. **Compose Previews Integration (Experimental)**:
- Replace dummy Hilt setups in `@Preview` with Koin's `KoinApplicationPreview` to inject dummy modules specifically for rendering Compose previews.
4. **Purge Wrappers**:
- Delete `AndroidMetricsViewModel`, `AndroidRadioConfigViewModel`, etc.
## 5. Phase 4: Advanced Edge Cases (`@AssistedInject` & WorkManager)
**Objective:** Address Dagger-specific advanced injection patterns.
1. **WorkManager & `@HiltWorker`**:
- Add `io.insert-koin:koin-androidx-workmanager` to dependencies.
- Replace `@HiltWorker` and `@AssistedInject` on Workers with `@KoinWorker`.
- Initialize WorkManager factory in `MeshUtilApplication` via `WorkManagerFactory()`.
2. **`@AssistedInject` (Non-Worker classes)**:
- Meshtastic heavily uses AssistedInject for Radio Interfaces (`NordicBleInterface`, `MockInterface`, etc.).
- Replace `@AssistedInject` with Koin's `@Factory` on the class.
- Replace `@Assisted` parameters in the constructor with `@InjectedParam`.
- In Koin Annotations, when injecting this factory, you pass parameters dynamically: `val radio: RadioInterface = get { parametersOf(address) }`.
3. **Dagger Custom `@Qualifier`s**:
- Project uses many custom qualifiers (e.g., `@UiDataStore`, `@MapDataStore`) for DataStore instances.
- Replace these custom annotations with Koin's `@Named("UiDataStore")`.
- Apply `@Named` to both the provided dependency (e.g., inside the `@Module` function) *and* the constructor parameter where it is injected.
4. **Compiler Plugin Multiplatform Benefit**:
- By using the new `io.insert-koin.compiler.plugin`, we completely bypass the old KSP boilerplate. There is no need for `kspCommonMainMetadata` or complex KSP target wiring in KMP modules.
## 6. Phase 5: Testing & Final Cleanup
**Objective:** Complete Hilt eradication and verify tests.
1. **Update Tests**:
- Replace `@HiltAndroidTest` with Koin testing utilities.
- Use `KoinTest` interface and `KoinTestRule` in your Android instrumented tests and Robolectric unit tests to supply mock modules.
2. **Remove Hilt Annotations**:
- Delete `@HiltAndroidApp`, `@AndroidEntryPoint`, `@InstallIn`, etc.
3. **Clean Build Scripts**:
- Remove Hilt plugins and dependencies from all `build.gradle.kts` and `libs.versions.toml`.
4. **Final Verification**:
- Run `./gradlew clean assembleDebug test` to ensure successful compilation and structural integrity.
## 6. Migration Key mappings (Cheat Sheet)
| Hilt/Dagger | Koin Annotations |
| :--- | :--- |
| `@Singleton class X @Inject constructor(...)` | `@Single class X(...)` |
| `@Module` + `@InstallIn` | `@Module` + `@ComponentScan` |
| `@Provides` | `@Single` or `@Factory` on a module function |
| `@Binds` | Automatic (or `@Single` on implementation) |
| `@HiltViewModel` | `@KoinViewModel` |
| `hiltViewModel()` | `koinViewModel()` or `koinNavViewModel()` |
| `Lazy<T>` | `Lazy<T>` (Native Kotlin) |
| Dummy `@Preview` ViewModels | `KoinApplicationPreview { ... }` |
## 7. Troubleshooting & Lessons Learned (March 2026)
### Koin K2 Compiler Plugin Signature Collision
During Phase 3, we discovered a bug in the Koin K2 Compiler Plugin (v0.3.0) where multiple `@Single` provider functions in the same module with identical JVM signatures (e.g., several `DataStore` providers taking `(Context, CoroutineScope)`) were incorrectly mapped to the same internal lambda. This caused `ClassCastException` at runtime (e.g., `LocalStats` being cast to `Preferences`).
**Solution:** Split providers with identical signatures into separate `@Module` classes. This forces the compiler plugin to generate unique mapping classes, preventing the collision.
### Circular Dependencies in Koin 4.2.0
True circular dependencies (e.g., `Service -> InterfaceFactory -> Spec -> Factory -> Service`) can cause `StackOverflowError` during graph resolution even with `Lazy<T>` injection if the `Lazy` is accessed too early (e.g., in a coroutine launched from `init`).
**Solution:** Break cycles by passing dependencies as function parameters instead of constructor parameters where possible (e.g., passing `service` to `InterfaceSpec.createInterface(...)`).
### Robolectric Tests & KoinApplicationAlreadyStartedException
When running Robolectric tests, `MeshUtilApplication` is recreated for each test. If `startKoin` is called in `onCreate` but not stopped, subsequent tests will fail with `org.koin.core.error.KoinApplicationAlreadyStartedException`.
**Solution:** Explicitly call `org.koin.core.context.stopKoin()` in the application's `onTerminate` method, which is invoked by Robolectric during teardown.
---
**Status:** **Fully Completed & Stable.**
- Hilt completely removed.
- Koin Annotations and K2 Compiler Plugin fully integrated.
- All DataStore and Circular Dependency issues resolved.
- App verified stable on device via Logcat audit.