Merge pull request #278 from usetrmnl/metro-migration-MAIN

feat: migrate from Dagger/Anvil/KAPT to Metro DI framework
This commit is contained in:
Hossain Khan
2026-04-03 09:25:41 -04:00
committed by GitHub
44 changed files with 172 additions and 306 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ This directory contains the development container configuration for TRMNL Androi
## Features
- **Base Image**: Java 17 (Bookworm)
- **Base Image**: Java 21 (Bookworm)
- **Android SDK**: Manually installed via post-create script
- **Android Command Line Tools**: Version 11076708
- **Platform Tools**: Latest version
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "TRMNL Android",
"image": "mcr.microsoft.com/devcontainers/java:1-17-bookworm",
"image": "mcr.microsoft.com/devcontainers/java:1-21-bookworm",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
+6 -6
View File
@@ -11,7 +11,7 @@ This document provides essential information for GitHub Copilot agents working o
- Build System: Gradle 8.13
- Min SDK: 28 (Android 9.0 Pie)
- Target SDK: 36 (Android 16.0)
- Architecture: Modern Android with Jetpack Compose, Circuit UDF, Dagger DI
- Architecture: Modern Android with Jetpack Compose, Circuit UDF, Metro DI
## Critical Build & Test Commands
@@ -55,7 +55,7 @@ This document provides essential information for GitHub Copilot agents working o
## Environment Requirements
- **JDK Version:** 17 (OpenJDK 17 - Temurin distribution recommended)
- **JDK Version:** 21 (OpenJDK 21 - Temurin distribution recommended)
- **Gradle:** 8.13 (via wrapper, do NOT install manually)
- **Android SDK:** Compile SDK 36
- **Build Tools:** Managed by Gradle plugin (AGP 8.9.2)
@@ -70,7 +70,7 @@ app/src/main/java/ink/trmnl/android/
├── MainActivity.kt # App entry point (138 lines)
├── TrmnlDisplayMirrorApp.kt # Application class (51 lines)
├── data/ # Repositories, DataStore implementations
├── di/ # Dagger dependency injection modules
├── di/ # Metro dependency injection modules
├── model/ # Data models (TrmnlDeviceConfig, etc.)
├── network/ # Retrofit API service, response models
├── ui/ # Jetpack Compose screens (Circuit UDF)
@@ -89,7 +89,7 @@ app/src/main/java/ink/trmnl/android/
### Key Architectural Patterns
- **UI Framework:** Jetpack Compose with Circuit (Slack's UDF architecture)
- **DI:** Dagger with Anvil for code generation
- **DI:** Metro (dev.zacsweers.metro) for compile-time code generation
- **Background Work:** WorkManager (15 min minimum interval)
- **Networking:** Retrofit + OkHttp + Moshi for JSON parsing
- **Data Storage:** DataStore (preferences) for settings/tokens
@@ -134,7 +134,7 @@ All workflows are in `.github/workflows/`:
**Solution:** This is informational only. The file is optional for local dev (used for release keystores). Debug builds work without it.
### Issue: First Build Takes 2-3 Minutes
**Solution:** Expected behavior. Gradle downloads dependencies and builds annotation processors (KSP, KAPT). Subsequent builds are much faster (~1 minute) due to caching.
**Solution:** Expected behavior. Gradle downloads dependencies and runs Metro compiler plugin + KSP. Subsequent builds are much faster (~1 minute) due to caching.
### Issue: Test Warning "Sharing is only supported for boot loader classes"
**Solution:** Harmless warning from Robolectric tests. Can be ignored. Tests still pass.
@@ -199,7 +199,7 @@ See `RELEASE_CHECKLIST.md` for complete release process.
## Trust These Instructions
These instructions have been validated by running all commands in a clean repository clone. If you encounter issues not documented here:
1. First verify you're using JDK 17 and the Gradle wrapper (`./gradlew`)
1. First verify you're using JDK 21 and the Gradle wrapper (`./gradlew`)
2. Try `./gradlew clean` then retry the command
3. Check if similar issues exist in closed GitHub issues
4. Only search the codebase if these instructions prove incomplete or incorrect
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: set up JDK
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: set up JDK
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
@@ -17,7 +17,7 @@ jobs:
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
- name: Create test directory
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '17' # Using a stable JDK version
java-version: '21' # Using a stable JDK version
distribution: 'temurin'
- name: Verify Java and Keytool Installation
+1
View File
@@ -37,3 +37,4 @@ app/release/
*.salive
app*.aab
secret.properties
metro-main*
+14 -13
View File
@@ -11,7 +11,7 @@ TRMNL Android is a native Android app that displays TRMNL e-ink device content o
- Build: Gradle 8.13 with AGP 8.9.2
- Min SDK: 28 (Android 9.0 Pie), Target SDK: 36 (Android 16.0)
- UI: Jetpack Compose with Circuit UDF architecture (Slack's unidirectional data flow)
- DI: Dagger 2.56.2 with Anvil 0.4.1 for code generation
- DI: Metro 0.12.1 (dev.zacsweers.metro) with MetroX Android for compile-time DI
- Background Work: WorkManager 2.10.1 (15-minute minimum interval limitation)
- Networking: Retrofit 2.11.0 + OkHttp 4.12.0 + Moshi 1.15.2
- Image Loading: Coil 3.2.0 with OkHttp integration
@@ -25,14 +25,14 @@ TRMNL Android is a native Android app that displays TRMNL e-ink device content o
**CRITICAL: On a fresh clone, you MUST build first to generate code:**
```bash
# 1. Build debug APK (generates Dagger/Circuit code via KSP/KAPT)
# 1. Build debug APK (generates Metro/Circuit code via compiler plugin + KSP)
./gradlew assembleDebug --parallel --daemon
# 2. Now you can run tests
./gradlew testDebugUnitTest --parallel --daemon
```
**Why?** Running tests before building will fail with Dagger errors about missing generated modules. The build step generates necessary code via KSP (Circuit) and KAPT (Dagger).
**Why?** Running tests before building will fail with Metro errors about missing generated modules. The build step generates necessary code via Metro compiler plugin (DI) and KSP (Circuit).
### Regular Development (After Initial Build)
@@ -53,7 +53,7 @@ TRMNL Android is a native Android app that displays TRMNL e-ink device content o
```
**Notes:**
- JDK: Minimum JDK 17, but JDK 21+ works (backward compatible)
- JDK: Minimum JDK 21 (required by Metro gradle plugin)
- ALWAYS use `./gradlew` wrapper, NEVER system gradle
- First build: ~1-2 minutes (optimized with G1GC, parallel execution, configuration cache)
- Subsequent builds: ~2-8 seconds with Gradle cache and configuration cache
@@ -72,8 +72,8 @@ app/src/main/java/ink/trmnl/android/
│ ├── TrmnlDeviceConfigDataStore.kt # Device config (token, server)
│ ├── ImageMetadataStore.kt # Image state management
│ ├── log/ # Refresh log management
├── di/ # Dagger modules
│ ├── AppComponent.kt # Main app component
├── di/ # Metro DI modules
│ ├── AppGraph.kt # Main app dependency graph
│ ├── NetworkModule.kt # Retrofit, OkHttp, Moshi
│ ├── CircuitModule.kt # Circuit UI setup
├── model/ # Data models
@@ -98,15 +98,16 @@ app/src/main/java/ink/trmnl/android/
### Key Architectural Patterns
**Circuit UDF (Unidirectional Data Flow):**
- All screens use `@CircuitInject` annotation for DI with Anvil
- All screens use `@CircuitInject` annotation for DI with Metro
- Presenter pattern: State + Event → Presenter → Updated State
- Example: `TrmnlMirrorDisplayScreen` with presenter handling events
**Dependency Injection:**
- Dagger with Anvil for compile-time code generation
- Metro for compile-time code generation via Kotlin compiler plugin (no KAPT)
- `@ContributesTo(AppScope::class)` for module contribution
- `@SingleIn(AppScope::class)` for app-scoped singletons
- Circuit integration: `arg("anvil-ksp-extraContributingAnnotations", "com.slack.circuit.codegen.annotations.CircuitInject")`
- `@DependencyGraph(AppScope::class)` on `AppGraph` as the root component
- Circuit integration: `arg("circuit.codegen.mode", "metro")`
**Background Work:**
- WorkManager with 15-minute minimum interval (Android OS limitation)
@@ -198,13 +199,13 @@ The project uses optimized Gradle settings based on best practices from the [Now
## Common Issues
- **Tests fail on fresh checkout**: Must run `./gradlew assembleDebug` first to generate Dagger/Circuit code
- **Tests fail on fresh checkout**: Must run `./gradlew assembleDebug` first to generate Metro/Circuit code
- **"secret.properties not found"**: Informational only, debug builds work without it
- **First build slow**: Expected for clean builds, ~1-2 minutes with optimized Gradle settings
- **"Sharing is only supported for boot loader classes"**: Harmless Robolectric warning, tests still pass
- **"INVISIBLE_REFERENCE" in NetworkingTools.kt:56**: Expected, uses internal Kotlin API (documented warning)
- **WorkManager 15-min minimum**: Android OS limitation, not a bug
- **Dagger KAPT errors about missing modules**: Run `assembleDebug` first to generate code
- **Metro codegen errors about missing modules**: Run `assembleDebug` first to generate code
## Code Style
@@ -243,7 +244,7 @@ The project uses optimized Gradle settings based on best practices from the [Now
- **Authentication**: Token-based auth required (TRMNL access token or device ID/MAC address)
- **BYOS Support**: Supports custom server URLs for BYOS installations
- **Screen Wake Lock**: `FLAG_KEEP_SCREEN_ON` doesn't work reliably on e-Ink tablets due to aggressive battery optimization
- **Kotlin Version**: Capped at 2.1.10 due to Dagger 2.56.2 compatibility (see libs.versions.toml comment)
- **Kotlin Version**: 2.3.20+ (Metro removed the Dagger/KAPT compatibility constraint)
- **No Product Flavors**: Only debug/release build types exist
## Copilot Instructions Context
@@ -253,5 +254,5 @@ From `.github/copilot-instructions.md`:
- Use Circuit's `@CircuitInject` for UI components
- WorkManager periodic work has 15-min minimum interval
- Image loading handles HTTP 403 auto-refresh for expired URLs
- Dagger KSP support is in Alpha - using KAPT for now
- Metro uses a Kotlin compiler plugin — no KAPT, no KSP for DI itself
- Trust the documented build commands - they're CI-validated
+1 -1
View File
@@ -17,7 +17,7 @@ The app uses a modern Android architecture with the following components:
- **UI**: Jetpack Compose with Circuit UDF architecture
- **Background Processing**: WorkManager for scheduled image (re)loading
- **Networking**: Retrofit and OkHttp for API communication
- **DI**: Dagger with Anvil for dependency injection
- **DI**: Metro for dependency injection
- **Data Storage**: DataStore for preferences and token storage
#### Key Features/Screens
+11 -19
View File
@@ -5,10 +5,9 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlinter)
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
id("dev.zacsweers.metro")
}
// Load secret.properties file for local development
@@ -99,12 +98,8 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
@@ -122,6 +117,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
@@ -147,13 +148,7 @@ dependencies {
implementation(libs.core.ktx)
ksp(libs.circuit.codegen)
implementation(libs.dagger)
// Dagger KSP support is in Alpha, not available yet. Using KAPT for now.
// https://dagger.dev/dev-guide/ksp.html
kapt(libs.dagger.compiler)
implementation(libs.anvil.annotations)
implementation(libs.anvil.annotations.optional)
implementation(libs.metrox.android)
// Timber
implementation(libs.timber)
@@ -198,10 +193,7 @@ dependencies {
}
ksp {
// Anvil-KSP
arg("anvil-ksp-extraContributingAnnotations", "com.slack.circuit.codegen.annotations.CircuitInject")
// kotlin-inject-anvil (requires 0.0.3+)
arg("kotlin-inject-anvil-contributing-annotations", "com.slack.circuit.codegen.annotations.CircuitInject")
arg("circuit.codegen.mode", "metro")
}
// Enable dynamic agent loading for tests needed by MockK
+3 -4
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,12 +16,12 @@
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"
android:theme="@style/Theme.App">
android:theme="@style/Theme.App"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -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.
@@ -377,7 +377,7 @@ class TrmnlDeviceConfigDataStore
*/
suspend fun saveAccessToken(token: String) {
context.deviceConfigStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = token
preferences[ACCESS_TOKEN_KEY] = token.trim()
}
}
@@ -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.

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