Overview

Wire's Kotlin generator turns proto enums into plain Kotlin enums, but the custom options riding alongside them — per-constant metadata, per-enum resource flags — stay unread. Someone still has to look up width, Res.string.ratio_1_1, or Res.drawable.ratio_1_1 by hand, or trust a hand-rolled task that silently defaults a missing value instead of failing.

proto-extended reads those options directly from your .proto sources and generates the properties for you, with strict metadata and base-resource validation that fails the build — naming the enum, constant and field or resource — instead of shipping a silently wrong value.

BlockTaskOutput fileRuns in
metadata { } generateProtoMetadata ProtoEnumMetadata.kt any module — pure Kotlin, wired into commonMain / main
resources { } generateProtoResources ProtoEnumResources.kt KMP module — pure Compose resources, wired into commonMain
Shared resource types

Generated labels and icons return Compose resource objects, never Android R integers.

NO-SOURCE by default

An unconfigured block's task reports NO-SOURCE and is skipped. No "must configure both" trap.

Fails loudly on drift

Metadata and base-resource validation turn silent drift into build failures naming the enum and constant.

Deterministic output

Enums sorted by qualified name, imports sorted — reordering unrelated protos never busts the build cache.

Configuration cache compatible

Neither task references Project at execution time.

Installation

Add the Plugables Maven repository to settings.gradle.kts:

settings.gradle.kts
pluginManagement {
    repositories {
        maven("https://maven.rohittp.com")
        mavenCentral()
        gradlePluginPortal()
    }
}

Then apply the plugin in whichever module needs it:

build.gradle.kts
plugins {
        id("com.rohittp.plugables.proto-extended") version "latest"
}

Applying the plugin registers both generateProtoMetadata and generateProtoResources unconditionally, in every module. Neither runs until its block sets a protoDir — the unconfigured one reports NO-SOURCE and costs nothing.

Automatic wiring needs a recognised plugin

Generated sources are wired into a source set automatically only when Kotlin Multiplatform, Kotlin JVM, or an Android plugin is also applied to the module — see KMP setup. Compose resources require Kotlin Multiplatform; metadata also supports Kotlin/JVM and Android. Without one of those, the block still generates a valid .kt file, but a build warning tells you it isn't on any source set.

Declaring resource flags

resources { } decides which enums get generated accessors by reading a custom extend google.protobuf.EnumOptions field — not a leading comment. A comment is invisible to protoc and validated by nothing; a typo in a custom option is a schema-linker error naming the file and line instead of a silent no-op.

Declare the extension once per proto source tree, in a file every enum-bearing proto imports:

gen_options.proto
syntax = "proto3";
package gen;
import "google/protobuf/descriptor.proto";

message ResourceGen {
  bool display_name = 1;
  bool icon = 2;
}

extend google.protobuf.EnumOptions {
  optional ResourceGen resources = 50100;
}

Flags are named for the generated Kotlin property (display_name, icon) rather than the Android resource folder (string, drawable) — the property name is the part that stays meaningful now that the plugin is multiplatform-aware. Field number 50100 only needs to be unique among extensions of EnumOptions; pick any unused number.

Extends EnumOptions, not EnumValueOptions

(gen.resources) applies to the enum itself (google.protobuf.EnumOptions), unlike a metadata option such as ratio_meta, which applies to each constant (google.protobuf.EnumValueOptions). One option per enum, not per constant.

Metadata generation

metadata { } walks every proto file, collects top-level and nested enums, and emits one flat extension property per field of any extend google.protobuf.EnumValueOptions message that appears on the enum's constants. The output is pure Kotlin — no Android import, compiles for every KMP target.

aspect_ratio.proto
import "gen_options.proto";
option java_package = "com.example.model";

message RatioMeta {
  int32 width = 1;
}

extend google.protobuf.EnumValueOptions {
  optional RatioMeta ratio_meta = 50001;
}

enum AspectRatio {
  RATIO_1_1 = 0 [(ratio_meta) = { width: 1 }];
  RATIO_16_9 = 1 [(ratio_meta) = { width: 16 }];
}

generates:

generated kotlin — ProtoEnumMetadata.kt
// GENERATED — do not edit. Source: proto enum definitions.
package com.example.generated

import com.example.model.AspectRatio

val AspectRatio.width: Int
    get() = when (this) {
        AspectRatio.RATIO_1_1 -> 1
        AspectRatio.RATIO_16_9 -> 16
    }

The metadata block exposes:

