Hilt Build Error on Kotlin 2.3.0: Provided Metadata instance has version 2.3.0 — Causes and Fixes Explained


error: [Hilt] Provided Metadata instance has version 2.3.0, while maximum supported version is 2.2.0.

This article explains the background of this error and introduces a new solution available since Dagger 2.57.

 

🤔 🧑🏻‍💻 1. Cause of the Error

This error occurs because kotlin-metadata-jvm, a library used internally by Dagger/Hilt, cannot understand the newer Kotlin metadata format (version 2.3.0).

Shading (Inshading) explained:

  • Shading means that a dependency is relocated and bundled inside another library’s JAR.
  • In earlier Dagger versions, kotlin-metadata-jvm was shaded (hidden) inside Dagger itself.
  • As a result, developers could not override or update it, even if Kotlin introduced a new metadata version.
  • This tightly coupled Dagger’s compatibility to a specific Kotlin version and forced users to wait for a Dagger release.

 

🤔 🧑🏻‍💻 2. What Changed in Dagger 2.57

Starting from Dagger 2.57, kotlin-metadata-jvm is unshaded (no longer hidden).

This means:

  • The dependency is now resolved normally via Gradle
  • Developers can explicitly specify a newer version without waiting for a Dagger update

This architectural change significantly improves Kotlin version agility.

 

🤔 🧑🏻‍💻 3. Solution: Explicitly Declare the Dependency

If you are using Kapt

Kapt runs through the Java compiler and is more sensitive to metadata incompatibility.


dependencies {
    // Add the latest metadata library to kapt
    kapt("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0-Beta1")
}

If you are using KSP

KSP is directly integrated with the Kotlin compiler, so this error is less likely.

If needed, you can still specify it explicitly.


dependencies {
    // Add to ksp configuration
    ksp("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0-Beta1")
}

Recommended: Force the version globally

If multiple modules are affected, this is the most reliable approach.


configurations.all {
    resolutionStrategy {
        force "org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0-Beta1"
    }
}

 

🤔 🧑🏻‍💻 4. Summary

  • If you are using Dagger 2.57 or later, you do not need to wait for a new Dagger release.
  • When the error appears, explicitly add the latest kotlin-metadata-jvm to your kapt or ksp configuration.
  • In general, migrating to KSP is recommended due to better compatibility and performance.
  • Developers who want to adopt the latest Kotlin features early should definitely apply this setup.

👉 Upgrade kotlin-metadata-jvm to support Kotlin 2.3.0 · Issue #5001 · google/dagger


How dp/sp/px Conversion Works in Android

  • This code defines extension functions to convert between dp, sp, and px.
  • It relies on density (for dp) and scaledDensity (for sp) extracted from Android’s DisplayMetrics.
  • The goal is to keep UI elements visually consistent across devices with different screen densities.

 

🧑🏻‍💻 Why These Conversions Matter (Foundation)

Screen Density Model


+---------------------------------------------+
| density       → converts dp ↔ px            |
| scaledDensity → converts sp ↔ px (font size)|
+---------------------------------------------+

  • dp: density-independent pixels
  • sp: scale-independent pixels (respects user font size setting)
  • px: raw physical pixels

density and scaledDensity come from:


resources.displayMetrics

This ensures the UI scales correctly across devices.

 

🧑🏻‍💻 Key Conversion Logic

1. dp → px

Formula: px = dp × density


dpToPx(dp) = dp.value * density

2. dp → sp

( dp → px → sp )
Formula: sp = dp × density ÷ scaledDensity


dpToSp(dp) = (dp.value * density / scale).sp

3. px → dp

Formula: dp = px ÷ density


toDp(px) = (px / density).dp

4. px → sp

Formula: sp = px ÷ scaledDensity


toSp(px) = (px / scale).sp

5. sp → dp

Formula: dp = sp × scaledDensity ÷ density


spToDp(sp) = (sp.value * scale / density).dp

