Skip to content

← Writing

Eight commits chasing the wrong Hilt fix

My first emulator run crashed on every Hilt-injected ViewModel. Eight commits chased the wrong fix — until docs revealed Compose BOM 2026.04 had quietly changed LocalContext.

I have five days of green Gradle builds. assembleDebug clean. testDebugUnitTest clean. 52 unit tests passing. Tarotscope — my native Android build — feels like it’s going to launch on time.

Then I run it on the emulator for the first time. The Splash screen appears for half a second and the app crashes.

java.lang.IllegalStateException: Expected an activity context for
creating a HiltViewModelFactory but instead found:
android.app.ContextImpl@50394f6
    at HiltViewModelFactory.create(HiltNavBackStackEntry.kt:70)
    at HiltViewModelKt.createHiltViewModelFactory(HiltViewModel.kt:95)
    at SplashScreenKt.SplashScreen(SplashScreen.kt:212)

Every screen in the app calls hiltViewModel() — that’s the whole point of FSD-adapted Clean Architecture with Hilt. The crash fires on the first one Compose tries to render. So 13 of 13 screens are broken. The app cannot boot.

What followed was eight commits chasing the wrong fix.

What the error means, and what I assumed

HiltViewModelFactory in androidx.hilt.navigation does this internally: it takes the Compose LocalContext, walks ContextWrapper.getBaseContext() up the chain looking for an Activity. When it finds one, it uses it as the owner for the ViewModel. When the chain ends without finding one, it throws.

ContextImpl is a direct subclass of Context. It is not a ContextWrapper. The unwrap loop exits immediately. Hilt throws.

So the question is: why is LocalContext.current in my Compose tree a ContextImpl and not the host Activity?

I assumed the bug was in my code. I had recently shipped ADR-012 — a LocalizedContent Composable that overrode LocalConfiguration and LocalContext to make language-switch live across the whole tree. The override called createConfigurationContext(), which returns a fresh ContextImpl. Stupid me. Of course that’s the bug.

It wasn’t.

Four wrong fixes

Fix 1. Replace createConfigurationContext() with a manually-wrapped ContextWrapper(activity) so the unwrap chain finds an Activity at the bottom.

Result: same crash. Compose UI’s subcomposition path — Scaffold + NavHost + AnimatedContent — apparently re-derives LocalContext from somewhere internal, bypassing my provider for descendants below a certain depth.

Fix 2. Move locale handling out of Compose entirely. Override attachBaseContext on the Activity, recreate the Activity on language change. No Composable wrap.

Result: same crash. The locale wrap was a red herring — the bug fired even when I deleted the wrapper completely.

Fix 3. Bump hilt-navigation-compose 1.2.0 → 1.3.0. The release notes said hiltViewModel() moved packages — to androidx.hilt.lifecycle.viewmodel.compose. Maybe the bug was a known issue fixed in 1.3.

I migrated all 13 import sites. Bumped the catalog. Re-ran.

Result: same stack trace. The crash is in HiltViewModelFactory from androidx.hilt.navigation — the shared internal class — not in either compose subpackage. Both old and new entry points lead to the same broken unwrap.

Fix 4. Drop the legacy hilt-navigation-compose artifact entirely. Use only the new hilt-lifecycle-viewmodel-compose:1.3.0.

Result: same crash. Same shared class.

Eight commits at this point. Each fix was a 30-minute cycle: code, sync Gradle, install on emulator, watch the crash. Every time, the same trace. I’m starting to wonder if my Hilt setup is fundamentally wrong — but my unit tests instantiate the same ViewModels with the same @HiltViewModel annotations and they all pass.

The cross-check that broke the loop

I switch tools. Open context7. Query: “Compose UI ProvideAndroidCompositionLocals 2026.04”.

The docs page mentions, in passing:

In Compose UI provided through Compose-BOM-2026.04, LocalContext is populated with a ContextImpl derived from the platform context, decoupled from the host Activity. Use LocalActivity.current (added in activity-compose:1.10.0) when you need the Activity.

Click.

The bug was never in my code. It was never in Hilt. Compose UI’s ProvideAndroidCompositionLocals changed what LocalContext means in BOM 2026.04. Hilt’s unwrap loop, written for the old behavior where view.context was always the Activity, fails on the new ContextImpl. Two AndroidX libraries. Each one fine in isolation. Together, broken.

I had been reading the wrong layer’s docs the whole time.

The real fix

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val activity = this
        setContent {
            CompositionLocalProvider(LocalContext provides activity) {
                TarotscopeTheme {
                    Scaffold(/* ... */) { TarotscopeNavGraph(/* ... */) }
                }
            }
        }
    }
}

Capture this outside the Composable lambda. Override LocalContext at the root with the Activity. The Activity is itself a ContextWrapper, so Hilt’s unwrap loop finds an Activity on the first iteration and the HiltViewModelFactory resolves cleanly.

App boots. Splash appears. Onboarding runs. Daily-loop works end-to-end.

One line of capture, one CompositionLocalProvider.

The price

The same Composable I had built for ADR-012 — LocalizedContent that overrode LocalContext to flip locale per language — is now incompatible with the workaround. It can’t coexist: my override and the BOM’s override of LocalContext would fight, and the order between them is brittle. So I reverted ADR-012. Settings → Language still persists to DataStore, but the picker no longer flips the UI shell live. The system locale wins until I can re-engage a switcher.

The EN+ID resource files and the ~350 migrated stringResource() call sites are not wasted — values-id/strings.xml activates whenever the user’s system locale is Indonesian. They just don’t activate from inside the app’s settings yet.

I documented all of this as ADR-014, with the removal trigger spelled out: when Hilt 1.4 stable migrates to LocalActivity.current (the API designed for this exact case), I delete the workaround and re-implement the locale wrap.

What I’d tell my past self

Read the docs of every layer that touches the bug, not just the one you suspect.

The crash trace points at Hilt. I read Hilt docs. I read Hilt source. I bumped Hilt versions. The whole time, the upstream cause was sitting in Compose UI’s BOM upgrade — a single sentence in a release note I never opened, because the symptom didn’t look like a Compose UI bug.

The other thing I’d tell myself: bleeding-edge stacks compound risk. Compose BOM 2026.04.01 shipped one sprint before Tarotscope started. Hilt 1.3.0 shipped two sprints before. Each one is fine on its own. Together, the compatibility surface is undertested by definition — only the few people running both versions on the same week catch the interaction. I caught one of them.

The fix is fourteen lines. The diagnosis took a day. Document workarounds prominently, in a comment that says exactly which library and which version triggers it and exactly when to delete the workaround. Future-me, scrolling through MainActivity.kt six months from now, doesn’t get to re-discover this trap.


Drafted right after fixing the bug. ADR-014 lives in the Tarotscope repo (private during dev) for posterity.