Skip to content

Commit

Permalink
Add transformAllClasses recipe
Browse files Browse the repository at this point in the history
Test: self test
Bug: NA
Change-Id: If427157f2040ad5be0b191509b092179f2c73fb8
  • Loading branch information
agolubevgo committed Nov 14, 2023
1 parent 0895bd2 commit 5f89bb8
Show file tree
Hide file tree
Showing 17 changed files with 493 additions and 0 deletions.
4 changes: 4 additions & 0 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ recipe_test(
recipe_test(
name = "workerEnabledTransformation",
)

recipe_test(
name = "transformAllClasses",
)
40 changes: 40 additions & 0 deletions recipes/transformAllClasses/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Transform classes with ScopedArtifactsOperation.toTransform()

This sample shows how to transform classes that will be used to create the `.dex` files.
There are two lists that need to be used to obtain the complete set of classes because some
classes are present as .class files in directories and others are present in jar files.
Therefore, you must query both [ListProperty] of [Directory] (classes) and [RegularFile]
(jars) to get the full list.

In this example, we query all classes to invoke some bytecode instrumentation on them.

The Variant API provides a convenient API to transform bytecodes based on ASM but this example
is using javassist to show how this can be done using a different bytecode enhancer.

Example deals with classes as `scoped artifacts`. Scoped artifacts are artifacts that can
be made available in the current variant scope, or may be optionally include the project's
dependencies in the results. Scoped artifacts can be classes or Java resources.

The [onVariants] block will create the [ModifyClassesTask] provider with input properties `allJars`,
`allDirectories` and the `output` jar file. `ScopedArtifactsOperation.toTransform()`
wires together transformation type [ScopedArtifact.CLASSES], input files and directories, output
to make scoped artifact transformer.

## To Run
To execute example you need to enter command:

`./gradlew :app:assembleDebug`

You will see output similar to following:

```
> Task :app:debugModifyClasses
handling .../app/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/processDebugResources/R.jar
Adding from jar com/example/android/recipes/transform_classes/R.class
handling .../app/build/tmp/kotlin-classes/debug
Found .../app/build/tmp/kotlin-classes/debug/com/example/android/recipes/sample/SomeSource.class.name
Adding javassist.CtNewClass@3c77f2f5[hasConstructor changed public abstract interface class com.example.android.recipes.sample.SomeInterface fields= constructors= methods=]
```


38 changes: 38 additions & 0 deletions recipes/transformAllClasses/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("android.recipes.transform_classes")
}



android {
namespace = "com.example.android.recipes.transform_classes"
compileSdk = $COMPILE_SDK
defaultConfig {
minSdk = $MINIMUM_SDK
targetSdk = $COMPILE_SDK
}
}

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
17 changes: 17 additions & 0 deletions recipes/transformAllClasses/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<application android:label="Minimal">
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.android.recipes.sample

class SomeSource {
override fun toString() = "Something !"
}
2 changes: 2 additions & 0 deletions recipes/transformAllClasses/build-logic/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534
org.gradle.parallel=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[versions]
androidGradlePlugin = $AGP_VERSION
kotlin = $KOTLIN_VERSION

[libraries]
android-gradlePlugin-api = { group = "com.android.tools.build", name = "gradle-api", version.ref = "androidGradlePlugin" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
42 changes: 42 additions & 0 deletions recipes/transformAllClasses/build-logic/plugins/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

plugins {
`java-gradle-plugin`
alias(libs.plugins.kotlin.jvm)
}

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}

dependencies {
compileOnly(libs.android.gradlePlugin.api)
implementation(gradleKotlinDsl())
implementation(files("libs/javassist-3.26.0-GA.jar"))
}

gradlePlugin {
plugins {
create("customPlugin") {
id = "android.recipes.transform_classes"
implementationClass = "CustomPlugin"
}
}
}

Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.AppPlugin
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.register
import com.android.build.api.variant.ScopedArtifacts
import com.android.build.api.artifact.ScopedArtifact

