proto-extended
Reads a directory of .proto files and turns custom enum
options into real Kotlin: typed metadata extension properties for
commonMain, and Compose Multiplatform
StringResource / DrawableResource accessors
usable by shared UI on Android and iOS. One proto enum becomes the
shared source of truth for behavior, localized labels, and icons.
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.
| Block | Task | Output file | Runs 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 |
Generated labels and icons return Compose resource objects, never Android R integers.
An unconfigured block's task reports NO-SOURCE and is skipped. No "must configure both" trap.
Metadata and base-resource validation turn silent drift into build failures naming the enum and constant.
Enums sorted by qualified name, imports sorted — reordering unrelated protos never busts the build cache.
Neither task references Project at execution time.
Installation
Add the Plugables Maven repository to settings.gradle.kts:
pluginManagement {
repositories {
maven("https://maven.rohittp.com")
mavenCentral()
gradlePluginPortal()
}
}
Then apply the plugin in whichever module needs it:
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:
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.
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 — 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:
| Property | Type | Required | Description |
|---|---|---|---|
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. |
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:
| Proto | Kotlin |
|---|---|
string | String |
double | Double |
float | Float |
int32, uint32 | Int |
int64, uint64 | Long |
bool | Boolean |
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.
// 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 — 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:
| Property | Type | Required | Description |
|---|---|---|---|
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. |
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.
// 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 plugin | generateProtoMetadata → | generateProtoResources → |
|---|---|---|
org.jetbrains.kotlin.multiplatform | commonMain | commonMain |
org.jetbrains.kotlin.jvm | main | — |
com.android.application / com.android.library | every 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:
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 { }withresources { }in the KMP module. - Remove
rPackage; set the resourcebasePackageto Compose'spackageOfResClass. - Move or copy base strings and drawables to
src/commonMain/composeResourcesand keep the lowercase enum names. - Replace Android
stringResource(id = value.displayName)with Compose MultiplatformstringResource(value.displayName). - Replace Android painter calls taking an integer with
painterResource(value.icon). - Delete the generated Android
Raccessors 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:
- 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.
- 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.
-
No field named
name,ordinal, orvalue, and no two meta messages contributing the same field name to one enum. A real KotlinEnummember (or Wire'sWireEnum.value) always wins over an extension property of the same name, so the generated property would be dead code with no compiler error. -
Only
string,double,float,int32,uint32,int64,uint64andboolfields are read.bytes,repeated,map, message-typed and enum-typed fields fail loudly instead of being silently stringified. -
Every field of the resource-flags message must be a
non-repeated bool named
display_nameoricon. Protoc already rejects a misspelled option reference; this catches a field the plugin has no meaning for. -
Every generated resource accessor must resolve in the base
Compose resource set. The lowercased enum constant must exist
in
values/strings.xmland/or the basedrawabledirectory 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:drawableleading 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; AndroidRintegers 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
oneofmetadata fields. Unlikebytes,repeatedandmapfields, which fail the build, aoneofinside a meta message is silently excluded by Wire'sdeclaredFields— 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
pruneorexcludestill gets extension properties generated against a class that no longer exists — both generators read.protosources directly and never inspect Wire's output. - No configurable
sourceSet.commonMainandmainare hardcoded; wire a different metadata source set by hand withkotlin.srcDir(tasks.named("generateProtoMetadata")).