This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Hedvig Android app - A modern Android application built with Jetpack Compose, Apollo GraphQL, and Kotlin. The app uses a highly modular architecture with 80+ modules organized into feature, data, and core layers.
Two foundations are newer than most of the codebase and are easy to get wrong if you assume the old patterns:
- Dependency injection is Metro (
dev.zacsweers.metro), a compile-time DI framework. Koin is gone. If you find yourself writing amodule { }or callingget(), stop — you are following a stale pattern. - Navigation is Navigation 3 (
androidx.navigation3) on top of a single app-owned back stack. There is noNavController, noNavHost, no route strings, nonavgraph/navdestination. Destinations are@Serializablekeys; the back stack is a plain mutable list of keys.
A full narrative of why these look the way they do — the engineering decisions, the alternatives rejected, the invariants — lives in docs/architecture/navigation-and-di.md. Read it before making structural changes to navigation or DI. This file is the day-to-day quick reference.
# 1. Download GraphQL schema (required before building)
./gradlew downloadOctopusApolloSchemaFromIntrospection
# 2. Download translations from Lokalise (required before building)
./gradlew downloadStrings
# 3. Build and sync the project
./gradlew buildPrerequisites:
lokalise.propertiesfile with credentials (from 1Password)~/.gradle/gradle.propertieswith GitHub Packages token (PAT withread:packagespermission)- See
scripts/ci-prebuild.shfor reference
# Build the app
./gradlew :app:assemble
# Run all tests
./gradlew test
# Run tests for a specific module
./gradlew :feature-home:test
# Run unit tests
./gradlew testDebugUnitTest
# Formatting
./gradlew ktlintCheck # Check formatting
./gradlew ktlintFormat # Auto-format files
# Linting
./gradlew lint
# Clean build
./gradlew clean
# Generate module dependency graph (requires graphviz: brew install graphviz)
./gradlew :generateProjectDependencyGraph
# Find unused resources
./gradlew :app:lint -Prur.lint.onlyUnusedResources
./gradlew :app:removeUnusedResourcesDebugThe codebase is organized under /app with 80+ modules following a strict modularization pattern:
- app/ - Main application module. Owns the single Metro
AppGraph, the singleNavDisplay, the back stack controller, and all cross-feature navigation wiring. - feature/ - Feature modules (feature-home, feature-chat, feature-login, etc.)
- feature/feature-{name}-navigation/ - Tiny modules holding only the public
@Serializable HedvigNavKeys of a feature that other features may navigate to. These are the one carve-out to the "features can't depend on features" rule. - data/ - Data layer modules (data-contract, data-chat, data-addons, etc.)
- core/ - Core utilities (core-common, core-datastore, core-resources, etc.)
- apollo/ - GraphQL client modules (apollo-octopus-public, apollo-core, etc.)
- navigation/ - Navigation infrastructure:
navigation-common(KMP, holdsHedvigNavKey+ marker interfaces),navigation-compose(KMP, theBackstackinterface, deep-link matching, decorators),navigation-keys-processor(KSP processor generating serializer registrations),navigation-activity(AndroidExternalNavigator). - design-system/ - Design system components
- ui/ - Shared UI components
- auth/ - Authentication modules
- database/ - Room database modules
- language/ - Localization modules
- shareddi/ - KMP module declaring the iOS
IosGraph(Metro graph for the iOS target). - Other utilities - payment, tracking, logging, featureflags, etc.
Critical architectural rule: Feature modules CANNOT depend on other feature modules. This is enforced at build time by hedvig.gradle.plugin (configureFeatureModuleGuidelines()). The single exception: any module can depend on a feature-{name}-navigation module, because those exist precisely to be shared cross-feature.
{name}-public- Public APIs and interfaces (often KMP-compatible){name}-android- Android-specific implementations{name}-test- Test utilitiesfeature-{name}-navigation- Public navigation keys of a feature (cross-feature depend-able)- No suffix for main implementation modules
If a module is KMP compatible, there is no need for the -public or -android suffix. The android-specific code lives inside the androidMain directory instead (see :language-core).
- Release (
com.hedvig.app) - Production builds for Play Store - Staging (
com.hedvig.app) - Internal testing via Firebase App Distribution (staging backend) - Develop (
com.hedvig.dev.app) - Development builds (staging backend)
The app uses Molecule (Cash App's library) for reactive state management. This is unchanged by the Metro/Nav3 migration:
// ViewModels delegate to Presenters
class FeatureViewModel(
useCase: FeatureUseCase,
) : MoleculeViewModel<FeatureEvent, FeatureUiState>(
initialState = FeatureUiState.Loading,
presenter = FeaturePresenter(useCase),
)
// Presenters contain presentation logic
class FeaturePresenter : MoleculePresenter<FeatureEvent, FeatureUiState> {
@Composable
override fun MoleculePresenterScope<FeatureEvent>.present(lastState: FeatureUiState): FeatureUiState {
// Composable state management logic
}
}Flow: User Action → Event → Presenter → UiState → UI
Metro is a compile-time DI framework. There are two scopes: a global AppScope graph for the whole app, plus a per-Activity ActivityRetainedScope graph extension (ActivityRetainedGraph) created once per MainActivity for things that must be 1:1 with one Activity's back stack. Bindings are contributed from any module and merged into the graph at compile time.
Core annotations you will actually use:
@Inject— constructor (or, onMainActivity/Application/Service, field) injection.@SingleIn(AppScope::class)— an app-wide singleton. Apply to anything that must have exactly one instance for the whole process (stateful services, caches). Put it wherever the binding is declared: on the@Injectconstructor, or on the@Providesmethod when you can't annotate the constructor.@SingleIn(ActivityRetainedScope::class)— a per-Activity singleton bound inActivityRetainedGraph. Used for things tied to one Activity's back stack (e.g.SessionReconciler). TheBackstackControlleritself isn't annotated — it's built directly byNavRetainedViewModel(a retainedViewModel) and passed into theActivityRetainedGraph.Factory, which binds it asBackstack. Seedocs/architecture/navigation-and-di.md§I.1 and §II.4.@ContributesBinding(AppScope::class)— on an implementation class, binds it to its interface in the graph. The standard way to provide anImplfor an interface.@Providesinside a@ContributesTo(AppScope::class) interface— for bindings you can't annotate a constructor on (framework types, builders, things needing configuration). SeeApplicationMetroProviders.@ContributesIntoSet/@ContributesIntoMap— multibindings. Used for sets ofSerializersModule, deep-link matcher providers, notification senders, and the ViewModel/worker maps.@Multibinds(allowEmpty = true)— declares a multibound collection on the graph even when no module contributes to it.@AssistedInject+@AssistedFactory— runtime parameters (e.g. a screen'scontractId) combined with injected dependencies.
The app graph is declared once, in :app:
@DependencyGraph(AppScope::class)
internal interface AppGraph : ViewModelGraph {
val workerFactory: MetroWorkerFactory
@Multibinds(allowEmpty = true)
val serializersModules: Set<SerializersModule>
fun inject(activity: MainActivity)
fun inject(application: HedvigApplication)
@DependencyGraph.Factory
interface Factory { fun create(@Provides applicationContext: Context): AppGraph }
}ViewModels are resolved through Metro, not viewModel(): Inside an entry<Key> { } block use:
// No runtime args:
val vm: InsuranceViewModel = metroViewModel()
// With assisted (navigation) args:
val vm: ContractDetailViewModel =
assistedMetroViewModel<ContractDetailViewModel, ContractDetailViewModel.Factory> {
create(key.contractId)
}To register a ViewModel, mark it @HedvigViewModel(scope) plus its Metro constructor annotation. The :viewmodel-processor KSP processor generates the @ViewModelKey / @ContributesIntoMap / factory boilerplate — you never hand-write it. Almost every ViewModel is ActivityRetainedScope (it can inject the per-Activity Backstack):
@AssistedInject
@HedvigViewModel(ActivityRetainedScope::class)
internal class ContractDetailViewModel(
@Assisted contractId: String,
useCase: GetContractForContractIdUseCase,
) : MoleculeViewModel<...>(...)A no-arg ViewModel uses @Inject + @HedvigViewModel(ActivityRetainedScope::class) instead. The rare @HedvigViewModel(AppScope::class) case is a ViewModel resolved by its own standalone Activity. The module must opt in with viewModels() in its hedvig {} block. A MergedMetroViewModelFactory (merging the app graph's and this Activity's ActivityRetainedGraph maps) is provided into the composition via LocalMetroViewModelFactory in MainActivity (read off navRetainedViewModel.viewModelFactory).
The code the processor generates is always public, even though the VM is usually internal. This is required: Metro only discovers cross-module contributions whose metro/hints marker is public, so an internal generated contribution is silently dropped from :app's graph and surfaces at runtime as IllegalArgumentException: Unknown model class …. Don't "fix" the generated wrapper to be internal — see docs/architecture/navigation-and-di.md §I.3.1.
Demo mode is the one place we need two implementations of the same type. Use the Provider<T> fun interface and a ProdOrDemoProvider<T> (always @SingleIn(AppScope::class)), which picks demoImpl vs prodImpl off DemoManager. Inject Provider<T> and call .provide(). Do not reach for Provider<T> for anything else.
WorkManager workers are built through MetroWorkerFactory, a multibound Map<KClass<out ListenableWorker>, ChildWorkerFactory>. A worker contributes an @AssistedFactory ChildWorkerFactory keyed with @WorkerKey.
Required Gradle flag: metro.generateContributionProviders=true in gradle.properties. The Metro compiler plugin is auto-applied to every module by hedvig.gradle.plugin (configureMetro), so module build.gradle.kts files never apply it manually.
There is one NavDisplay, in HedvigApp, rendering a single back stack that is a SnapshotStateList<HedvigNavKey>. There is no NavController and no route strings.
Destinations are keys. A destination is a @Serializable class/object implementing HedvigNavKey:
@Serializable
data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination, TopLevelTabRoot {
override val topLevelTab = TopLevelTab.Insurances
}
@Serializable
internal data class InsuranceContractDetailKey(val contractId: String) : HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination {
override val owningTab = TopLevelTab.Insurances
override val syntheticParents = emptyList<HedvigNavKey>()
}Keys reachable cross-feature live in the feature's -navigation module and are public. Keys internal to a feature stay internal in the feature module.
Marker interfaces (in navigation-common) let :app reason about a key without depending on the feature:
TopLevelTabRoot— this key is the root of a bottom-nav tab (exposestopLevelTab).DeepLinkAncestry— how to build a synthetic back stack when this key is entered alone (exposesowningTab+syntheticParents).CrossSellEligibleDestination— the cross-sell sheet may appear here.SuppressesChatPushNotification— suppress chat push while this screen is shown.DeliberateLogoutOrigin— reaching logout from here is intentional; don't stash the session for restore.
The back stack API. Presenters and entries receive the Backstack interface (the :app BackstackController is bound to it). entries is the source of truth; helpers are extensions:
backstack.add(ChatKey(id)) // push
backstack.popBackstack() // pop one; at the root it finishes the app (Back/close exits)
backstack.popUpTo<TerminateInsuranceKey>(inclusive = true)
backstack.navigateAndPopUpTo<FooKey>(BarKey, inclusive = true)
backstack.navigateUp() // task-aware up (deep links)
backstack.removeAllOf<InboxKey>()Never hold a long-lived reference to entries snapshot contents; mutate through the controller/extensions so changes are observed and persisted.
Critical navigation rule — navigateUp is reserved for the top app bar back button:
backstack.navigateUp() may only be wired to the back arrow in a screen's top app bar. In every other case — "done"/"close"/"continue" buttons, success screens, dismissing a flow, programmatic pops after an action — call backstack.popBackstack() instead.
Why: navigateUp carries deep-link/synthetic-stack semantics (the :app BackstackController overrides it to rebuild a parent stack when the user arrived via a lone deep link). That behavior is correct for the top app bar's "up" affordance, but wrong for an in-content button, where the user expects a plain temporal pop of the current entry. Mixing them makes a button behave differently depending on how the screen was reached, and can diverge from predictive (system) back.
How to apply: When arranging the backstack for a flow, do it at navigation time (when navigating to a screen), so that a later plain popBackstack() and the system back gesture always land in the same place — never special-case the pop inside a button handler.
Registering destinations. Each feature exposes a fun EntryProviderScope<HedvigNavKey>.featureEntries(...) that calls entry<Key> { } for each of its screens. :app calls all of them from hedvigEntryProvider. Cross-feature navigation is done by :app passing navigateToX lambdas into each feature's entries function — features never import each other's keys.
fun EntryProviderScope<HedvigNavKey>.insuranceEntries(backstack: Backstack, /* navigateToX lambdas */) {
entry<InsurancesKey>(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) {
val vm: InsuranceViewModel = metroViewModel()
InsuranceDestination(viewModel = vm, /* ... */)
}
}Process-death survival is automatic but requires opt-in per module. Add navKeys() to the module's hedvig { } block. The navigation-keys-processor KSP processor finds every concrete @Serializable HedvigNavKey in the module and generates a Metro @ContributesIntoSet SerializersModule provider registering them polymorphically. :app merges all contributed modules and uses them to (de)serialize the back stack into the Activity's SavedStateRegistry. If you add a key but forget navKeys(), the app will crash on restore with a missing polymorphic serializer.
Multiple back stacks (tabs) are handled by the "runs model" in BackstackController/TopLevelRunLogic: Home's run is always at the base of entries; side tabs are parked in parkedRuns when you switch away and restored when you switch back. Tab state (saveable state + ViewModels) of parked runs is kept alive by the retained NavEntryDecorators, which consult allLiveContentKeys.
Where the heavy logic lives (read these, don't reinvent):
BackstackController.kt— per-Activity controller (owned byNavRetainedViewModel, a retainedViewModel; survives config changes, dies with its Activity), owns all nav state, tab switching, login/logout stash, deep-link routing, task-aware Up.NavigationStateBridge.kt— the single seam between Activity lifecycle and the controller (seed/restore/persist + escape-to-own-task handoff).SessionReconciler.kt— auth↔back-stack reconciliation; gates the splash viaisReady; forced logout.HedvigEntryProvider.kt— all destination registration and cross-feature lambda wiring.
Critical Metro KMP rule — never put a platform-overridable @ContributesBinding default in commonMain:
If an interface needs a different implementation per platform, bind it per-platform with explicit @Provides/@ContributesBinding in each platform source set (androidMain, iosMain/nativeMain, jvmMain) — the way :featureflags:feature-flags binds FeatureManager (UnleashFeatureFlagProvider on Android, provided via FeatureFlagsAndroidMetroProviders). Do not annotate a commonMain default impl with @ContributesBinding.
Why: a commonMain @ContributesBinding contributes that binding to every target. A platform-specific impl (e.g. an androidMain class) that forgets its own contribution annotation is then silently shadowed by the common default at runtime — no compile error, just wrong behavior. This actually happened: NoopPermissionManager (commonMain, isPermissionGranted always false) shadowed the real ActivityCompatPermissionManager on Android, so every notification sender behaved as if POST_NOTIFICATIONS was never granted. With per-platform binding instead, a missing binding is a compile-time error (loud), not a silent fallback.
How to apply: When you see a commonMain interface with platform-specific impls, bind per-platform and keep commonMain free of the default binding. If you must keep a commonMain default (Metro 1.1.1 has no rank), the platform override must carry @ContributesBinding(AppScope::class, replaces = [TheCommonDefault::class]) — but prefer the per-platform pattern, since replaces only protects the impls that exist today and silently re-breaks if a future platform impl forgets to contribute.
Each feature builds DeepLinkMatchers from its HedvigDeepLinkContainer patterns and contributes a DeepLinkMatcherProvider (@ContributesIntoSet). :app aggregates them into one HedvigDeepLinkMatcher. MainActivity forwards ACTION_VIEW intents as raw URI strings down a deepLinkChannel; HedvigApp matches each to a key and routes it through the controller once logged in. A DeepLinkAncestry key entered while logged out is held as pendingDeepLink and landed after login.
Data modules follow this structure when they are not KMP compatible:
data-{domain}/
├── data-{domain}-public/ # Interfaces/models
└── data-{domain}-android/ # Android implementation (optional)
And this structure when they are KMP compatible:
data-{domain}/
└── data-{domain}/ # Interfaces/models (KMP), androidMain for android-specific code
Patterns:
- Repository pattern with interfaces, bound via
@ContributesBinding(AppScope::class). - Apollo GraphQL queries/mutations.
- Use cases for business logic, injected directly as typed dependencies.
- Room database for local persistence.
When a Presenter or ViewModel needs a use case, inject it directly as a typed dependency — never abstract it into an anonymous suspend () -> T lambda. If two separate operations are needed (e.g. payin vs payout setup), create two separate, dedicated use case classes and two separate presenters. Do not create a shared interface just to enable reuse through a single presenter.
Critical architectural rule — never expose GraphQL types in public API:
GraphQL is an implementation detail of the data layer. Apollo-generated types (anything from the octopus package — queries, mutations, fragments, their .Data shapes, generated input/enum types, etc.) must not appear in the signatures of public interfaces, public functions, return types, or public data classes that other modules consume.
Use cases and repositories should:
- Run the GraphQL operation internally (
.query(...),.mutation(...),.safeExecute(),.safeFlow()). - Map the response into a project-owned type (a plain Kotlin
data class, sealed type, primitive, orUnitif only success/failure matters) before returning. - Keep the
octopus.*import confined to theinternalimpl class only.
This applies even when the GraphQL type happens to be a perfect shape — wrap it. It keeps the rest of the project insulated from schema churn, makes the data source swappable, and prevents GraphQL types from leaking into KMP/iOS-facing APIs.
Example — wrong:
interface SetArticleRatingUseCase {
// ❌ exposes Apollo-generated type
suspend fun invoke(name: String, rating: Int): Either<ErrorMessage, PuppyGuideEngagementMutation.Data>
}Example — right:
interface SetArticleRatingUseCase {
// ✅ project-owned shape; Unit because callers only care about success/failure
suspend fun invoke(name: String, rating: Int): Either<ErrorMessage, Unit>
}
internal class SetArticleRatingUseCaseImpl(...) : SetArticleRatingUseCase {
override suspend fun invoke(...) = either {
val data = apolloClient.mutation(PuppyGuideEngagementMutation(...)).safeExecute()
.mapLeft { ErrorMessage() }.bind()
ensure(data.puppyGuideEngagement.success) { ErrorMessage() }
}
}- Jetpack Compose - 100% Compose, no XML layouts
- Navigation 3 (
androidx.navigation3) - singleNavDisplayover an app-owned back stack - Material 3 - Window size classes, theming. Only used internally by our design-system-internals
- Coil - Image loading (SVG, GIF, PDF support)
- ExoPlayer (Media3) - Video playback
- Apollo GraphQL (v4.x) - Primary data source (Octopus backend)
- Normalized caching with
MemoryCacheFactory - Response-based code generation
- Client-side schema modifications
- Normalized caching with
- Ktor Client - HTTP client with custom interceptors
- Room Database - Local persistence
- Kotlin Coroutines - Asynchronous programming
- Kotlin Flow - Reactive streams
- Molecule - Reactive state management
- Arrow - Functional programming utilities (Either, raceN, etc.)
- Metro (
dev.zacsweers.metro) - compile-time DI, singleAppScopegraph - metro-viewmodel / metro-viewmodel-compose - ViewModel resolution on Android
- kotlinx.serialization - JSON serialization, polymorphic back-stack persistence
- Timber - Logging
- Datadog - Analytics and RUM
- Firebase - Crashlytics, Analytics, Messaging
- Kotlin Multiplatform - Many modules support KMP
The project uses custom Gradle convention plugins for consistent configuration:
-
hedvig.gradle.plugin - Base plugin with:
- Feature module dependency enforcement (features can't depend on features, except
-navigationmodules) - Auto-application of the Metro compiler plugin to every Kotlin module
- Auto-addition of metro-viewmodel deps to Android modules
- Ktlint configuration
- Common dependencies (Compose BOM, logging, tracking auto-injected)
- Feature module dependency enforcement (features can't depend on features, except
-
hedvig.android.application - Android app configuration
-
hedvig.android.library - Android library configuration
-
hedvig.jvm.library - Pure Kotlin (JVM) libraries
-
hedvig.multiplatform.library - KMP support
-
hedvig.multiplatform.library.android - adds an android target to a KMP module when it needs android-specific code
Use this in module build.gradle.kts files:
plugins {
id("hedvig.android.library")
id("hedvig.gradle.plugin")
}
hedvig {
apollo("octopus") // Enable Apollo codegen with the given generated package
compose() // Enable Jetpack Compose
serialization() // Enable kotlinx.serialization
androidResources() // Enable Android resources
room(false) { ... } // Enable Room database
navKeys() // Wire the nav-keys KSP processor (REQUIRED if the module declares HedvigNavKeys)
}
dependencies {
implementation(projects.coreCommonPublic)
implementation(projects.navigationCompose)
implementation(projects.designSystemHedvig)
}Configuration in .editorconfig:
- Code style:
ktlint_official - Indent: 2 spaces
- Max line length: 120 characters
- Trailing commas: enabled
- No wildcard imports
- Function naming: Composables exempted from normal rules
Always run ./gradlew ktlintFormat before committing.
- Composable functions: PascalCase (e.g.,
FeatureScreen()) - Regular functions: camelCase
- ViewModels:
{Feature}ViewModel - Presenters:
{Feature}Presenter - Destinations / nav keys:
{Feature}Key(e.g.InsurancesKey,ChatKey) - Entry functions:
{feature}Entries - Use cases:
{Action}{Domain}UseCase(e.g.,GetHomeDataUseCase)
# Download schema from backend
./gradlew downloadOctopusApolloSchemaFromIntrospection
# Generate GraphQL code (happens automatically on build)
./gradlew :feature-{name}:generateApolloSourcesSchema locations:
app/apollo/apollo-octopus-public/src/main/graphql/- Main schema- Client-side schema modifications supported via build plugin
Place .graphql files in module's src/main/graphql/. Apollo generates type-safe Kotlin code automatically. Keep generated octopus.* types confined to internal impl classes (see the data layer rule above).
# Run all tests
./gradlew test
# Run tests for specific module
./gradlew :feature-home:test
./gradlew :data-contract:test
# Run unit tests only
./gradlew testDebugUnitTestTest patterns:
- Unit tests:
src/test/kotlin/(orsrc/commonTest/for KMP) - Android tests:
src/androidTest/kotlin/ - Use Turbine for testing Flows
- Use test modules for shared test utilities
- Navigation invariants are covered by
ExhaustiveBackStackSerializationTest(everyHedvigNavKeyround-trips through serialization) andBackstackTest. If you add a key, these guard process-death survival.
GitHub Actions workflows (in .github/workflows/):
- pr.yml - PR checks (lint, test, build)
- staging.yml - Staging builds
- upload-to-play-store.yml - Production releases
- graphql-schema.yml - Schema updates
- strings.yml - Translation updates
- unused-resources.yml - Resource cleanup checks
- umbrella.yml - Comprehensive checks
- build-logic/convention/ - Gradle convention plugins (Metro wiring, feature isolation, the
hedvig {}DSL) - app/app/.../di/AppGraph.kt - the global Metro graph; app/app/.../di/ActivityRetainedGraph.kt - the per-Activity graph extension (built by app/app/.../navigation/NavRetainedViewModel.kt)
- app/app/.../navigation/BackstackController.kt - the per-Activity source of navigation truth
- app/navigation/ - navigation infrastructure + KSP processor
- docs/architecture/navigation-and-di.md - the deep design spec for navigation + DI
- settings.gradle.kts - Module discovery and configuration
- gradle.properties - Project properties (
metro.generateContributionProviders=truelives here) - .editorconfig - Code style configuration
- Create directory:
app/feature/feature-{name}/ - Add
build.gradle.kts:
plugins {
id("hedvig.android.library")
id("hedvig.gradle.plugin")
}
hedvig {
compose()
navKeys() // if the module declares any HedvigNavKey
apollo("octopus") // if needed
}
dependencies {
implementation(projects.coreCommonPublic)
implementation(projects.navigationCompose)
implementation(projects.designSystemHedvig)
}- Create standard structure:
ui/,navigation/,di/(contributions live next to the classes they bind via Metro annotations, not in a centraldimodule). - Define
@SerializableHedvigNavKeys. Put any cross-feature-reachable keys in afeature-{name}-navigationmodule. - Expose a
fun EntryProviderScope<HedvigNavKey>.{name}Entries(...)and call it fromHedvigEntryProviderin:app. - Module will be auto-discovered by
settings.gradle.kts.
- Define
@Serializable {Name}Key : HedvigNavKey(add marker interfaces as needed). - Register it:
entry<{Name}Key> { key -> ... }in the feature's entries function. - Resolve the ViewModel with
metroViewModel()/assistedMetroViewModel(...). - Ensure the module has
navKeys()so the key survives process death. - For cross-feature entry, thread a
navigateToXlambda from:apprather than importing the key.
- Create
.graphqlfile insrc/main/graphql/. - Enable Apollo in
build.gradle.kts:hedvig { apollo("octopus") }. - Build generates type-safe Kotlin code.
- Use the generated query in an internal repository/use case impl; return a project-owned type.
Feature flags are backed by Unleash. Before adding or changing a flag, read
app/featureflags/feature-flags/FEATURE_FLAG_DEFAULTS.md — it explains why we never use
the SDK's defaultValue parameter (Unleash Android SDK issue #141), how a flag's value is
resolved when Unleash has never been fetched, and when bootstrap is required.
To add a new flag:
- Add the enum value to
Feature(commonMain), named to mirror its Unleash key polarity (ENABLE_Xforenable_x,DISABLE_Xfordisable_x), with a short explanation. - Map it to its raw Unleash key in
Feature.unleashKey(androidMain). UnleashFeatureFlagProviderneeds no change — it returns the rawisEnabled(key)for every flag. At the read site, use the value directly for a positive flag, or invert it (if (!disableX)) for a kill switch.
IMPORTANT — always reconsider bootstrap when adding a feature: Decide what the flag
should resolve to when it has never been fetched (offline first launch / fresh install
before the first poll returns). If the natural polarity default is acceptable, do nothing.
If a rollout needs the opposite default, add a Toggle(...) to the bootstrap list in
HedvigUnleashClient.start(...). Never bootstrap an app-gating flag (e.g.
UPDATE_NECESSARY) into its blocking state — that can brick the app for offline users.
# Download latest translations
./gradlew downloadStringsIMPORTANT: String resource XML files (strings.xml) are fully managed by Lokalise and regenerated on every ./gradlew downloadStrings run. Never add new strings directly to any strings.xml file — they will be overwritten and lost.
When new UI text is needed that does not yet exist as a string resource:
- Hardcode the English string directly in the Kotlin/Compose code.
- Add a
// TODO: Add "<English text>" / "<Swedish text>" to Lokalisecomment on the same line or the line above.
Example:
// TODO: Add "This is some text for feature X" / "Detta är lite text för feature X" to Lokalise
Text("This is some text for feature X")Build fails with "Cannot find schema":
./gradlew downloadOctopusApolloSchemaFromIntrospectionMissing translations:
./gradlew downloadStringsApp crashes on process-death restore / "polymorphic serializer not found":
- The module declaring the key is missing
navKeys()in itshedvig {}block, or the key isn't@Serializable.
Metro "cannot find binding" / duplicate binding errors:
- Check the type is contributed (
@ContributesBinding/@Provides/@ContributesIntoMap) intoAppScope. - Confirm
metro.generateContributionProviders=trueis present ingradle.properties.
Runtime crash IllegalArgumentException: Unknown model class …ViewModel (at MetroViewModelFactory):
- The VM's contribution wasn't merged into
:app's graph. The usual cause is aninternalgenerated contribution producing aninternal(cross-module-invisible)metro/hintsmarker — the:viewmodel-processormust generate itpublic. Seedocs/architecture/navigation-and-di.md§I.3.1. - To diagnose:
javap -p -conapp/app/build/tmp/kotlin-classes/debug/.../AppGraph$Impl$ActivityRetainedGraphImpl.classand check the VM is in theviewModelProvidersmap. Note:app:compileDebugKotlinis cacheable — verify it actually executed (notFROM-CACHE) before trusting the output.
Dependency resolution failures:
- Check
~/.gradle/gradle.propertieshas GitHub PAT withread:packages.
Ktlint formatting errors:
./gradlew ktlintFormatModules are auto-discovered via settings.gradle.kts:
- All directories under
app/withbuild.gradle.ktsare included - Micro-apps under
micro-apps/are manually included - No need to manually register new modules
- Build cache enabled via Gradle Develocity
- Configuration cache enabled (incubating)
- Type-safe project accessors for faster builds
- Parallel builds supported
- Dependency analysis plugin monitors dependency health