PropertyTypeRequiredDescription
protoDir DirectoryProperty Yes Directory of .proto sources. Setting it is what enables this block.
basePackage Property<String> Yes Package of the generated file.
outputDir DirectoryProperty No Defaults to build/generated/source/protoExtended/metadata. Rarely overridden.
build.gradle.kts
protoExtended {
    metadata {
        protoDir.set(layout.projectDirectory.dir("proto"))
        basePackage.set("com.example.generated")
    }
}

Supported scalar types, carried over unchanged from every proto3 field the plugin reads:

ProtoKotlin
stringString
doubleDouble
floatFloat
int32, uint32Int
int64, uint64Long
boolBoolean

Compose Multiplatform resource generation

resources { } reads the (gen.resources) option declared in gen_options.proto and emits a StringResource and/or DrawableResource accessor per flagged enum in commonMain. The resource name is the enum constant name lowercased.

aspect_ratio.proto
// same file as above, with the resource option added to the enum
enum AspectRatio {
  option (gen.resources) = { display_name: true, icon: true };
  RATIO_1_1 = 0;
  RATIO_16_9 = 1;
}

generates:

generated kotlin — ProtoEnumResources.kt
// GENERATED — do not edit. Source: proto enum definitions.
package com.example.generated.resources

import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import com.example.model.AspectRatio

val AspectRatio.displayName: StringResource
    get() = when (this) {
        AspectRatio.RATIO_1_1 -> Res.string.ratio_1_1
        AspectRatio.RATIO_16_9 -> Res.string.ratio_16_9
    }

val AspectRatio.icon: DrawableResource
    get() = when (this) {
        AspectRatio.RATIO_1_1 -> Res.drawable.ratio_1_1
        AspectRatio.RATIO_16_9 -> Res.drawable.ratio_16_9
    }

The resources block exposes the same three properties as metadata, plus the base resource directory:

PropertyTypeRequiredDescription
composeResourcesDir DirectoryProperty No Defaults to src/commonMain/composeResources; base strings and drawables are validated here.
outputDir DirectoryProperty No Defaults to build/generated/source/protoExtended/resources.
build.gradle.kts
protoExtended {
    resources {
        protoDir.set(layout.projectDirectory.dir("proto"))
        basePackage.set("com.example.generated.resources")
    }
}

compose.resources {
    packageOfResClass = "com.example.generated.resources"
}

Localization

The plugin maps enum constants to Compose resource keys; Compose performs locale selection. Put fallback strings in values/strings.xml and translations in qualifier folders such as values-fr/strings.xml or values-pt-rPT/strings.xml. Shared UI resolves them with stringResource(value.displayName) on Android and iOS.

Resource names are the schema contract

RATIO_16_9 requires a base string named ratio_16_9 and, when icon: true, a base drawable with the same name. Missing resources fail generateProtoResources with the exact enum and expected key before Kotlin compilation. Regional translations use normal Compose fallback behavior.

KMP setup

Version 2 keeps metadata, Compose resources, and the generated Wire enums in the same KMP module. Apply the Compose Multiplatform plugin, configure both proto-extended blocks, and make basePackage match Compose's packageOfResClass for resource generation.

shared/build.gradle.kts
// the KMP module, next to the Wire output
protoExtended {
    metadata {
        protoDir.set(layout.projectDirectory.dir("src/commonMain/proto"))
        basePackage.set("com.lascade.ta.shared.generated")
    }
    resources {
        protoDir.set(layout.projectDirectory.dir("src/commonMain/proto"))
        basePackage.set("com.lascade.ta.shared.generated.resources")
    }
}

compose.resources {
    packageOfResClass = "com.lascade.ta.shared.generated.resources"
}

One resource bundle for both hosts

Put strings and drawables under src/commonMain/composeResources. Android packages them in the AAR and the Apple framework publishes the same resource bundle; platform apps no longer need generated R accessors or duplicated enum-to-asset switches.

Wiring into a source set is automatic and lazy, keyed off whichever plugin is also applied to the module:

Consumer plugingenerateProtoMetadatagenerateProtoResources
org.jetbrains.kotlin.multiplatformcommonMaincommonMain
org.jetbrains.kotlin.jvmmain
com.android.application / com.android.libraryevery variant (AGP Variant API)

These source-set names are fixed, not configurable — there is no sourceSet property. Wiring runs during apply(), before your protoExtended { } block has been evaluated, so a property read at that point would always see an unset value. If your module needs a different source set, wire it by hand:

