auto-assert
Annotate a class with @AssertForAllCalls and the plugin
injects a static method call at the entry of every method —
constructors, accessors, lambdas, and equals/hashCode/toString
excluded. Use it for thread checks, invariant enforcement, or any
runtime guard you want to run before every call into a class.
Overview
Some classes have a contract that must hold for every method call — "must be called on the main thread", "must hold the render lock", "must not be invoked while the engine is paused". The classic fix is to copy-paste a guard call at the top of every method. It works until someone adds a new method and forgets.
auto-assert moves the guard from convention to
instrumentation. You annotate the class with
@AssertForAllCalls and point it at a static method on an
asserter object. At build time the plugin transforms the compiled
bytecode via the AGP Instrumentation API, inserting
INVOKESTATIC at the entry of every qualifying method.
The guard runs even if someone forgets to write it — it isn't in the source, it's in the class file.
Each Target class picks its own assertion method via the klass + method annotation parameters.
Constructors, synthetic/bridge methods, lambdas, accessors and Kotlin property getters/setters are skipped automatically.
@NoAssert on a single method removes the guard from just that one method.
The annotation classes are generated into your module — no extra library to ship.
Installation
Add Maven Central to your plugin repositories in settings.gradle.kts:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
Then apply the plugin in your Android module:
plugins {
id("com.rohittp.plugables.auto-assert") version "1.0.0"
}
No DSL configuration is required. The plugin generates the two
annotation classes into build/generated/source/autoAssert/main
and wires the AGP bytecode transform onto every variant.
Requires AGP 7.2+
The plugin uses the Android Variant API
(AndroidComponentsExtension) and the AGP
Instrumentation API. Tested against AGP 9.2.0.
Annotations
Two annotations are generated into the
com.rohittp.plugables.autoassert package on every build.
Import them like any other Kotlin annotation.
@AssertForAllCalls
Class-level. Marks a class as a Target class.
package com.rohittp.plugables.autoassert
import kotlin.reflect.KClass
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
annotation class AssertForAllCalls(val klass: KClass<*>, val method: String)
| Parameter | Type | Description |
|---|---|---|
klass |
KClass<*> |
The Asserter class. Refactor-safe — IDE renames update every usage. |
method |
String |
Name of the static, no-arg, void method on the Asserter to invoke at every Host method entry. |
@NoAssert
Method-level. Opt a single method out of instrumentation.
package com.rohittp.plugables.autoassert
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.BINARY)
annotation class NoAssert
There is no class-level @NoAssert — to opt a whole class
out, simply don't annotate it with @AssertForAllCalls.
Asserter Requirements
The class supplied via klass = ... is called the
Asserter. It must expose a method that matches the
JVM descriptor ()V — no arguments, returns void — and be
callable via INVOKESTATIC.
| Form | Works? | Notes |
|---|---|---|
Kotlin object with @JvmStatic method |
Yes | Idiomatic. @JvmStatic makes the method directly callable from JVM static dispatch. |
Java public static void method |
Yes | Plain JVM static. |
Kotlin object instance method (no @JvmStatic) |
No | Compiles to an instance method on the singleton, not a JVM static. Add @JvmStatic. |
| Top-level Kotlin function | No | Compiled into a synthetic FileKt class — you can't reference its ::class directly. Wrap in an object. |
| Method with parameters | No | v1 supports ()V only. |
object ThreadAsserter {
@JvmStatic
fun assertMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"Must be called on the main thread"
}
}
}
Built-in Skip Rules
Methods that match any of these rules are never instrumented, even on
a Target class. The list mirrors the rules pioneered by
FilamentScope's thread-safety instrumentation — they
reflect what's almost always noise.
| Skipped | Why |
|---|---|
Constructors (<init>, <clinit>) |
JVM requires super()/this initialisers to run first — injecting before them is invalid bytecode. |
Synthetic and bridge methods (ACC_SYNTHETIC | ACC_BRIDGE) |
Compiler-generated. Bridge methods just delegate to the real method, which is already instrumented. |
Lambdas (lambda$…, $anonymous) |
The enclosing method is already instrumented; lambda bodies run on whatever thread the dispatcher chose. |
equals(Object), hashCode(), toString() |
Called by debugger/log/hash code paths on unpredictable threads. Asserting here pollutes diagnostics. |
Field-derived accessors (getX(), setX(T)) |
Backed by a real field — pure read/write, no real method body to guard. |
| Kotlin property accessors | Detected via @kotlin.Metadata. Includes property overrides without backing fields. |
Methods annotated @NoAssert |
Explicit opt-out. |
If you need to assert on a getter
Convert the property to an explicit function. The plugin can't tell
a "real" getter from a property accessor — both look identical in
bytecode. If you need the guard on a read, write it as
fun computeFoo(): T.
End-to-end Example
A renderer that must always run on the GL thread. Define the asserter, annotate the renderer, and the plugin injects the check into every host method.
import com.rohittp.plugables.autoassert.AssertForAllCalls
import com.rohittp.plugables.autoassert.NoAssert
object GlAsserter {
lateinit var glThread: Thread
@JvmStatic
fun assertGlThread() {
check(Thread.currentThread() === glThread) {
"Must be called on the GL thread"
}
}
}
@AssertForAllCalls(klass = GlAsserter::class, method = "assertGlThread")
class SceneRenderer {
fun render() { /* … */ } // instrumented
fun update() { /* … */ } // instrumented
@NoAssert
fun dumpForLogging(): String { // skipped — safe to call from any thread
return "scene state…"
}
}
After the build, the compiled bytecode of SceneRenderer looks like:
public final void render();
Code:
0: invokestatic // Method GlAsserter.assertGlThread:()V
3: /* … original method body … */
public final void update();
Code:
0: invokestatic // Method GlAsserter.assertGlThread:()V
3: /* … original method body … */
public final String dumpForLogging();
Code:
0: /* … original method body — no injection … */
Configuration
The autoAssert DSL block exposes a single optional
property. Most projects never need to override it.
| Property | Type | Default | Description |
|---|---|---|---|
outputDir |
DirectoryProperty |
build/generated/source/autoAssert/main |
Where the two annotation source files are written. Automatically wired into every variant's kotlin source set. |
Caveats
Three intentional limitations to be aware of. All are surfaced upfront so the plugin's behaviour is predictable.
No inheritance
@AssertForAllCalls only triggers instrumentation on the
class that declares it. Subclasses are not automatically
Target classes — overrides and new methods on the subclass run
unguarded unless that subclass also carries the annotation.
No asserter arguments
The Asserter method must be ()V. The plugin does not
pass the calling class/method name. If you need that information,
capture it from the JVM stack frame inside your asserter
(Thread.currentThread().stackTrace) — pay the cost on
failure, not on every call.
Multi-module duplicate classes
Because the annotation FQCN is fixed
(com.rohittp.plugables.autoassert.AssertForAllCalls),
applying the plugin to multiple Android modules in the
same build — where one depends on another — will produce duplicate
.class files at dex time. Apply the plugin in a single
module, or in a shared base module everything else depends on.
When you want zero-cost in release
Keep the assertion logic inside the asserter method behind a
BuildConfig.DEBUG guard. The INVOKESTATIC
stays in release builds, but the method body becomes a no-op the
JIT will inline away.