6. sp → px

Formula: px = sp × scaledDensity


spToPx(sp) = sp.value * scale

7. Type-Specific Extensions

The code also adds natural calling styles:


Dp.toPx(context)
Float.toDp(context)
TextUnit.toPx(context)
TextUnit.toDp(context)

These simply delegate to the Context converters and make the API flexible.

 

🧑🏻‍💻 Visualization — Full Conversion Map


+------------------+           +------------------+
|       Dp         | <------>  |        px        |
|   (dp.value)     |           |     (Float)      |
+------------------+           +------------------+
          |                               ^
          | dpToSp / spToDp               |
          v                               |
+------------------+           +------------------+
|       Sp         | <------>  |    scaled px     |
|  (TextUnit.sp)   |           |  (scaledDensity) |
+------------------+           +------------------+

 

🧑🏻‍💻 Notes & Caveats

  • scaledDensity changes when users adjust system font size.
  • Jetpack Compose usually hides px conversions, but you still need px for:
    • Custom drawing
    • Canvas operations
    • Bitmap sizing
  • Expert consultation recommended for deeply understanding DPI internals in OEM-modified environments.

 

🧑🏻‍💻 References

👉 dp / px / sp 完全相互変換
👉 DisplayMetrics  |  API reference  |  Android Developers
👉 各種のピクセル密度をサポートする  |  Compatibility  |  Android Developers


Patterning Dagger/Hilt Cases Where a Module Is or Is Not Required

 

🧑🏻‍💻 When a Module Is Not Required: Concrete Classes

In Hilt/Dagger, concrete classes with an @Inject constructor can be injected automatically.


class ApiClient @Inject constructor()

class UserRepository @Inject constructor(
    private val api: ApiClient
)

Point:
If the class can be instantiated directly, a Module is not required.

 

🧑🏻‍💻 When a Module Is Required (1): Interfaces

When injecting an interface, Hilt cannot determine which implementation to use.

You need to specify it explicitly using @Binds inside a Module.


interface Logger { 
    fun log(msg: String) 
}

class ConsoleLogger @Inject constructor() : Logger

@Module
@InstallIn(SingletonComponent::class)
interface LoggerModule {
    @Binds
    fun bindLogger(impl: ConsoleLogger): Logger
}

Point:
Interfaces always require a Module.

 

🧑🏻‍💻 When a Module Is Required (2): External Libraries

External libraries typically do not have @Inject constructor.

You must provide the creation logic inside a Module.


@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    fun provideClient(): OkHttpClient = OkHttpClient.Builder().build()
}

Point:
You make external classes injectable by defining how to create them in a Module.

 

🧑🏻‍💻 Summary

  • Your own concrete classes → auto-injectable
  • Interfaces & external libraries → Module required
  • Multiple implementations or singleton handling → use @Qualifier, @Named, @Singleton

👉 Dagger/HiltでModuleが必要か一目でわかるようにパターン化する


Kotlin StateFlow: value vs. update – Which One Should You Use?

When updating a MutableStateFlow, you have two options. Here is how to decide instantly.

 

🧑🏻‍💻 The Golden Rule

  • Use update { } if the new value depends on the current value (e.g., incrementing a counter, toggling a boolean).
  • Use value = if you are completely overwriting the state (e.g., setting a loading state, resetting data).

 

🧑🏻‍💻 Why does it matter?

Direct assignment (value = ...) is not thread-safe for "read-modify-write" operations.

If two coroutines try to update the state simultaneously using .value = .value + 1, you risk a Race Condition where one update is lost.

The update function is atomic. It uses a Compare-And-Set mechanism to ensure that updates happen sequentially and safely, even across multiple threads.

 

🧑🏻‍💻 Code Comparison

❌ Risky (Race Condition prone)


// If called concurrently, updates might be lost
_uiState.value = _uiState.value.copy(count = _uiState.value.count + 1)

✅ Safe (Thread-safe)


