diff --git a/build.gradle.kts b/build.gradle.kts index 2337a999..74788882 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { repositories { mavenCentral() gradlePluginPortal() + google() } sourceSets { @@ -63,6 +64,7 @@ val createClasspathManifest = tasks.register("createClasspathManifest") { } val kotlinVersion: String by project +val androidGradlePluginVersion: String = "7.2.2" configurations.implementation { exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") @@ -77,10 +79,12 @@ dependencies { implementation("org.ow2.asm:asm-tree:9.2") implementation("com.googlecode.java-diff-utils:diffutils:1.3.0") compileOnly("org.jetbrains.kotlin.multiplatform:org.jetbrains.kotlin.multiplatform.gradle.plugin:1.6.0") + compileOnly("com.android.tools.build:gradle:${androidGradlePluginVersion}") // The test needs the full kotlin multiplatform plugin loaded as it has no visibility of previously loaded plugins, // unlike the regular way gradle loads plugins. add(testPluginRuntimeConfiguration.name, "org.jetbrains.kotlin.multiplatform:org.jetbrains.kotlin.multiplatform.gradle.plugin:$kotlinVersion") + add(testPluginRuntimeConfiguration.name, "com.android.tools.build:gradle:${androidGradlePluginVersion}") testImplementation(kotlin("test-junit")) "functionalTestImplementation"(files(createClasspathManifest)) diff --git a/src/functionalTest/kotlin/kotlinx/validation/api/TestDsl.kt b/src/functionalTest/kotlin/kotlinx/validation/api/TestDsl.kt index 16f507e2..f47f3034 100644 --- a/src/functionalTest/kotlin/kotlinx/validation/api/TestDsl.kt +++ b/src/functionalTest/kotlin/kotlinx/validation/api/TestDsl.kt @@ -47,6 +47,18 @@ internal fun FileContainer.kotlin(classFileName: String, sourceSet:String = "mai file(fileName, fn) } +/** + * same as [file][FileContainer.file], but prepends "src/${sourceSet}/java" before given `classFileName` + */ +internal fun FileContainer.java(classFileName: String, sourceSet:String = "main", fn: AppendableScope.() -> Unit) { + require(classFileName.endsWith(".java")) { + "ClassFileName must end with '.java'" + } + + val fileName = "src/${sourceSet}/java/$classFileName" + file(fileName, fn) +} + /** * Shortcut for creating a `build.gradle.kts` by using [file][FileContainer.file] */ diff --git a/src/functionalTest/kotlin/kotlinx/validation/test/AndroidLibraryTest.kt b/src/functionalTest/kotlin/kotlinx/validation/test/AndroidLibraryTest.kt new file mode 100644 index 00000000..e40a0a06 --- /dev/null +++ b/src/functionalTest/kotlin/kotlinx/validation/test/AndroidLibraryTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2016-2022 JetBrains s.r.o. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +package kotlinx.validation.test + +import kotlinx.validation.api.* +import org.junit.Assume +import org.junit.Test +import java.io.File + +internal class AndroidLibraryTest : BaseKotlinGradleTest() { + + // region Kotlin Android Library + + @Test + fun `Given a Kotlin Android Library, when api is dumped, then task should be successful`() { + assumeHasAndroid() + val runner = test { + createProjectWithSubModules() + runner { + arguments.add(":kotlin-library:apiDump") + arguments.add("--full-stacktrace") + } + } + + runner.build().apply { + assertTaskSuccess(":kotlin-library:apiDump") + } + } + + @Test + fun `Given a Kotlin Android Library, when api is checked, then it should match the expected`() { + assumeHasAndroid() + test { + createProjectWithSubModules() + runner { + arguments.add(":kotlin-library:apiCheck") + } + }.build().apply { + assertTaskSuccess(":kotlin-library:apiCheck") + } + } + + //endregion + + //region Java Android Library + + @Test + fun `Given a Java Android Library, when api is dumped, then task should be successful`() { + assumeHasAndroid() + val runner = test { + createProjectWithSubModules() + runner { + arguments.add(":java-library:apiDump") + arguments.add("--full-stacktrace") + } + } + + runner.build().apply { + assertTaskSuccess(":java-library:apiDump") + } + } + + @Test + fun `Given a Java Android Library, when api is checked, then it should match the expected`() { + assumeHasAndroid() + test { + createProjectWithSubModules() + runner { + arguments.add(":java-library:apiCheck") + } + }.build().apply { + assertTaskSuccess(":java-library:apiCheck") + } + } + + //endregion + + /** + * Creates a single project with 2 (Kotlin and Java Android Library) modules, applies + * the plugin on the root project. + */ + private fun BaseKotlinScope.createProjectWithSubModules() { + settingsGradleKts { + resolve("examples/gradle/settings/settings-android-project.gradle.kts") + } + buildGradleKts { + resolve("examples/gradle/base/androidProjectRoot.gradle.kts") + } + initLocalProperties() + + dir("kotlin-library") { + buildGradleKts { + resolve("examples/gradle/base/androidKotlinLibrary.gradle.kts") + } + kotlin("KotlinLib.kt") { + resolve("examples/classes/KotlinLib.kt") + } + apiFile(projectName = "kotlin-library") { + resolve("examples/classes/KotlinLib.dump") + } + } + dir("java-library") { + buildGradleKts { + resolve("examples/gradle/base/androidJavaLibrary.gradle.kts") + } + java("JavaLib.java") { + resolve("examples/classes/JavaLib.java") + } + apiFile(projectName = "java-library") { + resolve("examples/classes/JavaLib.dump") + } + } + } + + private fun initLocalProperties() { + val home = System.getenv("ANDROID_HOME") ?: System.getenv("HOME") + File(rootProjectDir, "local.properties").apply { + writeText("sdk.dir=$home/Android/Sdk") + } + } + + // We do not have ANDROID_HOME on CI, and this functionality is not critical, so we are disabling these + // tests on CI + private fun assumeHasAndroid() { + Assume.assumeFalse(System.getenv("ANDROID_HOME").isNullOrEmpty()) + } +} diff --git a/src/functionalTest/resources/examples/classes/JavaLib.dump b/src/functionalTest/resources/examples/classes/JavaLib.dump new file mode 100644 index 00000000..3e9c2425 --- /dev/null +++ b/src/functionalTest/resources/examples/classes/JavaLib.dump @@ -0,0 +1,12 @@ +public final class examples/classes/JavaLib { + public fun ()V + public fun foo ()Ljava/lang/String; +} + +public final class org/jetbrains/kotlinx/android/java/library/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public fun ()V +} + diff --git a/src/functionalTest/resources/examples/classes/JavaLib.java b/src/functionalTest/resources/examples/classes/JavaLib.java new file mode 100644 index 00000000..d13c4a54 --- /dev/null +++ b/src/functionalTest/resources/examples/classes/JavaLib.java @@ -0,0 +1,18 @@ +/* + * Copyright 2016-2022 JetBrains s.r.o. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +package examples.classes; + +public final class JavaLib { + + public String foo() { + return "foo"; + } + + private String bar() { + return "bar"; + } + +} diff --git a/src/functionalTest/resources/examples/classes/KotlinLib.dump b/src/functionalTest/resources/examples/classes/KotlinLib.dump new file mode 100644 index 00000000..2ff97443 --- /dev/null +++ b/src/functionalTest/resources/examples/classes/KotlinLib.dump @@ -0,0 +1,12 @@ +public final class examples/classes/KotlinLib { + public fun ()V + public final fun foo ()Ljava/lang/String; +} + +public final class org/jetbrains/kotlinx/android/kotlin/library/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public fun ()V +} + diff --git a/src/functionalTest/resources/examples/classes/KotlinLib.kt b/src/functionalTest/resources/examples/classes/KotlinLib.kt new file mode 100644 index 00000000..0945cd38 --- /dev/null +++ b/src/functionalTest/resources/examples/classes/KotlinLib.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2016-2022 JetBrains s.r.o. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +package examples.classes + +class KotlinLib { + + fun foo(): String = "foo" + internal fun bar(): String = "bar" + +} diff --git a/src/functionalTest/resources/examples/gradle/base/androidJavaLibrary.gradle.kts b/src/functionalTest/resources/examples/gradle/base/androidJavaLibrary.gradle.kts new file mode 100644 index 00000000..4af392a4 --- /dev/null +++ b/src/functionalTest/resources/examples/gradle/base/androidJavaLibrary.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright 2016-2022 JetBrains s.r.o. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +plugins { + id("com.android.library") + id("org.jetbrains.kotlinx.binary-compatibility-validator") +} + +android { + + namespace = "org.jetbrains.kotlinx.android.java.library" + + compileSdk = 32 + + defaultConfig { + minSdk = 31 + targetSdk = 32 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + +} + +dependencies { + // no dependencies required +} diff --git a/src/functionalTest/resources/examples/gradle/base/androidKotlinLibrary.gradle.kts b/src/functionalTest/resources/examples/gradle/base/androidKotlinLibrary.gradle.kts new file mode 100644 index 00000000..f7b99ed4 --- /dev/null +++ b/src/functionalTest/resources/examples/gradle/base/androidKotlinLibrary.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2016-2022 JetBrains s.r.o. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +plugins { + id("com.android.library") + id("kotlin-android") + id("org.jetbrains.kotlinx.binary-compatibility-validator") +} + +android { + + namespace = "org.jetbrains.kotlinx.android.kotlin.library" + + compileSdk = 32 + + defaultConfig { + minSdk = 31 + targetSdk = 32 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + +} + +dependencies { + // no dependencies required +} diff --git a/src/functionalTest/resources/examples/gradle/base/androidProjectRoot.gradle.kts b/src/functionalTest/resources/examples/gradle/base/androidProjectRoot.gradle.kts new file mode 100644 index 00000000..3de5235a --- /dev/null +++ b/src/functionalTest/resources/examples/gradle/base/androidProjectRoot.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("com.android.application").version("7.2.2").apply(false) + id("com.android.library").version("7.2.2").apply(false) + id("org.jetbrains.kotlin.android").version("1.7.10").apply(false) + id("org.jetbrains.kotlinx.binary-compatibility-validator").apply(false) +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} diff --git a/src/functionalTest/resources/examples/gradle/settings/settings-android-project.gradle.kts b/src/functionalTest/resources/examples/gradle/settings/settings-android-project.gradle.kts new file mode 100644 index 00000000..9f409b50 --- /dev/null +++ b/src/functionalTest/resources/examples/gradle/settings/settings-android-project.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "android-project" +include(":kotlin-library") +include(":java-library") diff --git a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt index b0a7841f..672083c2 100644 --- a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt +++ b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt @@ -5,10 +5,12 @@ package kotlinx.validation +import com.android.build.gradle.LibraryExtension import org.gradle.api.* import org.gradle.api.plugins.* import org.gradle.api.provider.* import org.gradle.api.tasks.* +import org.gradle.api.tasks.compile.JavaCompile import org.jetbrains.kotlin.gradle.dsl.* import org.jetbrains.kotlin.gradle.plugin.* import java.io.* @@ -108,8 +110,24 @@ class BinaryCompatibilityValidatorPlugin : Plugin { private fun configureAndroidPlugin( project: Project, extension: ApiValidationExtension + ) { + val kotlinPluginPresent = project.plugins + .withType(KotlinAndroidPluginWrapper::class.java) + .isEmpty().not() + + if (kotlinPluginPresent) { + configureAndroidPluginForKotlinLibrary(project, extension) + } else { + configureAndroidPluginForJavaLibrary(project, extension) + } + } + + private fun configureAndroidPluginForKotlinLibrary( + project: Project, + extension: ApiValidationExtension ) = configurePlugin("kotlin-android", project, extension) { - val androidExtension = project.extensions.getByName("kotlin") as KotlinAndroidProjectExtension + val androidExtension = project.extensions + .getByName("kotlin") as KotlinAndroidProjectExtension androidExtension.target.compilations.matching { it.compilationName == "release" }.all { @@ -117,6 +135,18 @@ class BinaryCompatibilityValidatorPlugin : Plugin { } } + private fun configureAndroidPluginForJavaLibrary( + project: Project, + extension: ApiValidationExtension + ) = configurePlugin("com.android.library", project, extension) { + val androidExtension = project.extensions.getByType(LibraryExtension::class.java) + androidExtension.libraryVariants.matching { + it.name == "release" + }.all { + project.configureJavaCompilation(it.javaCompileProvider, extension) + } + } + private fun configureKotlinPlugin( project: Project, extension: ApiValidationExtension @@ -212,6 +242,39 @@ internal val Project.apiValidationExtensionOrNull: ApiValidationExtension? .map { it.extensions.findByType(ApiValidationExtension::class.java) } .firstOrNull { it != null } +private fun Project.configureJavaCompilation( + configurableFileCollection: TaskProvider, + extension: ApiValidationExtension, + targetConfig: TargetConfig = TargetConfig(this), +) { + val projectName = project.name + val apiDirProvider = targetConfig.apiDir + val apiBuildDir = apiDirProvider.map { buildDir.resolve(it) } + + val apiBuild = task(targetConfig.apiTaskName("Build")) { + isEnabled = apiCheckEnabled(projectName, extension) + // 'group' is not specified deliberately, so it will be hidden from ./gradlew tasks + description = "Builds Java API for 'main' compilations of $projectName. " + + "Complementary task and shouldn't be called manually" + inputClassesDirs = files(provider { + if (isEnabled) configurableFileCollection.get().outputs.files + else emptyList() + }) + inputDependencies = files(provider { + if (isEnabled) configurableFileCollection.get().outputs.files + else emptyList() + }) + outputApiDir = apiBuildDir.get() + } + + configureCheckTasks( + apiBuildDir, + apiBuild, + extension, + targetConfig + ) +} + fun apiCheckEnabled(projectName: String, extension: ApiValidationExtension): Boolean = projectName !in extension.ignoredProjects && !extension.validationDisabled @@ -224,7 +287,7 @@ private fun Project.configureApiTasks( val apiBuildDir = targetConfig.apiDir.map { buildDir.resolve(it) } val apiBuild = task(targetConfig.apiTaskName("Build")) { isEnabled = apiCheckEnabled(projectName, extension) - // 'group' is not specified deliberately so it will be hidden from ./gradlew tasks + // 'group' is not specified deliberately, so it will be hidden from ./gradlew tasks description = "Builds Kotlin API for 'main' compilations of $projectName. Complementary task and shouldn't be called manually" inputClassesDirs = files(provider { if (isEnabled) sourceSet.output.classesDirs else emptyList() })