Automating Jetpack Compose Navigation Registration with KSP

Core Capabilities of Kotlin Symbol Processing

KSP functions as a compiler plugin operating during the pre-compilation phase. Key constraints and features include:

  • Acts as a drop-in alternative to kapt and Java annotation processors.
  • Executes before semantic analysis and bytecode generation.
  • Supports read-only accesss to source symbols; only new files can be written.
  • Iterative: supports multiple rounds where output from one round feeds into the next.
  • Configurable via Gradle DSL without modifying existing source trees.

Project Configuration

Add the KSP plugin to the root build.gradle.kts:

plugins {
    id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
    id("org.jetbrains.kotlin.jvm") version "1.9.20" apply false
}

Create a dedicated library module for the processor. In its build.gradle.kts:

plugins {
    `java-library`
    kotlin("jvm")
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:1.9.20-1.0.14")
}

Processor Foundation

Implement the processing interface and factory:

class RouteScannerProcessor(
    private val generator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> = emptyList()
}

class RouteScannerProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return RouteScannerProcessor(environment.codeGenerator, environment.logger)
    }
}

Register the provider at src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider with the fully qualified name of RouteScannerProvider.

Enable the processor in target modules:

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    ksp(project(":navigation-ksp-module"))
}

Streamlining Compose Navigation

Standard Jetpack Compose navigation requires manual routing inside NavHost. Common friction points include:

  • Forgetting to register destination composibles leads to runtime crashes.
  • String-based routes introduce typo vulnerabilities and lack compile-time safety.
  • Arguement serialization via complex query parameters or bundle parsing becomes verbose.

Annotation-Driven Architecture

Define a sealed interface hierarchy to represent typed navigation payloads:

sealed interface ScreenPath {
    data class UserProfile(val userId: String) : ScreenPath
    data class SettingsScreen(val theme: String) : ScreenPath
}

Create a marker annotation that binds a composable function to a specific path type:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoRoute(val pathType: KClass<out ScreenPath>)

Leverage fully qualified names to generate type-safe route strings without manual configuration. Each annotated method receives a standard NavBackStackEntry parameter.

Processing Logic

The symbol processor scans for @AutoRoute annotations, extracts target types, and generates two artifacts using KotlinPoet:

  1. An extension function on NavGraphBuilder to auto-register destinations.
  2. An object containing inline constants for each registered route.
class RouteScannerProcessor(
    private val generator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    private var hasRun = false

    override fun process(resolver: Resolver): List<KSAnnotated> {
        if (hasRun) return resolver.getSymbolsWithAnnotation("com.app.navigator.annotation.AutoRoute").toList()
        
        val targets = resolver.getSymbolsWithAnnotation("com.app.navigator.annotation.AutoRoute")
            .filterIsInstance<KSFunctionDeclaration>()
            .toList()

        if (targets.isEmpty()) {
            hasRun = true
            return emptyList()
        }

        val graphExtensionBuilder = FileSpec.builder("com.app.navigator.generated", "NavGraphExtensions")
        val constantBuilder = FileSpec.builder("com.app.navigator.constants", "RouteConstants")

        val processedNodes = mutableListOf<KSNode>()
        val registerBlock = FunSpec.builder("registerAllScreens")
            .addModifiers(KModifier.PUBLIC)
            .receiver(ClassName("androidx.navigation.compose", "NavGraphBuilder"))

        targets.forEach { funcDecl ->
            funcDecl.annotations.find { it.shortName.getShortName() == "AutoRoute" }?.let { ann ->
                val routeTypeInfo = ann.arguments.first().value as KSType
                val className = routeTypeInfo.toClassName()
                
                processedNodes.add(funcDecl.containingFile)
                
                // Generate route registration
                val screenFunc = MemberName(className.packageName.asString(), className.simpleName)
                registerBlock.addStatement(
                    "%M(%L) { %M(it) }",
                    MemberName("androidx.navigation.compose", "composable"),
                    className.canonicalName,
                    screenFunc
                )

                // Generate constant mapping
                constantBuilder.addProperty(
                    PropertySpec.builder(className.simpleName, ClassName("kotlin", "String"))
                        .receiver(ClassName("com.app.navigator.constants", "RouteConstants"))
                        .initializer("const %P", className.canonicalName)
                        .build()
                )
            }
        }

        graphExtensionBuilder.addFunction(registerBlock.build()).build().writeTo(generator, true, processedNodes)
        constantBuilder.build().writeTo(generator, true, processedNodes)
        
        hasRun = true
        return targets.filter { !it.validate() }
    }
}

Generated Outputs

The processor produces production-ready code eliminating boilerplate:

NavGraphExtensions.kt

public fun NavGraphBuilder.registerAllScreens() {
  composable("com.app.navigator.model.UserProfile"){ UserProfileScreen(it) }
  composable("com.app.navigator.model.SettingsScreen"){ SettingsScreenContent(it) }
}

RouteConstants.kt

public object RouteConstants {
  public const val UserProfile: String = "com.app.navigator.model.UserProfile"
  public const val SettingsScreen: String = "com.app.navigator.model.SettingsScreen"
}

Integration Point

Apply the generated extension directly within the navigation host declaration:

NavHost(
    navController = navigationController,
    startDestination = RouteConstants.UserProfile
) {
    registerAllScreens()
}

This pattern shifts routing maintenance from manual string management to a compile-safe, annotation-driven workflow. Type validation occurs during build time, and argument handling remains decoupled through strongly-typed sealed interfaces.

Tags: kotlin KSP Jetpack Compose Navigation Android Development

Posted on Sat, 06 Jun 2026 18:33:31 +0000 by gtzpower