Files
Meshtastic-Android/docs/archive/koin-migration-plan.md

9.0 KiB

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 @Qualifiers:
    • 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.