build.gradle.kts
kotlin.sourceSets["jvmMain"].kotlin.srcDir(tasks.named("generateProtoMetadata"))

Unwired diagnostic

If a block is configured but none of the plugins above are present, the build still succeeds — the task writes a valid file, but nothing compiles it. proto-extended warns instead of failing silently:

w: protoExtended { metadata { … } } is configured, but nothing wired it into
   a source set. Generated sources in
   build/generated/source/protoExtended/metadata are not on any source set.
   Add them manually with kotlin.srcDir(tasks.named("generateProtoMetadata"))
   if that is intentional.

The warning tolerates a deliberate manual srcDir wire-up like the one above — it only tells you nothing wired the output in, not that you did something wrong.

Migrating from 1.x

Version 2 deliberately removes the Android-only resource API. The metadata block is unchanged; migrate resource consumers as one source change:

  • Replace androidResources { } with resources { } in the KMP module.
  • Remove rPackage; set the resource basePackage to Compose's packageOfResClass.
  • Move or copy base strings and drawables to src/commonMain/composeResources and keep the lowercase enum names.
  • Replace Android stringResource(id = value.displayName) with Compose Multiplatform stringResource(value.displayName).
  • Replace Android painter calls taking an integer with painterResource(value.icon).
  • Delete the generated Android R accessors after all callers compile against the shared properties.

Binary-incompatible major release

displayName changes from Int to StringResource, icon changes from Int to DrawableResource, and generateProtoAndroidResources is replaced by generateProtoResources. Upgrade the plugin and its callers together.

Validation rules

proto3 scalars have no field presence, so a metadata field silently defaulted to 0 / "" / false is indistinguishable from a genuinely-set value. Every rule below exists to turn that class of silent bug into a build failure that names the enum, constant and field:

  1. All constants set the option, or none. If any constant of an enum carries a metadata option, every constant must — a defaulted value for the rest would be silently wrong.
  2. All constants set each field, or none. Same hazard, checked per field rather than per option, since one meta message can declare several fields. This check runs after the type check below — a field of an unsupported type still fails the build even when no constant sets it.
  3. No field named name, ordinal, or value, and no two meta messages contributing the same field name to one enum. A real Kotlin Enum member (or Wire's WireEnum.value) always wins over an extension property of the same name, so the generated property would be dead code with no compiler error.
  4. Only string, double, float, int32, uint32, int64, uint64 and bool fields are read. bytes, repeated, map, message-typed and enum-typed fields fail loudly instead of being silently stringified.
  5. Every field of the resource-flags message must be a non-repeated bool named display_name or icon. Protoc already rejects a misspelled option reference; this catches a field the plugin has no meaning for.
  6. Every generated resource accessor must resolve in the base Compose resource set. The lowercased enum constant must exist in values/strings.xml and/or the base drawable directory according to its flags. The failure reports every missing enum/resource pair in one pass.

Rule 1 failing looks like this:

> Task :shared:generateProtoMetadata FAILED

Enum `ta.AspectRatio` declares (ratio_meta) on 1 of 2 constants. Missing on:
  - RATIO_16_9

Every constant must set the option, or none.

Non-goals

Deliberate limits, surfaced upfront.

  • No comment-directive fallback. The old buildSrc task's // gen:string / // gen:drawable leading comments are not supported — only the (gen.resources) option.
  • No translation-completeness validation. The base Compose resource must exist; regional locale coverage is left to Compose resource linting and normal fallback behavior.
  • No platform-specific resource output. Resource properties are generated once in commonMain; Android R integers and iOS asset-name switches are deliberately not generated.
  • No enum- or message-typed metadata fields. Only the eight scalar types listed under Metadata generation are read.
  • No oneof metadata fields. Unlike bytes, repeated and map fields, which fail the build, a oneof inside a meta message is silently excluded by Wire's declaredFields — it generates nothing and reports nothing.
  • No configurable property names. Generated accessors are always named for the field (width) or the fixed pair (displayName, icon).
  • Wire Kotlin codegen is assumed. protobuf-lite and pbandk generate different class names and are out of scope.
  • Wire is assumed to generate every enum on the proto path. An enum dropped via Wire's prune or exclude still gets extension properties generated against a class that no longer exists — both generators read .proto sources directly and never inspect Wire's output.
  • No configurable sourceSet. commonMain and main are hardcoded; wire a different metadata source set by hand with kotlin.srcDir(tasks.named("generateProtoMetadata")).