/**
* This custom plugin will register a callback that is applied to all variants.
*/
class CustomPlugin : Plugin<Project> {
override fun apply(project: Project) {

// Registers a callback on the application of the Android Application plugin.
// This allows the CustomPlugin to work whether it's applied before or after
// the Android Application plugin.
project.plugins.withType(AppPlugin::class.java) {

// Queries for the extension set by the Android Application plugin.
// This is the second of two entry points into the Android Gradle plugin
val androidComponents =
project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
// Registers a callback to be called, when a new variant is configured
androidComponents.onVariants { variant ->
val taskProvider = project.tasks.register<ModifyClassesTask>("${variant.name}ModifyClasses")

// Register modify classes task
variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)
.use(taskProvider)
.toTransform(
ScopedArtifact.CLASSES,
ModifyClassesTask::allJars,
ModifyClassesTask::allDirectories,
ModifyClassesTask::output
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import com.android.build.api.variant.ScopedArtifacts
import com.android.build.api.artifact.ScopedArtifact

import org.gradle.api.DefaultTask
import org.gradle.api.file.Directory
import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.TaskAction
import javassist.ClassPool
import javassist.CtClass
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.BufferedOutputStream
import java.io.File
import java.util.jar.JarFile
import java.util.jar.JarEntry
import java.util.jar.JarOutputStream
import org.gradle.api.file.RegularFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.file.RegularFileProperty

abstract class ModifyClassesTask: DefaultTask() {
// This property will be set to all Jar files available in scope
@get:InputFiles
abstract val allJars: ListProperty<RegularFile>

// Gradle will set this property with all class directories that available in scope
@get:InputFiles
abstract val allDirectories: ListProperty<Directory>

// Task will put all classes from directories and jars after optional modification into single jar
@get:OutputFile
abstract val output: RegularFileProperty

@TaskAction
fun taskAction() {

val pool = ClassPool(ClassPool.getDefault())

val jarOutput = JarOutputStream(BufferedOutputStream(FileOutputStream(
output.get().asFile
)))
// we just copying classes fromjar files without modification
allJars.get().forEach { file ->
println("handling " + file.asFile.getAbsolutePath())
val jarFile = JarFile(file.asFile)
jarFile.entries().iterator().forEach { jarEntry ->
println("Adding from jar ${jarEntry.name}")
jarOutput.putNextEntry(JarEntry(jarEntry.name))
jarFile.getInputStream(jarEntry).use {
// just copying to output jar without changes
it.copyTo(jarOutput)
}
jarOutput.closeEntry()
}
jarFile.close()
}
// Iterating through class files from directories
// Looking for SomeSource.class to add generated interface and instrument with additional output in
// toString methods (in our case it's just System.out)
allDirectories.get().forEach { directory ->
println("handling " + directory.asFile.getAbsolutePath())
directory.asFile.walk().forEach { file ->
if (file.isFile) {
if (file.name.endsWith("SomeSource.class")) {
println("Found $file.name")
val interfaceClass = pool.makeInterface("com.example.android.recipes.sample.SomeInterface");
println("Adding $interfaceClass")
jarOutput.putNextEntry(JarEntry("com/example/android/recipes/sample/SomeInterface.class"))
jarOutput.write(interfaceClass.toBytecode())
jarOutput.closeEntry()
val ctClass = file.inputStream().use {
pool.makeClass(it);
}
ctClass.addInterface(interfaceClass)

val m = ctClass.getDeclaredMethod("toString");
if (m != null) {
// injecting additional code that will be located at the beginning of toString method
m.insertBefore("{ System.out.println(\"Some Extensive Tracing\"); }");

val relativePath = directory.asFile.toURI().relativize(file.toURI()).getPath()
// Writing changed class to output jar
jarOutput.putNextEntry(JarEntry(relativePath.replace(File.separatorChar, '/')))
jarOutput.write(ctClass.toBytecode())
jarOutput.closeEntry()
}
} else {
// if class is not SomeSource.class - just copy it to output without modification
val relativePath = directory.asFile.toURI().relativize(file.toURI()).getPath()
println("Adding from directory ${relativePath.replace(File.separatorChar, '/')}")
jarOutput.putNextEntry(JarEntry(relativePath.replace(File.separatorChar, '/')))
file.inputStream().use { inputStream ->
inputStream.copyTo(jarOutput)
}
jarOutput.closeEntry()
}
}
}
}
jarOutput.close()
}
}
Loading

0 comments on commit 5f89bb8

Please sign in to comment.