Skip to content

Latest commit

 

History

History
419 lines (326 loc) · 11.8 KB

File metadata and controls

419 lines (326 loc) · 11.8 KB

Custom Annotation Use Cases

This document describes the custom annotations, what each one generates, and example usages. These use cases are implementation-agnostic -- they can be fulfilled by KSP processors, a compiler plugin feature, or any other code generation mechanism.


@ContributesFeatureFlag

Contributes a FeatureFlag object into Set<FeatureFlag> via @IntoSet. Always scoped to AppScope since all feature flags live in the app scope.

The description, removeBy, and fakeModeValues parameters are metadata only -- used by linters and tooling, not by the generated binding.

Annotation definition

annotation class ContributesFeatureFlag(
  val description: String,
  val removeBy: Date,
  val fakeModeValues: Array<FlagValue> = [],
)

Target

object classes implementing FeatureFlag.

Usage

@ContributesFeatureFlag(
  description = "Enables new checkout flow",
  removeBy = Date(Month.June, 1, 2026),
)
object NewCheckoutFlag : FeatureFlag { ... }

Generated output (pseudo)

@ContributesTo(AppScope::class)
@BindingContainer
object NewCheckoutFlagBindingContainer {
  @Provides @IntoSet
  fun contributeFlag(): FeatureFlag = NewCheckoutFlag
}

@ContributesDynamicConfigurationFlag

Identical to @ContributesFeatureFlag but for permanent flags that should not be removed. Separate annotation for lint/policy enforcement.

Annotation definition

annotation class ContributesDynamicConfigurationFlag(
  val description: String,
  val fakeModeValues: Array<FlagValue> = [],
)

Target

object classes implementing FeatureFlag.

Usage

@ContributesDynamicConfigurationFlag(
  description = "Controls max retry count for sync operations",
)
object SyncRetryConfig : FeatureFlag { ... }

Generated output (pseudo)

@ContributesTo(AppScope::class)
@BindingContainer
object SyncRetryConfigBindingContainer {
  @Provides @IntoSet
  fun contributeFlag(): FeatureFlag = SyncRetryConfig
}

@ContributesRobot

Generates a contributed interface with an accessor method that exposes the robot class on the dependency graph. If the robot is not already injectable with @Inject, the generated interface also includes a @Provides method that constructs the robot. The target must extend ScreenRobot or ComposeScreenRobot.

Annotation definition

annotation class ContributesRobot(
  val scope: KClass<*>,
)

Target

Classes extending ScreenRobot or ComposeScreenRobot. The robot constructor does not need @Inject; when @Inject is absent, constructor parameters are injected through the generated @Provides function. If a robot declares multiple constructors, annotate the robot class or the constructor Metro should use with @Inject.

Usage

@ContributesRobot(AppScope::class)
class LoginScreenRobot(
  private val authRobot: AuthRobot,
) : ComposeScreenRobot<LoginScreenRobot>() {
  fun tapSignIn() { clickView(R.id.sign_in) }
  fun seeWelcomeMessage() { seeView(R.id.welcome) }
}

Generated output (pseudo)

@ContributesTo(AppScope::class)
interface LoginScreenRobotComponent {
  fun getcom_squareup_example_LoginScreenRobotComponent(): LoginScreenRobot

  @Provides
  fun provideLoginScreenRobotComponent(
    authRobot: AuthRobot,
  ): LoginScreenRobot = LoginScreenRobot(authRobot)
}

This exposes the robot as an accessor on the merged graph, making it injectable at the test site. The generated provider supplies the robot binding only when Metro cannot already use @Inject. Each robot gets its own contributed interface with a package-qualified accessor method name derived from the robot fqcn, avoiding collisions when different packages contribute robots with the same class name.


@ContributesService

Contributes a Retrofit service binding to a scope. Handles two distinct paths: real services (interface declarations) and fake services (classes that replace a real service in debug builds).

The target must have exactly one @Qualifier annotation (e.g., @RetrofitAuthenticated, @RetrofitUnauthenticated) which determines which ServiceCreator is injected.

Annotation definition

annotation class ContributesService(
  val scope: KClass<*>,
  val replaces: Array<KClass<*>> = [],
)

Target

  • Real services: Retrofit service interfaces with a qualifier annotation.
  • Fake services: Classes in debug source sets that implement a real service interface, using replaces to specify which service they replace. @Inject is optional; if it is absent, the generated binding container includes a @Provides constructor provider. Fake services with multiple constructors must annotate the service class or the constructor Metro should use with @Inject.

Usage — Real service

@ContributesService(AppScope::class)
@RetrofitAuthenticated
interface RemoteDeviceApiService {
  @POST("/v2/devices")
  fun updateDevice(@Body request: UpdateDeviceRequest): Response<UpdateDeviceResponse>
}

Generated output — Real service (pseudo)

In release builds:

@ContributesTo(AppScope::class)
@BindingContainer
object ServiceContribution {
  @Provides @SingleIn(AppScope::class)
  fun provideRemoteDeviceApiService(
    @RetrofitAuthenticated serviceCreator: ServiceCreator,
  ): RemoteDeviceApiService {
    return serviceCreator.create(RemoteDeviceApiService::class.java)
  }
}

In debug builds, an additional @FakeMode safety check is generated to catch cases where fake mode is enabled but no fake service was provided:

@ContributesTo(AppScope::class)
@BindingContainer
object ServiceContribution {
  @Provides @SingleIn(AppScope::class)
  fun provideRemoteDeviceApiService(
    @RetrofitAuthenticated serviceCreator: ServiceCreator,
    @FakeMode isFakeMode: Boolean,
  ): RemoteDeviceApiService {
    check(!isFakeMode) { "No fake service provided for RemoteDeviceApiService." }
    return serviceCreator.create(RemoteDeviceApiService::class.java)
  }
}

