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.

Bytecode-level enforcement

The guard runs even if someone forgets to write it — it isn't in the source, it's in the class file.

Per-class asserter

Each Target class picks its own assertion method via the klass + method annotation parameters.

Smart skip rules

Constructors, synthetic/bridge methods, lambdas, accessors and Kotlin property getters/setters are skipped automatically.

Opt-out per method

@NoAssert on a single method removes the guard from just that one method.

Zero runtime dependency

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:

settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

Then apply the plugin in your Android module:

build.gradle.kts
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.

generated kotlin — AssertForAllCalls.kt
package com.rohittp.plugables.autoassert

import kotlin.reflect.KClass

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
annotation class AssertForAllCalls(val klass: KClass<*>, val method: String)
ParameterTypeDescription
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.

generated kotlin — NoAssert.kt
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.

FormWorks?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.
kotlin — recommended shape
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.

SkippedWhy
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.

kotlin
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:

javap -c (post-transform)
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.