Consumer contract¶
The processor emits Kotlin source files that reference fully qualified type names from the consumer project. Those names are not configurable. They are ClassName
constants in codegen/processor/src/main/kotlin/io/github/thomaskioko/codegen/processor/util/External.kt, and every generator imports from there.
What is hardcoded¶
The hardcoded names fall into six groups, organised by the role each plays at runtime.
Decompose. com.arkivanov.decompose.ComponentContext is the single Decompose type the codegen references. It appears as the @Provides parameter on every generated
graph factory function so every presenter on the graph can request it. AppRootBindingGenerator also references it as a constructor parameter on the generated
binding container function.
Metro. Every generated annotation references one of dev.zacsweers.metro.{ContributesTo, GraphExtension, Provides, IntoSet, BindingContainer, SingleIn}.
BindingContainer is used by UiBindingGenerator and AppRootBindingGenerator. SingleIn is used only by AppRootBindingGenerator to scope the generated provider
to the parent scope. The rationale for the binding container generators is in
generators.md.
Consumer navigation primitives under com.thomaskioko.tvmaniac.navigation:
NavRoute,NavRoot,BaseRoute. Marker interfaces for routable targets. The processor does not check inheritance at parse time; it relies on the consumer's compiler to fail if the route class does not implement the expected supertype.NavDestinationand the nestedNavDestination.Screen,NavDestination.Overlay,NavDestination.TabRoot. The multibinding entry types the generated@Provides @IntoSetfunctions return. The kind branches inNavDestinationBindingGeneratorandTabDestinationBindingGeneratorswitch between these subclasses.NavRouteBinding,NavRootBinding. The route serializer multibinding entries that feed polymorphic state restoration. See State save and restore below.RootChild,ScreenDestination,SheetChild,SheetDestination. The slot and stack child wrappers the generated factory lambdas produce.
Consumer UI primitives under com.thomaskioko.tvmaniac.navigation.ui: ScreenContent and SheetContent. These are the multibinding entry types UiBindingGenerator
returns. Their constructor signatures (matches: (RootChild) -> Boolean, content: @Composable (RootChild, Modifier) -> Unit for ScreenContent; (SheetChild) -> Unit
for SheetContent) are part of this contract. Changing them in the consumer breaks every generated UI binding.
Consumer home navigation under com.thomaskioko.tvmaniac.home.nav: TabChild. The tab root factory lambda always wraps its produced presenter as TabChild(...).
Compose. androidx.compose.ui.Modifier is referenced by UiBindingGenerator on the screen path (overlay renderers do not forward a modifier) and by
AppRootUiBindingGenerator on the generated extension. androidx.compose.runtime.Composable is referenced only by AppRootUiBindingGenerator, which emits the
annotation directly on the generated AppRootContent extension.
App root primitives. Whatever package the consumer chose. The @AppRoot and @AppRootUi generators do not reference any consumer-specific name; the bound
interface (RootPresenter in Tv Maniac), the implementation type, and the host composable all flow through from the annotated symbol via the parser. Consumers can
rename or repackage these without touching the codegen.
Feature flag primitives¶
The feature flag codegen tier in codegen/featureflag-processor adds two consumer-side hardcoded names, listed in
codegen/featureflag-processor/src/main/kotlin/io/github/thomaskioko/codegen/featureflag/processor/util/External.kt.
Consumer feature flag primitives under com.thomaskioko.tvmaniac.featureflags:
FeatureFlag<T>. The generic interface consumers inject.FeatureFlagBindingGeneratorparameterises it withBooleanin every generated@Providesfunction and@IntoSetrebind. Changing the package or renaming the interface breaks every generated binding.FeatureFlagFactory. The construction surface. The generator emitsfactory.boolean(key, title, description, defaultValue, dateAdded)calls referencing this type. The factory must declare aboolean(...)method with that exact signature; the generator does not branch on alternative shapes.
Feature flag Metro primitives. Same dev.zacsweers.metro package as the navigation contract, but the feature flag generator also references AppScope directly as
the scope marker for @ContributesTo(AppScope::class) and @SingleIn(AppScope::class), and Qualifier to stamp the generated <BaseName>Qualifier annotation
(FeatureFlagQualifierGenerator). Consumers that contribute their feature flags into a different scope must fork the processor and edit the matching External.kt constant.
kotlinx.datetime. kotlinx.datetime.LocalDate is the only datetime reference. The generator parses the annotation's dateAdded ISO String at codegen time and emits
a LocalDate(year, month, day) constructor call. Consumers must keep kotlinx-datetime on the classpath of every module that declares @FeatureFlag anchors.
The full feature flag codegen reference lives in featureflag.md. The validation rules and error markers are documented there.
Runtime flow¶
The pieces above interact at runtime when the user navigates to a destination. Tracing one navigation request from start to render makes the contract concrete.
- The user calls
navigator.navigateTo(ShowDetailsRoute(showId = 42))somewhere in a presenter. - The consumer's
Navigatorlooks the route up in the activity scope'sSet<NavDestination<*>>multibinding. Each entry is a generatedNavDestination.Screen,NavDestination.Overlay, orNavDestination.TabRoot. The navigator picks the entry whoserouteClassmatches the route's runtime class. - The navigator inspects the entry's subclass.
NavDestination.Screengoes onto the back stack.NavDestination.Overlaygoes into the overlay slot.NavDestination.TabRootswitches the active tab. - The navigator calls the entry's factory lambda with
(route, componentContext). The factory lambda is the body insideprovide<BaseName>NavDestinationthat the codegen emitted. For a parameterized presenter it casts the route, reads the property the parser recorded, and callsfactory.create(route.<routeProperty>). For a presenter with no runtime parameters it just reads the presenter off the graph. Either way, the lambda wraps the produced presenter in aScreenDestination(for stack and overlay) or aTabChild(for tabs). - The slot or stack host now holds a
RootChild. To render it as a Compose UI, the host iterates the activity scope'sSet<ScreenContent>(orSet<SheetContent>) multibinding. Each entry is a generatedScreenContent(orSheetContent). The host calls each entry'smatchespredicate against the active child and picks the entry that returnstrue. - The host invokes the matched entry's
contentlambda with the active child and (for screens) aModifier. The lambda casts the child, casts its presenter, and calls the annotated composable. The composable renders.
The codegen's job is to produce steps 4 and 6: the destination factory lambda and the UI content lambda. Steps 1, 2, 3, 5 live in the consumer project.
State save and restore¶
When the operating system kills a backgrounded process, the navigation state must survive so the app can rebuild the same back stack on relaunch. Decompose serialises the
back stack as a list of route instances; the consumer is responsible for choosing a serializer for each route type. This is where NavRouteBinding and NavRootBinding
come in.
Every generated destination binding contributes one NavRouteBinding<*> (for SCREEN and OVERLAY) or NavRootBinding<*> (for TAB_ROOT) into the activity scope
multibinding. Each entry pairs a KClass with the route's KSerializer:
@Provides
@IntoSet
public fun provideShowsRouteBinding(): NavRouteBinding<*> =
NavRouteBinding(ShowsRoute::class, ShowsRoute.serializer())
The consumer's serialization layer iterates this multibinding to build a SerializersModule keyed by route class. When Decompose persists the back stack on process death
and restores it on relaunch, it uses that module to encode and decode each route. The codegen never serialises anything itself; it only contributes the entries the
consumer's serialization layer needs.
Tabs use the parallel NavRootBinding so that NavRoot instances participate in the same polymorphic save and restore alongside NavRoute instances. Tab bindings additionally contribute the NavRoot singleton itself into Set<NavRoot>, which navigators iterate to enumerate the available tabs without consulting destination factories.
Why constants rather than configuration¶
Hardcoding the consumer type names rather than reading them from KSP processor options is a deliberate decision, for three reasons.
- Single source of truth. Every generator looks up the same
ClassName. A typo or a rename in the consumer project produces one compile error in the generator output, not a per feature search. - No KSP options to plumb. Configurability would mean reading KSP processor options at every call site, plus a corresponding Gradle DSL knob in
useCodegen(). The cost is real and the only known consumer is Tv Maniac. - Fork friendly without being fork tolerant. A non Tv Maniac consumer is expected to fork the processor and edit
External.ktdirectly. The constants areinternal, deliberately low friction to change in source, and impossible to override without a fork. That keeps the upstream processor opinionated and small.
What a fork would change¶
For a project with a different navigation primitive set, the surface to edit is External.kt plus the corresponding fakes in
codegen/processor-test/src/test/kotlin/io/github/thomaskioko/codegen/processor/TestStubs.kt. The generators themselves do not need touching as long as the consumer's
primitives keep the same constructor signatures (for example NavDestination.Screen(routeClass, factory), ScreenContent(matches, content)).
If a fork's primitives have a different structure (for example a single Destination type instead of the Screen / Overlay / TabRoot split), the generators do need
editing. The ScreenKind enum in NavData plus the subclass switch in NavDestinationBindingGenerator.destinationFun is the seam for that change.