Usage — Fake service

// In src/debug
@SingleIn(AppScope::class)
@ContributesService(AppScope::class, replaces = [RemoteDeviceApiService::class])
class FakeRemoteDeviceApiService(
  factory: MockServiceHelper.Factory,
) : RemoteDeviceApiService {
  private val mockHelper = factory.create<RemoteDeviceApiService>()
  override fun updateDevice(request: UpdateDeviceRequest) =
    mockHelper.mockResponse { UpdateDeviceResponse() }.updateDevice(request)
  // ...
}

Generated output — Fake service (pseudo)

The generated fake service binding container replaces the real service contribution. It re-creates the real service binding under a @RealService qualifier, then adds a switcher that picks real or fake based on the @FakeMode boolean. If the fake service is not already injectable with @Inject, the container also provides the fake service by calling its constructor:

@ContributesTo(
  scope = AppScope::class,
  replaces = [
    RemoteDeviceApiService::class,
    RemoteDeviceApiService.ServiceContribution::class,
  ],
)
@BindingContainer
object ServiceContribution {
  // Real service still available under @RealService qualifier
  @Provides @SingleIn(AppScope::class) @RealService
  fun provideRemoteDeviceApiService(
    @RetrofitAuthenticated serviceCreator: ServiceCreator,
  ): RemoteDeviceApiService {
    return serviceCreator.create(RemoteDeviceApiService::class.java)
  }

  // Switcher: returns fake or real based on runtime @FakeMode flag
  @Provides
  fun provideFakeOrRealRemoteDeviceApiService(
    @RealService realService: Provider<RemoteDeviceApiService>,
    fakeService: Provider<FakeRemoteDeviceApiService>,
    @FakeMode isFakeMode: Boolean,
  ): RemoteDeviceApiService {
    return if (isFakeMode) fakeService.get() else realService.get()
  }

  @Provides
  fun provideContributedServiceReplacement(
    factory: MockServiceHelper.Factory,
  ): FakeRemoteDeviceApiService {
    return FakeRemoteDeviceApiService(factory)
  }
}

The qualifier (e.g., @RetrofitAuthenticated) is read from the replaced service interface's annotations. The fake class must extend all replaced service types. Both real and fake must use the same scope.


@ContributesMultibindingScoped

Contributes a class implementing Scoped into a Set<Scoped> qualified with @ForScope. The scope value is used for both the contribution target and the @ForScope qualifier, since they are always the same. If the class is not already injectable with @Inject, the plugin also generates a @Provides function that calls the constructor.

Annotation definition

annotation class ContributesMultibindingScoped(
  val scope: KClass<*>,
)

Target

Classes implementing Scoped.

Usage

@ContributesMultibindingScoped(AppScope::class)
class AppLifecycleLogger(
  private val logger: Logger,
) : Scoped {
  override fun onEnterScope(scope: MortarScope) { logger.log("entered") }
  override fun onExitScope() { logger.log("exited") }
}

Generated output (pseudo)

@ContributesTo(AppScope::class)
@BindingContainer
interface MultibindingScopedContribution {
  companion object {
    @Provides
    fun provideContributedMultibindingScoped(logger: Logger): AppLifecycleLogger {
      return AppLifecycleLogger(logger)
    }
  }

  @Binds @IntoSet @ForScope(AppScope::class)
  fun bindsAppLifecycleLogger(target: AppLifecycleLogger): Scoped
}

Note: the scope from the annotation (AppScope) is used in two places -- as the @ContributesTo scope and as the @ForScope qualifier value. If the class or selected constructor is annotated with @Inject, the constructor provider is not generated. Classes with multiple constructors must mark the class or selected constructor with @Inject.


@DevelopmentAppComponent

Generates a complete Metro @DependencyGraph component for development/demo apps. Eliminates the need to manually define the application component interface, its factory, and its factory provider. The scope is always AppScope.

The annotated class must extend DevelopmentApplication, which provides a reflection-based provideGraphFactory() method to locate the generated factory at runtime.

Annotation definition

annotation class DevelopmentAppComponent(
  val generateLoggedInComponent: Boolean = true,
  val featureScope: KClass<*> = Unit::class,
  val featureComponent: KClass<*> = Unit::class,
) {
  interface Factory {
    fun create(application: Application): Any
  }
}

Target

Classes extending DevelopmentApplication.

Usage

@DevelopmentAppComponent
class MyDemoApp : DevelopmentApplication()

Generated output (pseudo)

Inside the annotated class, two nested types are generated:

@SingleIn(AppScope::class)
@DependencyGraph(AppScope::class)
interface MetroComponent {
  @DependencyGraph.Factory
  interface Factory : DevelopmentAppComponent.Factory {
    override fun create(@Provides application: Application): MetroComponent
  }
}

Metro processes the generated @DependencyGraph interface and creates the implementation class. All @ContributesTo(AppScope::class) contributions are automatically merged into the graph. The application parameter is provided as a binding via @Provides.

At runtime, DevelopmentApplication.provideGraphFactory() uses reflection to find the Metro-generated Factory.Impl singleton and returns it as a DevelopmentAppComponent.Factory.

generateLoggedInComponent = false

When generateLoggedInComponent is set to false, the @DependencyGraph annotation includes excludes to remove the logged-in scope infrastructure:

@DependencyGraph(
  scope = AppScope::class,
  excludes = [LoginScreenModule::class, DevelopmentLoggedInComponent::class],
)
interface MetroComponent { ... }

Validation

  • The annotated class must extend DevelopmentApplication.
  • If featureComponent is specified, featureScope must also be specified (and vice versa).