A Gradle plugin for building exteraGram plugins written in Kotlin. It takes a regular
Android library module, relocates its dependencies into their own packages, runs
everything through R8 and produces classes.dex. If a bundle block is present, the
dex is additionally wrapped into a jar whose manifest carries the plugin metadata:
Plugin-Id, Plugin-Class, version, minimum client version. Service definitions
declared as providedService are shipped inside the same jar as separate dexed jars.
The jar can be signed right away with a key from a keystore.
The plugin is not published anywhere. Clone the repository and include it as a composite build.
git clone https://github.com/exteraStuff/gradle-plugin// settings.gradle.kts
pluginManagement {
includeBuild("/path/to/gradle-plugin")
// ...
}// build.gradle.kts
plugins {
id("com.android.library") version "9.0.1"
id("io.github.exterastuff.plugin")
}Requirements:
- JDK 21 — used to build the plugin itself.
- Android SDK — path set in
local.properties. com.android.libraryin the same module. The plugin expects AGP and creates no tasks without it.Telegram.jar— client classes extracted from the APK withdex2jar.
extera {
telegram {
jar = file("libs/Telegram.jar")
}
shadow {
targetPackage = "com.example.myplugin_shaded"
relocate("kotlin", "de.comahe.i18n4k")
relocate("androidx") {
// Classes provided by the host (compileOnly) must stay where they are,
// otherwise the plugin won't find them at runtime.
exclude("androidx.recyclerview.**")
exclude("androidx.lifecycle.**")
}
}
r8 {
proguardFiles.from("proguard-rules.pro")
minSdk = 26
}
// optional
bundle {
manifest {
id = "my-plugin"
name = "My Plugin"
description = ":)"
icon = "someIconPack/10"
author = "@username"
version = "1.0.0"
minClientVersion = "12.1.1"
entryClass = "com.example.myplugin.Plugin"
}
// optional
signing {
release {
keyStore {
path = file("keystore.p12")
alias = "my-plugin"
storePassword = providers.environmentVariable("KEYSTORE_PASSWORD").get()
}
}
}
}
}| Property | Default | Description |
|---|---|---|
jar |
— | Client classes from dex2jar |
conflictingPackages |
kotlin/, kotlinx/coroutines/, com/android/tools/r8/ |
Package prefixes stripped from Telegram.jar |
| Property | Default | Description |
|---|---|---|
targetPackage |
— | Package that all relocated classes move under |
relocate has two forms: relocate("kotlin", ...) moves the listed packages
entirely, while relocate("androidx") { exclude("androidx.lifecycle.**") }
leaves some of the classes where they were.
| Property | Default | Description |
|---|---|---|
proguardFiles |
— | R8 rules |
version |
9.4.17 |
R8 version used to build the dex |
minSdk |
variant minSdk |
Minimum API level for the dex |
This block is optional. Without it the plugin only builds the dex, and the
packagePluginJar* tasks are not created.
| Property | Default | Description |
|---|---|---|
id |
— | Jar file name and Plugin-Id in the manifest |
name |
— | Display name |
description |
— | Description |
icon |
— | Plugin emoji icon |
author |
— | Author |
version |
— | Plugin version, also used in the jar name |
minClientVersion |
— | Minimum exteraGram version, e.g. "12.1.1" |
entryClass |
— | Fully qualified name of the class the client starts from |
updateSources |
empty | Update sources: name → url |
dependencies |
empty | Other plugins this one requires, declared with dependency |
dependency declares a plugin the client must have before loading this one:
dependency("streaks") {
minVersion = "1.2.0"
// Do not use "latest" as hash will change after new release
providerSource("https://github.com/exteraStuff/streaks/releases/v1.2.0/download/streaks.jar")
providerSha1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
}
| Property | Default | Description |
|---|---|---|
minVersion |
— | Minimal supported version, major.minor.patch |
providerSources |
empty | Urls the client can download the dependency from |
providerSha1 |
— | SHA-1 of the jar served by providerSources |
Only a major bump of the dependency counts as incompatible.
Contains two blocks, debug and release, one per build variant. The
signPluginJar* task is created only for variants whose block is defined.
| Property | Default | Description |
|---|---|---|
keyStore |
— | Key used to sign the jar |
tsaUrls |
debug — empty; release — DigiCert, Sectigo, rfc3161.ai.moda |
Timestamp servers, tried in order |
If tsaUrls is empty, the jar is signed without a timestamp. If the list is set but
none of the servers respond, the task fails.
keyStore fields:
| Property | Default | Description |
|---|---|---|
path |
— | Keystore file |
alias |
— | Key alias inside the keystore |
storePassword |
— | Keystore password |
keyPassword |
storePassword |
Key password, if different |
| Property | Default | Description |
|---|---|---|
dexOutputDir |
build/outputs/dex |
Where the dex is written |
jarOutputDir |
build/outputs/jar |
Where the jar is written |
A plugin can expose service definitions to other plugins — interfaces and types through which it is accessed from the outside. Such a definition is not merged into the plugin's dex; it is shipped inside the plugin jar as a separate dexed jar, so the client can load it once and share it with everyone who requires it.
The providedService configuration in the dependencies block declares a service
definition that the plugin provides.
dependencies {
providedService(project(":api"))
}Such a dependency:
- is added to
compileOnly— the plugin compiles against it, shadow does not pull it into the fat jar, and R8 sees it only on the classpath; - is resolved without transitives: the artifact is treated as self-contained;
- is run through D8 and placed at
services/<group>.<artifact>-<version>.jarinside the plugin jar. The nested jar containsclasses.dexand the resources of the original artifact, and its manifest carriesService-IdandService-Version; - is listed in the plugin manifest under
Plugin-Provided-Services.
Service classes are not relocated: their names stay the same as those seen by the compiled plugin code — otherwise callers would not be able to find them.
Coordinates are taken from the resolution result, so project(":api") appears in the
manifest as the subproject's group:name:version, not as the :api path.
The same kind of configuration, but the service definition is not shipped in the plugin jar — it is only declared as a requirement that must be provided by the client or by another installed plugin.
dependencies {
requiredService("io.github.exterastuff.other:api:1.2.0")
}It behaves the same way — compileOnly, no transitives, coordinates from resolution —
but it is not run through D8 and does not end up in services/. It is listed under
Plugin-Required-Services.
Both lists are sorted and comma-separated:
Plugin-Provided-Services: io.github.exterastuff.demo:api:2.4.1
Plugin-Required-Services: io.github.exterastuff.other:api:1.2.0
The attributes are always written; an empty value means there are no such dependencies.
./gradlew buildDexDebug # build/outputs/dex/debug/classes.dex
./gradlew buildDexRelease # build/outputs/dex/release/classes.dex
./gradlew buildDex # both variantsWith a bundle block, jar packaging is also available:
./gradlew packagePluginJarRelease # build/outputs/jar/<id>-<version>.jar
./gradlew packagePluginJarDebug # build/outputs/jar/<id>-<version>-debug.jar
./gradlew packagePluginJar # both variantsAnd with signing defined, the built jar can be signed:
./gradlew signPluginJarRelease # build/outputs/jar/<id>-<version>-signed.jar
./gradlew signPluginJarDebug # build/outputs/jar/<id>-<version>-debug-signed.jar
./gradlew signPluginJar # all defined variantsThe debug variant is built by R8 in DEBUG mode: some optimizations are disabled,
the build is faster and stack traces are more readable.
| Feature | Support |
|---|---|
| Configuration cache | Supported |
| Build cache | processTelegramJar, shade* and dexProvidedServices* are cacheable; buildDex*, packagePluginJar* and signPluginJar* rely on up-to-date checks only |
| Parallel execution | Supported |
Checked on Gradle 9.7.1 with AGP 9.x.
- Cleaning
Telegram.jar. ASM restores theInnerClassesattributes broken bydex2jarand strips the packages listed inconflictingPackages, so client classes don't clash with the project's dependencies. - Fat jar. Compiled classes and runtime dependencies are merged into a single jar
with shadow; packages from
relocateare moved undertargetPackage— otherwise they would clash with identical classes inside the client itself. - R8. The fat jar is the input, the cleaned
Telegram.jarandcompileOnlydependencies go on the classpath,android.jargoes into the library. The output is a dex. - Provided services. Each one is run through D8 separately and turned into a jar
containing
classes.dexinstead of classes. - Packaging. The dex is put into the jar, the dexed jars go into the
services/directory, and the plugin metadata is written to the manifest. - Signing. The jar is signed with a key from the keystore using
SHA256withRSA; the timestamp is taken from the first TSA in the list that responds.