feat: migrate DI from Dagger/Anvil to Metro

Replace Dagger 2 + Anvil DI infrastructure with Metro framework.

Changes:
- Add AppGraph.kt: Metro @DependencyGraph replacing Dagger AppComponent
- Remove AppComponent.kt, ActivityKey.kt, TrmnlAppComponentFactory.kt
- Update all DI modules (CircuitModule, NetworkModule, DataStoreModule,
  WorkerModule) from Dagger @Module objects to Metro @ContributesTo interfaces
- Update ApplicationContext qualifier to use dev.zacsweers.metro.Qualifier
- Migrate TrmnlDisplayMirrorApp to MetroApplication pattern
- Update MainActivity: ContributesMultibinding → ContributesIntoMap
- Update all presenter files: dagger.assisted.* → dev.zacsweers.metro.*
- Update all data/work files: javax.inject.Inject → dev.zacsweers.metro.Inject
- Remove android:appComponentFactory from manifest (MetroX provides it)

Closes #273
Closes #274
Closes #275
Closes #276
This commit is contained in:
Hossain Khan
2026-04-03 08:27:29 -04:00
parent acc1f6d05a
commit e1c91ba4e2
28 changed files with 111 additions and 230 deletions
+1 -3
View File
@@ -6,7 +6,6 @@
<application
android:name=".TrmnlDisplayMirrorApp"
android:appComponentFactory=".di.TrmnlAppComponentFactory"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -17,8 +16,7 @@
android:theme="@style/Theme.App"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="36"
android:enableOnBackInvokedCallback="true"
tools:replace="android:appComponentFactory">
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -19,8 +19,10 @@ import com.slack.circuit.foundation.rememberCircuitNavigator
import com.slack.circuit.overlay.ContentWithOverlays
import com.slack.circuit.sharedelements.SharedElementTransitionLayout
import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory
import com.squareup.anvil.annotations.ContributesMultibinding
import ink.trmnl.android.di.ActivityKey
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.android.ActivityKey
import ink.trmnl.android.di.AppScope
import ink.trmnl.android.di.ApplicationContext
import ink.trmnl.android.ui.display.TrmnlMirrorDisplayScreen
@@ -30,7 +32,6 @@ import ink.trmnl.android.work.TrmnlImageUpdateManager
import ink.trmnl.android.work.TrmnlWorkScheduler.Companion.IMAGE_REFRESH_ONETIME_WORK_NAME
import ink.trmnl.android.work.TrmnlWorkScheduler.Companion.IMAGE_REFRESH_PERIODIC_WORK_NAME
import timber.log.Timber
import javax.inject.Inject
/**
* Main activity for the TRMNL app.
@@ -39,7 +40,7 @@ import javax.inject.Inject
* It can function as either a mirror for existing TRMNL devices or as a
* standalone TRMNL display connected directly to BYOS servers.
*/
@ContributesMultibinding(AppScope::class, boundType = Activity::class)
@ContributesIntoMap(AppScope::class, binding = binding<Activity>())
@ActivityKey(MainActivity::class)
class MainActivity
@Inject
@@ -3,23 +3,23 @@ package ink.trmnl.android
import android.app.Application
import android.util.Log
import androidx.work.Configuration
import ink.trmnl.android.di.AppComponent
import ink.trmnl.android.work.TrmnlWorkerFactory
import dev.zacsweers.metro.createGraphFactory
import dev.zacsweers.metrox.android.MetroAppComponentProviders
import dev.zacsweers.metrox.android.MetroApplication
import ink.trmnl.android.di.AppGraph
import timber.log.Timber
import javax.inject.Inject
/**
* Application class for the app with key initializations.
*/
class TrmnlDisplayMirrorApp :
Application(),
MetroApplication,
Configuration.Provider {
private val appComponent: AppComponent by lazy { AppComponent.create(this) }
private val appGraph by lazy { createGraphFactory<AppGraph.Factory>().create(this) }
fun appComponent(): AppComponent = appComponent
@Inject
lateinit var workerFactory: TrmnlWorkerFactory
override val appComponentProviders: MetroAppComponentProviders
get() = appGraph
override val workManagerConfiguration: Configuration
get() {
@@ -27,14 +27,13 @@ class TrmnlDisplayMirrorApp :
return Configuration
.Builder()
.setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.WARN)
.setWorkerFactory(workerFactory)
.setWorkerFactory(appGraph.workerFactory)
.build()
}
override fun onCreate() {
super.onCreate()
installLoggingTree()
appComponent.inject(this)
}
private fun installLoggingTree() {
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dev.zacsweers.metro.Inject
import ink.trmnl.android.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.time.Instant
import javax.inject.Inject
private val Context.imageDataStore: DataStore<Preferences> by preferencesDataStore(
name = "trmnl_image_metadata",
@@ -8,9 +8,10 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.optional.SingleIn
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC
import ink.trmnl.android.data.AppConfig.TRMNL_API_SERVER_BASE_URL
import ink.trmnl.android.di.AppScope
@@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
/**
* DataStore for managing TRMNL device configuration.
@@ -2,7 +2,8 @@ package ink.trmnl.android.data
import com.slack.eithernet.ApiResult
import com.slack.eithernet.exceptionOrNull
import com.squareup.anvil.annotations.optional.SingleIn
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.di.AppScope
import ink.trmnl.android.model.SupportedDeviceModel
import ink.trmnl.android.model.TrmnlDeviceConfig
@@ -20,7 +21,6 @@ import ink.trmnl.android.util.AndroidDeviceInfoProvider
import ink.trmnl.android.util.HTTP_500
import ink.trmnl.android.util.isHttpOk
import timber.log.Timber
import javax.inject.Inject
/**
* Repository class responsible for fetching and mapping display data.
@@ -2,7 +2,8 @@ package ink.trmnl.android.data
import com.slack.eithernet.ApiResult
import com.slack.eithernet.exceptionOrNull
import com.squareup.anvil.annotations.optional.SingleIn
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.di.AppScope
import ink.trmnl.android.model.TrmnlDeviceConfig
import ink.trmnl.android.network.TrmnlUserApiService
@@ -11,7 +12,6 @@ import ink.trmnl.android.network.model.TrmnlUser
import ink.trmnl.android.network.util.constructApiUrl
import ink.trmnl.android.util.AndroidDeviceInfoProvider
import timber.log.Timber
import javax.inject.Inject
/**
* Repository class for user-level TRMNL API operations.
@@ -5,6 +5,7 @@ import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import com.squareup.moshi.Moshi
import dev.zacsweers.metro.Inject
import ink.trmnl.android.di.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -13,7 +14,6 @@ import java.io.File
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Locale
import javax.inject.Inject
/**
* Handles exporting refresh logs to a JSON file and sharing it via Android's share intent.
@@ -2,7 +2,8 @@ package ink.trmnl.android.data.log
import android.content.Context
import androidx.datastore.core.DataStore
import com.squareup.anvil.annotations.optional.SingleIn
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.data.AppConfig.MAX_LOG_ENTRIES
import ink.trmnl.android.data.HttpResponseMetadata
import ink.trmnl.android.di.AppScope
@@ -12,7 +13,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
/**
* Manages logs related to terminal refresh operations.
@@ -1,12 +0,0 @@
package ink.trmnl.android.di
import android.app.Activity
import dagger.MapKey
import kotlin.reflect.KClass
/**
* A Dagger multi-binding key used for registering a [Activity] into the top level dagger graphs.
*/
@MapKey annotation class ActivityKey(
val value: KClass<out Activity>,
)
@@ -1,34 +0,0 @@
package ink.trmnl.android.di
import android.app.Activity
import android.content.Context
import com.squareup.anvil.annotations.MergeComponent
import com.squareup.anvil.annotations.optional.SingleIn
import dagger.BindsInstance
import ink.trmnl.android.TrmnlDisplayMirrorApp
import javax.inject.Provider
@MergeComponent(
scope = AppScope::class,
modules = [CircuitModule::class],
)
@SingleIn(AppScope::class)
interface AppComponent {
val activityProviders: Map<Class<out Activity>, @JvmSuppressWildcards Provider<Activity>>
/**
* Injects dependencies into [TrmnlDisplayMirrorApp].
*/
fun inject(app: TrmnlDisplayMirrorApp)
@MergeComponent.Factory
interface Factory {
fun create(
@ApplicationContext @BindsInstance context: Context,
): AppComponent
}
companion object {
fun create(context: Context): AppComponent = DaggerAppComponent.factory().create(context)
}
}
@@ -0,0 +1,35 @@
package ink.trmnl.android.di
import android.content.Context
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides
import dev.zacsweers.metrox.android.MetroAppComponentProviders
import ink.trmnl.android.work.TrmnlWorkerFactory
/**
* Metro dependency graph for the application.
*
* - [MetroAppComponentProviders] enables constructor injection of Activities via
* [dev.zacsweers.metrox.android.MetroAppComponentFactory].
* - [TrmnlWorkerFactory] is exposed so [ink.trmnl.android.TrmnlDisplayMirrorApp] can
* configure WorkManager.
*/
@DependencyGraph(AppScope::class)
interface AppGraph : MetroAppComponentProviders {
val workerFactory: TrmnlWorkerFactory
/**
* Re-exposes the raw [Context] with the [ApplicationContext] qualifier so that
* module contributors can inject it by qualifier.
*/
@Provides
@ApplicationContext
fun provideApplicationContext(context: Context): Context = context
@DependencyGraph.Factory
fun interface Factory {
fun create(
@Provides context: Context,
): AppGraph
}
}
@@ -1,6 +1,6 @@
package ink.trmnl.android.di
import javax.inject.Qualifier
import dev.zacsweers.metro.Qualifier
/** Qualifier to denote a `Context` that is specifically an Application context. */
@Qualifier annotation class ApplicationContext
@@ -3,27 +3,27 @@ package ink.trmnl.android.di
import com.slack.circuit.foundation.Circuit
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.ui.Ui
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.optional.SingleIn
import dagger.Module
import dagger.Provides
import dagger.multibindings.Multibinds
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Multibinds
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
/**
* Dagger module that provides dependencies for the Circuit framework.
* Metro module that provides dependencies for the Circuit framework.
*/
@ContributesTo(AppScope::class)
@Module
interface CircuitModule {
/**
* Dagger multi-binding method that provides a set of Presenter.Factory instances.
* Metro multi-binding declaration for Presenter.Factory instances contributed via
* @ContributesIntoSet.
*/
@Multibinds fun presenterFactories(): Set<Presenter.Factory>
@Multibinds val presenterFactories: Set<Presenter.Factory>
/**
* Dagger multi-binding method that provides a set of Ui.Factory instances.
* Metro multi-binding declaration for Ui.Factory instances contributed via
* @ContributesIntoSet.
*/
@Multibinds fun viewFactories(): Set<Ui.Factory>
@Multibinds val viewFactories: Set<Ui.Factory>
companion object {
/**
@@ -4,19 +4,17 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.optional.SingleIn
import dagger.Module
import dagger.Provides
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.data.log.TrmnlRefreshLogSerializer
import ink.trmnl.android.data.log.TrmnlRefreshLogs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@ContributesTo(AppScope::class)
object DataStoreModule {
interface DataStoreModule {
@Provides
@SingleIn(AppScope::class)
fun provideActivityLogDataStore(
@@ -4,12 +4,11 @@ import android.content.Context
import androidx.core.content.pm.PackageInfoCompat
import com.slack.eithernet.integration.retrofit.ApiResultCallAdapterFactory
import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.optional.SingleIn
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.BuildConfig
import ink.trmnl.android.network.RateLimitInterceptor
import ink.trmnl.android.network.TrmnlApiService
@@ -22,12 +21,11 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import java.io.File
import java.util.concurrent.TimeUnit
@Module
@ContributesTo(AppScope::class)
object NetworkModule {
// Cache size for OkHttp (10 MB)
private const val CACHE_SIZE = 10 * 1024 * 1024L
// Cache size for OkHttp (10 MB)
private const val CACHE_SIZE = 10 * 1024 * 1024L
@ContributesTo(AppScope::class)
interface NetworkModule {
@Provides
@SingleIn(AppScope::class)
fun provideRateLimitInterceptor(): RateLimitInterceptor = RateLimitInterceptor()
@@ -1,100 +0,0 @@
package ink.trmnl.android.di
import android.app.Activity
import android.app.Application
import android.content.Intent
import androidx.annotation.Keep
import androidx.core.app.AppComponentFactory
import ink.trmnl.android.TrmnlDisplayMirrorApp
import javax.inject.Provider
/**
* Custom implementation of [AppComponentFactory] used to inject Android components
* (specifically Activities) via Dagger using constructor injection. This factory
* allows the Android system to delegate activity instantiation to Dagger's dependency
* graph, enabling constructor injection instead of field injection.
*
* This class is referenced in the `AndroidManifest` within the `<application>` tag.
*
* Usage:
* Add the following to your AndroidManifest.xml:
*
* ```xml
* <application
* android:appComponentFactory=".di.ComposeAppComponentFactory"
* ... />
* ```
*/
@Keep
class TrmnlAppComponentFactory : AppComponentFactory() {
/**
* Retrieves an instance of the specified class (typically an Activity) from the provided
* Dagger providers map. If a provider exists for the class, it uses that provider to
* obtain the instance; otherwise, it returns null.
*
* @param T The type of the class being retrieved.
* @param classLoader The ClassLoader used to load the class.
* @param className The fully qualified name of the class to be instantiated.
* @param providers A map containing Dagger providers for the available classes.
* @return The instance of the class if found in the providers map, or null if not.
*/
private inline fun <reified T> getInstance(
classLoader: ClassLoader,
className: String,
providers: Map<Class<out T>, @JvmSuppressWildcards Provider<T>>,
): T? {
// Load the class using the provided ClassLoader and attempt to retrieve the instance.
val clazz = Class.forName(className, false, classLoader).asSubclass(T::class.java)
val modelProvider = providers[clazz] ?: return null
return modelProvider.get() as T
}
/**
* Called by the Android system to instantiate an activity. This method checks if the
* activity can be provided by the Dagger component. If the Dagger component can provide
* the activity, it returns the injected instance; otherwise, it falls back to the
* default system behavior.
*
* @param classLoader The ClassLoader used to load the activity class.
* @param className The fully qualified name of the activity to be instantiated.
* @param intent The intent that was used to start the activity.
* @return The activity instance, either from Dagger or from the system.
*/
override fun instantiateActivityCompat(
classLoader: ClassLoader,
className: String,
intent: Intent?,
): Activity =
getInstance(classLoader, className, activityProviders)
?: super.instantiateActivityCompat(classLoader, className, intent)
/**
* Called by the Android system to instantiate the Application class. This method
* initializes the Dagger component and retrieves the map of activity providers,
* which are used later for activity injection.
*
* @param classLoader The ClassLoader used to load the Application class.
* @param className The fully qualified name of the Application class.
* @return The Application instance.
*/
override fun instantiateApplicationCompat(
classLoader: ClassLoader,
className: String,
): Application {
val app = super.instantiateApplicationCompat(classLoader, className)
// Retrieve the Dagger app component and the activity providers from it
activityProviders = (app as TrmnlDisplayMirrorApp).appComponent().activityProviders
return app
}
/**
* Companion object to store activity providers. This object holds the Dagger-provided
* map of activity classes to their corresponding providers. It's used to inject activities
* upon instantiation.
*
* This map is initialized when the application is created via the Dagger component.
*/
companion object {
private lateinit var activityProviders: Map<Class<out Activity>, Provider<Activity>>
}
}
@@ -3,15 +3,13 @@ package ink.trmnl.android.di
import android.content.Context
import androidx.work.Configuration
import androidx.work.WorkManager
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.optional.SingleIn
import dagger.Module
import dagger.Provides
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import ink.trmnl.android.work.TrmnlWorkerFactory
@Module
@ContributesTo(AppScope::class)
object WorkerModule {
interface WorkerModule {
@Provides
@SingleIn(AppScope::class)
fun provideWorkManager(
@@ -37,9 +37,9 @@ import com.slack.circuit.runtime.CircuitUiState
import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.screen.Screen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import ink.trmnl.android.BuildConfig
import ink.trmnl.android.R
import ink.trmnl.android.data.AppConfig.TRMNL_ANDROID_APP_GITHUB_URL
@@ -43,9 +43,9 @@ import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.screen.PopResult
import com.slack.circuit.runtime.screen.Screen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import ink.trmnl.android.data.AppConfig.TRMNL_API_SERVER_BASE_URL
import ink.trmnl.android.data.TrmnlDisplayRepository
import ink.trmnl.android.di.AppScope

Some files were not shown because too many files have changed in this diff Show More