Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Bytecode Transformations: Exploring new AGP API...

Bytecode Transformations: Exploring new AGP APIs for instrumenting your app

Sometimes you want to automate some boring tasks (for example, measuring the execution time of annotated methods or logging method calls with their arguments) or modify third-party dependencies in your own favor.

The Android Gradle plugin (AGP) has been providing Transform API for such cases, but it's proved to be inefficient and may be the cause of slow builds. The new API is much more efficient and easier to consume, so one can directly start modifying bytecode with almost zero-setup.

This talk will cover the new transform API, how it compares to the old one, touch a little bit on the JVM bytecode internals and show how this API can be useful for automating otherwise tedious or even impossible, from the source code perspective, tasks.

As an example, we'll try to turn all android Logcat logs into Timber logs (even the ones coming from third-party dependencies), to feed all log streams into a single source.

Roman Zavarnitsyn

July 06, 2022
Tweet

More Decks by Roman Zavarnitsyn

Other Decks in Programming

Transcript

  1. dependencies { implementation("com.jakewharton.timber:timber:5.0.1") } class CrashReportingTree : Timber.Tree() { override

    fun log(priority: Int, message: String)) { if (priority == Log.WARN) { CrashLib.reportWarning(message) } } }
  2. dependencies { implementation("com.jakewharton.timber:timber:5.0.1") } class CrashReportingTree : Timber.Tree() { override

    fun log(priority: Int, message: String)) { if (priority = = Log.WARN) { CrashLib.reportWarning(message) } } } if (BuildConfig.DEBUG) { Timber.plant(new DebugTree()); } else { Timber.plant(new CrashReportingTree()); }
  3. dependencies { implementation(“com.jakewharton.timber:timber:5.0.1”) implementation(“com.github.bumptech.glide:glide:4.11.0“) } @GlideModule public class YourAppGlideModule extends

    AppGlideModule { @Override public void applyOptions(Context context, GlideBuilder builder) { builder.setLogLevel(Log.DEBUG); } }
  4. dependencies { implementation(“com.jakewharton.timber:timber:5.0.1”) implementation(“com.github.bumptech.glide:glide:4.11.0“) } @GlideModule public class YourAppGlideModule extends

    AppGlideModule { @Override public void applyOptions(Context context, GlideBuilder builder) { builder.setLogLevel(Log.DEBUG); } } ... // somewhere later in Glide Log.d(TAG, “Failed to write data”)
  5. Metaprogramming • Bytecode manipulation - modify/add JVM bytecode (ASM &

    Javassist) • Kotlin Compiler Plugin - modify/add Kotlin IR (multiplatform) • KotlinPoet & JavaPoet - generate Kotlin and Java source code • KSP - generate Kotlin symbols (multiplatform)
  6. Metaprogramming Add Modify Project Dependencies • KSP • KotlinPoet •

    Kotlin Compiler Plugin • Bytecode manipulation • Kotlin Compiler Plugin • Bytecode manipulation • Bytecode manipulation • Bytecode manipulation
  7. Metaprogramming Add Modify Project Dependencies • KSP • KotlinPoet •

    Kotlin Compiler Plugin • Bytecode manipulation • Kotlin Compiler Plugin • Bytecode manipulation • Bytecode manipulation • Bytecode manipulation
  8. Transform API • Part of the Android Gradle Plugin (AGP)

    API • Processes intermediary build artifacts (jars, classes, resources.) • AGP creates a Gradle task for each transform • AGP handles dependencies between transforms
  9. Transform API Java/Kotlin Classes/Jars Dex fi les javac/kotlinc D8 APK/AAB

    https://www.droidcon.com/2019/08/07/android-transformers-bytecode-in-disguise-droidcon-newyork-2019/
  10. Transform API class TimberTransform : Transform() { override fun getName():

    String = "TimberTransform" override fun getInputTypes(): Set<QualifiedContent.ContentType> = setOf(QualifiedContent.DefaultContentType.CLASSES) override fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.EXTERNAL_LIBRARIES ) .. . }
  11. Transform API class TimberTransform : Transform() { override fun getName():

    String = "TimberTransform" override fun getInputTypes(): Set<QualifiedContent.ContentType> = setOf(QualifiedContent.DefaultContentType.CLASSES) override fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.EXTERNAL_LIBRARIES ) .. . }
  12. Transform API class TimberTransform : Transform() { override fun getName():

    String = "TimberTransform" override fun getInputTypes(): Set<QualifiedContent.ContentType> = setOf(QualifiedContent.DefaultContentType.CLASSES) // OR RESOURCES override fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.EXTERNAL_LIBRARIES ) .. . }
  13. Transform API class TimberTransform : Transform() { override fun getName():

    String = "TimberTransform" override fun getInputTypes(): Set<QualifiedContent.ContentType> = setOf(QualifiedContent.DefaultContentType.CLASSES) override fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.EXTERNAL_LIBRARIES ) .. . }
  14. Transform API class TimberTransform : Transform() { .. . override

    fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.EXTERNAL_LIBRARIES ) override fun isIncremental(): Boolean = true override fun transform(transformInvocation: TransformInvocation) { // actual processing of .class and .jar inputs } }
  15. Transform API class TimberTransform : Transform() { .. . override

    fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.EXTERNAL_LIBRARIES ) override fun isIncremental(): Boolean = true override fun transform(transformInvocation: TransformInvocation) { // actual processing of .class and .jar inputs } }
  16. Transform API class TimberTransform : Transform() { .. . override

    fun transform(transformInvocation: TransformInvocation) { // actual processing of .class and .jar inputs // ~ 200 LoC of boilerplate // for input in inputs (jars or dirs) // logic to handle incremental builds for input // if input needs transformation // logic to define output location // for file in input (jar or dir) // if file is .class // read the bytecode // modify input // write the bytecode to output // else // copy the file to output } }
  17. Transform API class TimberTransform : Transform() { .. . override

    fun transform(transformInvocation: TransformInvocation) { // actual processing of .class and .jar inputs // ~ 200 LoC of boilerplate // for input in inputs (jars or dirs) // logic to handle incremental builds for input // if input needs transformation // logic to define output location // for file in input (jar or dir) // if file is .class // read the bytecode // modify input <- we actually just need only this // write the bytecode to output // else // copy the file to output } }
  18. Transform API (deprecated) • 1 Transform = 1 new Gradle

    Task • A lot of boilerplate (read class bytes, modify, write back for each class) • Consumers should handle jar inputs (open jar, repeat the above for each class, close jar) • Consumers should handle incremental Transforms • Consumers should handle output folders to not break the chain Scheduled for removal in AGP 8.0
  19. Instrumentation API • Single Gradle task for all transforms •

    No repeating work - reading input classes/jars’ bytes, writing output, handling incremental builds - everything is done by AGP • Better cache-ability - uses Gradle’s ArtifactTransforms to process dependency jars/aars • Lightweight API
  20. Instrumentation API v Transform API Project Classes/Jars D8/R8 Task Single

    Gradle Transform Task Dependency Classes/Jars D8 Artifact Transform Gradle’s Artifact Transform* * Transformed dependencies are cached across builds and projects, as long as inputs are the same
  21. Instrumentation API v Transform API class TimberTransform : Transform() {

    override fun getName(): String = "TimberTransform" override fun getInputTypes(): Set<QualifiedContent.ContentType> = setOf(QualifiedContent.DefaultContentType.CLASSES) override fun getScopes(): MutableSet<in QualifiedContent.Scope> = setOf( QualifiedContent.Scope.PROJECT, QualifiedContent.Scope.SUB_PROJECTS, QualifiedContent.Scope.EXTERNAL_LIBRARIES ) override fun isIncremental(): Boolean = true override fun transform(transformInvocation: TransformInvocation) { // actual processing of .class and .jar inputs // ~ 200 LoC of boilerplate // for input in inputs (jars or dirs) // logic to handle incremental builds for input // if input needs transformation // logic to define output location // for file in input (jar or dir) // if file is .class // read the bytecode // modify input <- we actually just need only this // write the bytecode to output // else // copy the file to output } } abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<Parameters> { override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input override fun isInstrumentable( classData: ClassData ): Boolean = true }
  22. Instrumentation API abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun

    isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  23. Instrumentation API abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun

    isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  24. Instrumentation API abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun

    isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  25. Instrumentation API abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun

    isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  26. Instrumentation API interface ClassData { /** * Fully qualified name

    of the class. */ val className: String /** * List of the annotations the class has. */ val classAnnotations: List<String> /** * List of all the interfaces that this class or a superclass of this class implements. */ val interfaces: List<String> /** * List of all the super classes that this class or a super class of this class extends. */ val superClasses: List<String> }
  27. Instrumentation API abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun

    isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  28. Instrumentation API abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun

    isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  29. ASM

  30. ASM • JVM bytecode manipulation framework • Uses Visitor pattern

    to parse and modify .class bytecode • Well-documented and optimized for speed • Used by Kotlin, Gradle, AGP, Mockito, R8 etc.
  31. ASM • MethodVisitor • visitCode() • visitParameter(name) • visitAnnotation(desc): AnnotationVisitor

    • visitLocalVariable(name) • visitMethodInsn(opcode) • visitEnd() • ClassVisitor • visit() • visitAnnotation(desc): AnnotationVisitor • visitField(name, desc): FieldVisitor • visitMethod(name, desc): MethodVisitor
  32. ASM-tree* • Uses tree-structure for bytecode instructions instead of visitors

    • Useful for partial analysis of bytecode (e.g. allows look-behind and look-ahead for instructions)
  33. JVM Bytecode 101 • Constant pool - type de fi

    nitions for methods/variables/etc. • Local variables - store intermediate results and method params • Operand stack - push/pop values, manipulate them, store the result • Stack frames - created for each method call; contains operand stack and local variables • Instructions - method invocations, stack manipulation, arithmetic, control fl ow
  34. JVM Bytecode 101 … Thread_1 Frame_Method1 Frame_Method2 … Frame_MethodN Thread_2

    Frame_Method1 Frame_Method2 … Frame_MethodN Thread_N Frame_Method1 Frame_Method2 Frame_MethodN
  35. JVM Bytecode 101 Frame_Method1 Frame_Method2 Frame_MethodN 0 1 … …

    N Local variables 0 1 … … N Local variables 0 1 … … N Local variables value_1 value_2 result_1 … Operand Stack value_1 value_2 result_1 … Operand Stack value_1 value_2 result_1 … Operand Stack #1= “java/lang/ Object” #2 = “<init>” … … #N = fi lename Constant pool
  36. JVM Bytecode 101 $ kotlinc Test.kt && javap -v Test.class

    class Test { fun calc() { val a = 1 val b = 2 val c = a + b println(c) } }
  37. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Constant pool: #1 = Utf8 com/romtsn/timberland/sample/Test #2 = Class #1 // com/romtsn/timberland/sample/Test #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = NameAndType #5:#6 // "<init>":()V #8 = Methodref #4.#7 // java/lang/Object."<init>":()V #9 = Utf8 this #10 = Utf8 Lcom/romtsn/timberland/sample/Test; #11 = Utf8 calc #12 = Utf8 java/lang/System #13 = Class #12 // java/lang/System #14 = Utf8 out #15 = Utf8 Ljava/io/PrintStream; #16 = NameAndType #14:#15 // out:Ljava/io/PrintStream; #17 = Fieldref #13.#16 // java/lang/System.out:Ljava/io/PrintStream; #18 = Utf8 java/io/PrintStream ...
  38. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Constant pool: #1 = Utf8 com/romtsn/timberland/sample/Test #2 = Class #1 // com/romtsn/timberland/sample/Test #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = NameAndType #5:#6 // "<init>":()V #8 = Methodref #4.#7 // java/lang/Object."<init>":()V #9 = Utf8 this #10 = Utf8 Lcom/romtsn/timberland/sample/Test; #11 = Utf8 calc #12 = Utf8 java/lang/System #13 = Class #12 // java/lang/System #14 = Utf8 out #15 = Utf8 Ljava/io/PrintStream; #16 = NameAndType #14:#15 // out:Ljava/io/PrintStream; #17 = Fieldref #13.#16 // java/lang/System.out:Ljava/io/PrintStream; #18 = Utf8 java/io/PrintStream
  39. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } LocalVariableTable: Start Length Slot Name Signature 2 14 1 a I 4 12 2 b I 8 8 3 c I 0 16 0 this Lcom/romtsn/timberland/sample/Test;
  40. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } LocalVariableTable: Start Length Slot Name Signature 2 14 1 a I 4 12 2 b I 8 8 3 c I 0 16 0 this Lcom/romtsn/timberland/sample/Test;
  41. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  42. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  43. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  44. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  45. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  46. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  47. JVM Bytecode 101 class Test { fun calc() { val

    a = 1 val b = 2 val c = a + b println(c) } } Operand Stack: 0: iconst_1 // 1 1: istore_1 // val a = 1 2: iconst_2 // 2 3: istore_2 // val b = 2 4: iload_1 // load local variable "a" and push it onto stack 5: iload_2 // load local variable "b" and push it onto stack 6: iadd // add them, pop them and push the result back 7: istore_3 // pop the result from stack and store into local variable "c" 11: iload_3 // load local variable "c" and push it onto stack 12: invokevirtual #23 // Method java/io/PrintStream.println:(I)V - > invoke println(c) 15: return
  48. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  49. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = // modify input }
  50. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = LogToTimberClassVisitor( instrumentationContext.apiVersion.get(), nextClassVisitor ) }
  51. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = LogToTimberClassVisitor( instrumentationContext.apiVersion.get(), / / ASM_9_3 as of now (supports java 19) nextClassVisitor ) }
  52. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> { override fun isInstrumentable(classData: ClassData): Boolean = true override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor = LogToTimberClassVisitor( instrumentationContext.apiVersion.get(), / / ASM_9_3 as of now (supports java 19) nextClassVisitor ) }
  53. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    // assuming our plugin is already depending on AGP val androidComponents = target.extensions.getByType(AndroidComponentsExtension :: class.java) androidComponents.onVariants { variant -> variant.instrumentation.transformClassesWith( TimberClassVisitorFactory :: class.java, InstrumentationScope.ALL // OR PROJECT ) { // no params } variant.instrumentation.setAsmFramesComputationMode( COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) }
  54. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    // assuming our plugin is already depending on AGP val androidComponents = target.extensions.getByType(AndroidComponentsExtension :: class.java) androidComponents.onVariants { variant -> variant.instrumentation.transformClassesWith( TimberClassVisitorFactory :: class.java, InstrumentationScope.ALL // OR PROJECT ) { // no params } variant.instrumentation.setAsmFramesComputationMode( COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) }
  55. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    // assuming our plugin is already depending on AGP val androidComponents = target.extensions.getByType(AndroidComponentsExtension :: class.java) androidComponents.onVariants { variant -> variant.instrumentation.transformClassesWith( TimberClassVisitorFactory :: class.java, InstrumentationScope.ALL // OR PROJECT ) { // no params } variant.instrumentation.setAsmFramesComputationMode( COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) }
  56. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    // assuming our plugin is already depending on AGP val androidComponents = target.extensions.getByType(AndroidComponentsExtension :: class.java) androidComponents.onVariants { variant -> variant.instrumentation.transformClassesWith( TimberClassVisitorFactory :: class.java, InstrumentationScope.ALL // OR PROJECT ) { // no params } variant.instrumentation.setAsmFramesComputationMode( COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) }
  57. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    // assuming our plugin is already depending on AGP val androidComponents = target.extensions.getByType(AndroidComponentsExtension :: class.java) androidComponents.onVariants { variant -> variant.instrumentation.transformClassesWith( TimberClassVisitorFactory :: class.java, InstrumentationScope.ALL // OR PROJECT ) { // no params } // instruct the API to compute Frames for us variant.instrumentation.setAsmFramesComputationMode( COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) } 0 1 … … N Local variables value_1 value_2 result_1 … Operand Stack
  58. FramesComputationMode • Frames and Maximum Operand Stack sizes (maxs) have

    to be recomputed based on new bytecode. 4 modes: • COMPUTE_FRAMES_FOR_ALL_CLASSES - very slow, don’t use it; requires a second pass for all classes • COMPUTE_FRAMES_FOR_INSTRUMENTED_CLASSES - slow, use when modifying class body • COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS - optimal, use when modifying method body • COPY_FRAMES - fast, use when adding an annotation/interface or computing frames/maxs yourself Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")
  59. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    class LogToTimberClassVisitor( private val apiVersion: Int, nextClassVisitor: ClassVisitor ) : ClassVisitor(apiVersion, nextClassVisitor) { override fun visitMethod( access: Int, // public / private / final name: String?, // name of the method descriptor: String?, // parameter and return types signature: String?, // generics exceptions: Array<out String>? // if the method throws Exception ): MethodVisitor { val mv = super.visitMethod(access, name, descriptor, signature, exceptions) return LogToTimberMethodVisitor(apiVersion, mv) } }
  60. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    class LogToTimberClassVisitor( private val apiVersion: Int, nextClassVisitor: ClassVisitor ) : ClassVisitor(apiVersion, nextClassVisitor) { override fun visitMethod( access: Int, // public / private / final name: String?, // name of the method descriptor: String?, // parameter and return types signature: String?, // generics exceptions: Array<out String>? // if the method throws Exception ): MethodVisitor { val mv = super.visitMethod(access, name, descriptor, signature, exceptions) return LogToTimberMethodVisitor(apiVersion, mv) } }
  61. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    class LogToTimberMethodVisitor( apiVersion: Int, nextMethodVisitor: MethodVisitor ) : MethodVisitor(apiVersion, nextMethodVisitor) { override fun visitMethodInsn( opcode: Int, / / type of the method (static, interface, normal) owner: String?, // owner class name: String?, // name of the method descriptor: String?, // descriptor of the method isInterface: Boolean // if the method belongs to an interface ) { // invoked on each method call } }
  62. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    override fun visitMethodInsn( opcode: Int, // type of the method (static, interface, normal) owner: String?, / / owner class name: String?, // name of the method descriptor: String?, // descriptor of the method isInterface: Boolean // if the method belongs to an interface ) { if (opcode = = Opcodes.INVOKESTATIC & & owner == "android/util/Log") { } }
  63. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    override fun visitMethodInsn( opcode: Int, // type of the method (static, interface, normal) owner: String?, / / owner class name: String?, // name of the method descriptor: String?, // descriptor of the method isInterface: Boolean // if the method belongs to an interface ) { if (opcode = = Opcodes.INVOKESTATIC & & owner == "android/util/Log") { // if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { } } }
  64. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    override fun visitMethodInsn( opcode: Int, // type of the method (static, interface, normal) owner: String?, // owner class name: String?, // name of the method descriptor: String?, // descriptor of the method isInterface: Boolean // if the method belongs to an interface ) { if (opcode == Opcodes.INVOKESTATIC && owner == "android/util/Log") { // if it's a "Log.d(tag: String, message: String)" call if (name == "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { } } }
  65. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message tag
  66. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message tag
  67. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message tag
  68. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message tag
  69. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … tag message
  70. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message
  71. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message Tree
  72. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message Tree
  73. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message Tree
  74. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … message Tree
  75. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … … Tree
  76. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { visitInsn(Opcodes.SWAP) / / message, tag -> tag, message // call Timber.tag(tag): Timber.Tree visitMethodInsn( Opcodes.INVOKESTATIC, "timber/log/Timber", "tag", "(Ljava/lang/String;)Ltimber/log/Timber\$Tree;", false ) visitInsn(Opcodes.SWAP) / / tree, message -> message, tree // call Timber.Tree.d(message) visitMethodInsn( Opcodes.INVOKEVIRTUAL, "timber/log/Timber\$Tree", "d", "(Ljava/lang/String;)V", false ) } } … …
  77. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { ... return / / return, so the Log gets replaced with Timber } } // delegate to the next visitor, no changes from our side super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
  78. Log.d(TAG, "Failed to write data") -> Timber.tag(TAG).d("Failed to write data")

    if (opcode = = Opcodes.INVOKESTATIC && owner == "android/util/Log") { / / if it's a "Log.d(tag: String, message: String)" call / / handle other Log.x methods and overloads if (name = = "d" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") { ... return / / return, so the Log gets replaced with Timber } } // delegate to the next visitor, no changes from our side super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
  79. Testing • Unit testing with ASM - check if bytecode

    passes JVM veri fi er, check if the control fl ows are not broken, etc. (CheckClassAdapter) • Unit testing plain text - dump bytecode of a class before and after instrumentation and compare it • Integration testing with Gradle - verify how it works on a sample project with other Plugins using Instrumentation API, verify R8 builds, etc.
  80. Tips & Gotchas • Gradle Artifact Transforms cache - dependencies

    are cached between builds/ projects - use some fl ag to invalidate instrumentation parameters
  81. Gradle Artifact Transforms cache abstract class TimberClassVisitorFactory : AsmClassVisitorFactory<TimberClassVisitorFactory.Parameters> {

    interface Parameters : InstrumentationParameters { @get:Input @get:Optional val invalidate: Property<Long> } ... }
  82. Gradle Artifact Transforms cache variant.instrumentation.transformClassesWith( TimberClassVisitorFactory :: class.java, InstrumentationScope.ALL )

    { params -> if (myPluginExtension.instrumentDeps.get()) { params.invalidate.set(System.currentTimeMillis()) } }
  83. Tips & Gotchas • Gradle Artifact Transforms cache - the

    dependencies are cached between builds/projects - use some fl ag to invalidate instrumentation parameters • R8 runs through all instructions - even those, that are there for debugging purposes - always test your bytecode with R8
  84. Tips & Gotchas • Gradle Artifact Transforms cache - the

    dependencies are cached between builds/projects - use some fl ag to invalidate instrumentation parameters • R8 runs through all instructions - even those, that are there for debugging purposes - always test your bytecode with R8 • ASM IntelliJ Plugin - to fi gure out what bytecode to write and what it all looks like
  85. Tips & Gotchas • Gradle Artifact Transforms cache - the

    dependencies are cached between builds/projects - use some fl ag to invalidate instrumentation parameters • R8 runs through all instructions - even those, that are there for debugging purposes - always test your bytecode with R8 • ASM IntelliJ Plugin - to fi gure out what bytecode to write and what it all looks like • jadx decompiler - to see your generated bytecode as source code
  86. Tips & Gotchas • Gradle Artifact Transforms cache - the

    dependencies are cached between builds/projects - use some fl ag to invalidate instrumentation parameters • R8 runs through all instructions - even those, that are there for debugging purposes - always test your bytecode with R8 • ASM IntelliJ Plugin - to fi gure out what bytecode to write and what it all looks like • jadx decompiler - to see your generated bytecode as source code
  87. Other examples • Sentry Android Gradle plugin - instrumenting Room

    Database queries, File I/O Stream, OkHttp/Retro fi t requests • Firebase Performance plugin - instrumenting network requests and annotated methods • Dagger Hilt - transforming @AndroidEntryPoint to extend Hilt classes • Your plugin!
  88. References • Sentry Android Gradle plugin - https://github.com/getsentry/sentry-android- gradle-plugin/tree/main/plugin-build/src/main/kotlin/io/sentry/android/gradle/ instrumentation

    • Java Bytecode dive-in - https://www.jrebel.com/blog/java-bytecode-tutorial • Instrumentation API docs - https://developer.android.com/reference/tools/ gradle-api/7.2/com/android/build/api/variant/Instrumentation • ASM IntelliJ Plugin - https://plugins.jetbrains.com/plugin/10302-asm- bytecode-viewer