// Guarantees consistency
_uiState.update { it.copy(count = it.count + 1) }

✅ Safe (Overwrite)


// No race condition risk because we ignore the previous state
_uiState.value = UiState.Loading

 

🧑🏻‍💻 Summary

When in doubt, use update. It is safer by default and prevents subtle concurrency bugs.


Why are updates to Kotlin, Compose, and KSP such a hassle?

In Android development, you're constantly dealing with the same set of three: Kotlin, the Compose Compiler, and KSP.

They seem like a friendly group, but their update schedules are always completely different! You upgrade Kotlin, and the Compose Compiler isn't compatible. You change something, and KSP throws a build error because of an internal API change...

To better manage this "dependency triangle" situation, the main idea is to use Renovate's configuration to treat them as a single unit. The simple plan is: "Raise all Kotlin ecosystem dependencies at the same time!"

 

🧑🏻‍💻 Brief overview of the renovate.json file


{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:base",
    "group:all",
    ":dependencyDashboard",
    "schedule:daily"
  ],
  "baseBranches": ["main"],
  "commitMessageExtra": "{{{currentValue}}} to {{#if isPinDigest}}{{{newDigestShort}}}{{else}}{{#if isMajor}}{{prettyNewMajor}}{{else}}{{#if isSingleVersion}}{{prettyNewVersion}}{{else}}{{#if newValue}}{{{newValue}}}{{else}}{{{newDigestShort}}}{{/if}}{{/if}}{{/if}}{{/if}}",
  "packageRules": [
    {
      "matchPackagePatterns": ["androidx.compose.compiler:compiler"],
      "groupName": "kotlin"
    },
    {
      "matchPackagePatterns": ["org.jetbrains.kotlin.*"],
      "groupName": "kotlin"
    },
    {
      "matchPackagePatterns": ["com.google.devtools.ksp"],
      "groupName": "kotlin"
    }
  ]
}

👉 architecture-samples/renovate.json at main · android/architecture-samples

Roughly summarized, here are the key points:

  • groupName: "kotlin" to bundle dependencies This setting specifies that the three elements—the Compose Compiler, Kotlin, and KSP—should be treated as belonging to the "same group." This allows Renovate to update them all together at once.
  • schedule: daily for a calm update pace This checks for updates once a day. You'll receive pull requests (PRs) on a daily basis, preventing a huge influx of dependency updates all at once, which makes things much easier to manage.
  • commitMessageExtra to see changes at a glance The version difference, like "2.0.10 → 2.0.20," is automatically added to the PR title. It's a small tweak, but surprisingly useful.

Setting up your configuration this way significantly reduces the tragedy of "Kotlin got updated, but Compose broke..."

 

🧑🏻‍💻 What We Found While Using It

Once this setup is in place, you can feel much more confident testing updates for everything Kotlin-related. Renovate diligently checks daily, automatically creating a PR whenever a new version drops.

But there's one small warning:

The Compose Compiler sometimes takes a little extra time to catch up to the latest Kotlin version. So, don't just merge the PR when you see it—it's highly recommended to verify the CI status first.

KSP is similar; because it depends on Kotlin's internal workings, it's safer to update it along with Kotlin and run your tests together.

 

🧑🏻‍💻 Summary: Teach Renovate that "These Three Are a Set"

The configuration we discussed treats the trio of Kotlin, the Compose Compiler, and KSP as a single group.

  • Bundle all Kotlin-related dependencies for simultaneous updates.
  • Check for updates at a manageable daily pace.
  • See version differences directly in the PR title.

Just implementing this significantly reduces the problems caused by versions getting out of sync and breaking your build.

💡 Key Takeaway: Use Renovate less as an "automatic update tool" and more as a "dependency rulebook."

We simply need to tell Kotlin, Compose, and KSP to cooperate and "work together."

👉 Kotlin・Compose・KSP の更新、どうしてこんなに面倒なの?