blob: c596da566ccfc7d06fc95c39c49b31cb513e7d1e [file] [log] [blame]
/*
* Copyright 2019 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.
*/
package androidx.build
import androidx.build.dependencies.KOTLIN_NATIVE_VERSION
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.AppExtension
import com.android.build.gradle.AppPlugin
import com.android.build.gradle.LibraryExtension
import com.android.build.gradle.LibraryPlugin
import com.android.build.gradle.TestedExtension
import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask
import com.android.build.gradle.internal.lint.AndroidLintTask
import com.android.build.gradle.internal.lint.LintModelWriterTask
import com.android.build.gradle.internal.lint.VariantInputs
import java.io.File
import kotlin.reflect.KFunction
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.type.ArtifactTypeDefinition
import org.gradle.api.attributes.Attribute
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.plugins.ExtraPropertiesExtension
import org.gradle.api.tasks.ClasspathNormalizer
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.create
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinBasePluginWrapper
import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.tooling.core.withClosure
const val composeSourceOption =
"plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true"
const val composeMetricsOption =
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination"
const val composeReportsOption =
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination"
const val enableMetricsArg = "androidx.enableComposeCompilerMetrics"
const val enableReportsArg = "androidx.enableComposeCompilerReports"
/**
* Plugin to apply common configuration for Compose projects.
*/
class AndroidXComposeImplPlugin : Plugin<Project> {
override fun apply(project: Project) {
val f: KFunction<Unit> = Companion::applyAndConfigureKotlinPlugin
project.extensions.add("applyAndConfigureKotlinPlugin", f)
val extension = project.extensions.create<AndroidXComposeExtension>(
"androidxCompose",
project
)
project.plugins.all { plugin ->
when (plugin) {
is LibraryPlugin -> {
val library = project.extensions.findByType(LibraryExtension::class.java)
?: throw Exception("Failed to find Android extension")
project.configureAndroidCommonOptions(library)
}
is AppPlugin -> {
val app = project.extensions.findByType(AppExtension::class.java)
?: throw Exception("Failed to find Android extension")
project.configureAndroidCommonOptions(app)
}
is KotlinBasePluginWrapper -> {
configureComposeCompilerPlugin(project, extension)
if (plugin is KotlinMultiplatformPluginWrapper) {
project.configureForMultiplatform()
}
}
}
}
}
companion object {
/**
* @param isMultiplatformEnabled whether this module has a corresponding
* multiplatform configuration, or whether it is Android only
*/
fun applyAndConfigureKotlinPlugin(
project: Project,
isMultiplatformEnabled: Boolean
) {
if (isMultiplatformEnabled) {
project.apply(plugin = "kotlin-multiplatform")
project.extensions.create(
AndroidXComposeMultiplatformExtension::class.java,
"androidXComposeMultiplatform",
AndroidXComposeMultiplatformExtensionImpl::class.java
)
} else {
project.apply(plugin = "org.jetbrains.kotlin.android")
}
project.configureManifests()
if (isMultiplatformEnabled) {
project.configureForMultiplatform()
} else {
project.configureForKotlinMultiplatformSourceStructure()
}
project.tasks.withType(KotlinCompile::class.java).configureEach { compile ->
// Needed to enable `expect` and `actual` keywords
compile.kotlinOptions.freeCompilerArgs += "-Xmulti-platform"
}
}
private fun Project.androidxExtension(): AndroidXExtension? {
return extensions.findByType(AndroidXExtension::class.java)
}
private fun Project.configureAndroidCommonOptions(testedExtension: TestedExtension) {
testedExtension.defaultConfig.minSdk = 21
@Suppress("UnstableApiUsage")
extensions.findByType(AndroidComponentsExtension::class.java)!!.finalizeDsl {
val isPublished = androidxExtension()?.shouldPublish() ?: false
it.lint {
// Too many Kotlin features require synthetic accessors - we want to rely on R8 to
// remove these accessors
disable.add("SyntheticAccessor")
// These lint checks are normally a warning (or lower), but we ignore (in AndroidX)
// warnings in Lint, so we make it an error here so it will fail the build.
// Note that this causes 'UnknownIssueId' lint warnings in the build log when
// Lint tries to apply this rule to modules that do not have this lint check, so
// we disable that check too
disable.add("UnknownIssueId")
error.add("ComposableNaming")
error.add("ComposableLambdaParameterNaming")
error.add("ComposableLambdaParameterPosition")
error.add("CompositionLocalNaming")
error.add("ComposableModifierFactory")
error.add("AutoboxingStateCreation")
error.add("AutoboxingStateValueProperty")
error.add("InvalidColorHexValue")
error.add("MissingColorAlphaChannel")
error.add("ModifierFactoryReturnType")
error.add("ModifierFactoryExtensionFunction")
error.add("ModifierNodeInspectableProperties")
error.add("ModifierParameter")
error.add("MutableCollectionMutableState")
error.add("OpaqueUnitKey")
error.add("UnnecessaryComposedModifier")
error.add("FrequentlyChangedStateReadInComposition")
error.add("ReturnFromAwaitPointerEventScope")
error.add("UseOfNonLambdaOffsetOverload")
error.add("MultipleAwaitPointerEventScopes")
// Paths we want to enable ListIterator checks for - for higher level
// libraries it won't have a noticeable performance impact, and we don't want
// developers reading high level library code to worry about this.
val listIteratorPaths = listOf(
"compose:foundation",
"compose:runtime",
"compose:ui",
"text"
)
// Paths we want to disable ListIteratorChecks for - these are not runtime
// libraries and so Iterator allocation is not relevant.
val ignoreListIteratorFilter = listOf(
"compose:ui:ui-test",
"compose:ui:ui-tooling",
"compose:ui:ui-inspection",
)
// Disable ListIterator if we are not in a matching path, or we are in an
// unpublished project
if (
listIteratorPaths.none { path.contains(it) } ||
ignoreListIteratorFilter.any { path.contains(it) } ||
!isPublished
) {
disable.add("ListIterator")
}
}
}
if (!allowMissingLintProject()) {
// TODO: figure out how to apply this to multiplatform modules
dependencies.add(
"lintChecks",
project.dependencies.project(
mapOf(
"path" to ":compose:lint:internal-lint-checks",
// TODO(b/206617878) remove this shadow configuration
"configuration" to "shadow"
)
)
)
}
}
private fun Project.configureManifests() {
val libraryExtension = project.extensions.findByType<LibraryExtension>() ?: return
libraryExtension.apply {
sourceSets.findByName("main")!!.manifest
.srcFile("src/androidMain/AndroidManifest.xml")
sourceSets.findByName("androidTest")!!.manifest
.srcFile("src/androidAndroidTest/AndroidManifest.xml")
}
}
/**
* General configuration for MPP projects. In the future, these workarounds should either be
* generified and added to AndroidXPlugin, or removed as/when the underlying issues have been
* resolved.
*/
private fun Project.configureForKotlinMultiplatformSourceStructure() {
val libraryExtension = project.extensions.findByType<LibraryExtension>() ?: return
// TODO: b/148416113: AGP doesn't know about Kotlin-MPP's sourcesets yet, so add
// them to its source directories (this fixes lint, and code completion in
// Android Studio on versions >= 4.0canary8)
libraryExtension.apply {
sourceSets.findByName("main")?.apply {
java.srcDirs(
"src/commonMain/kotlin", "src/jvmMain/kotlin",
"src/androidMain/kotlin"
)
res.srcDirs(
"src/commonMain/resources",
"src/androidMain/res"
)
assets.srcDirs("src/androidMain/assets")
// Keep Kotlin files in java source sets so the source set is not empty when
// running unit tests which would prevent the tests from running in CI.
java.includes.add("**/*.kt")
}
sourceSets.findByName("test")?.apply {
java.srcDirs(
"src/commonTest/kotlin", "src/jvmTest/kotlin"
)
res.srcDirs("src/commonTest/res", "src/jvmTest/res")
// Keep Kotlin files in java source sets so the source set is not empty when
// running unit tests which would prevent the tests from running in CI.
java.includes.add("**/*.kt")
}
sourceSets.findByName("androidTest")?.apply {
java.srcDirs("src/androidAndroidTest/kotlin")
res.srcDirs("src/androidAndroidTest/res")
assets.srcDirs("src/androidAndroidTest/assets")
// Keep Kotlin files in java source sets so the source set is not empty when
// running unit tests which would prevent the tests from running in CI.
java.includes.add("**/*.kt")
}
}
}
/**
* General configuration for MPP projects. In the future, these workarounds should either be
* generified and added to AndroidXPlugin, or removed as/when the underlying issues have been
* resolved.
*/
private fun Project.configureForMultiplatform() {
// This is to allow K/N not matching the kotlinVersion
(this.rootProject.property("ext") as ExtraPropertiesExtension)
.set("kotlin.native.version", KOTLIN_NATIVE_VERSION)
val multiplatformExtension = checkNotNull(multiplatformExtension) {
"Unable to configureForMultiplatform() when " +
"multiplatformExtension is null (multiplatform plugin not enabled?)"
}
/*
The following configures source sets - note:
1. The common unit test source set, commonTest, is included by default in both android
unit and instrumented tests. This causes unnecessary duplication, so we explicitly do
_not_ use commonTest, instead choosing to just use the unit test variant.
TODO: Consider using commonTest for unit tests if a usable feature is added for
https://youtrack.jetbrains.com/issue/KT-34662.
2. The default (android) unit test source set is named 'androidTest', which conflicts / is
confusing as this shares the same name / expected directory as AGP's 'androidTest', which
represents _instrumented_ tests.
TODO: Consider changing unitTest to androidLocalTest and androidAndroidTest to
androidDeviceTest when https://github.com/JetBrains/kotlin/pull/2829 rolls in.
*/
multiplatformExtension.sourceSets.all {
// Allow all experimental APIs, since MPP projects are themselves experimental
it.languageSettings.apply {
optIn("kotlin.ExperimentalMultiplatform")
}
}
configureLintForMultiplatformLibrary(multiplatformExtension)
afterEvaluate {
if (multiplatformExtension.targets.findByName("jvm") != null) {
tasks.named("jvmTestClasses").also(::addToBuildOnServer)
}
if (multiplatformExtension.targets.findByName("desktop") != null) {
tasks.named("desktopTestClasses").also(::addToBuildOnServer)
}
}
}
}
}
private const val COMPILER_PLUGIN_CONFIGURATION = "kotlinPlugin"
private fun configureComposeCompilerPlugin(
project: Project,
extension: AndroidXComposeExtension
) {
project.afterEvaluate {
// If a project has opted-out of Compose compiler plugin, don't add it
if (!extension.composeCompilerPluginEnabled) return@afterEvaluate
val androidXExtension = project.extensions.findByType(AndroidXExtension::class.java)
?: throw Exception("You have applied AndroidXComposePlugin without AndroidXPlugin")
val shouldPublish = androidXExtension.shouldPublish()
// Create configuration that we'll use to load Compose compiler plugin
val configuration = project.configurations.create(COMPILER_PLUGIN_CONFIGURATION)
// Add Compose compiler plugin to kotlinPlugin configuration, making sure it works
// for Playground builds as well
project.dependencies.add(
COMPILER_PLUGIN_CONFIGURATION,
if (ProjectLayoutType.isPlayground(project)) {
AndroidXPlaygroundRootImplPlugin.projectOrArtifact(
project.rootProject,
":compose:compiler:compiler"
)
} else {
project.rootProject.resolveProject(":compose:compiler:compiler")
}
)
val kotlinPlugin = configuration.incoming.artifactView { view ->
view.attributes { attributes ->
attributes.attribute(
Attribute.of("artifactType", String::class.java),
ArtifactTypeDefinition.JAR_TYPE
)
}
}.files
val enableMetricsProvider = project.providers.gradleProperty(enableMetricsArg)
val enableReportsProvider = project.providers.gradleProperty(enableReportsArg)
val libraryMetricsDirectory = project.rootProject.getLibraryMetricsDirectory()
val libraryReportsDirectory = project.rootProject.getLibraryReportsDirectory()
project.tasks.withType(KotlinCompile::class.java).configureEach { compile ->
// Append inputs to KotlinCompile so tasks get invalidated if any of these values change
compile.inputs.files({ kotlinPlugin })
.withPropertyName("composeCompilerExtension")
.withNormalizer(ClasspathNormalizer::class.java)
compile.inputs.property("composeMetricsEnabled", enableMetricsProvider).optional(true)
compile.inputs.property("composeReportsEnabled", enableReportsProvider).optional(true)
// Gradle hack ahead, we use of absolute paths, but is OK here because we do it in
// doFirst which happens after Gradle task input snapshotting. AGP does the same.
compile.doFirst {
compile.kotlinOptions.freeCompilerArgs += "-Xplugin=${kotlinPlugin.first()}"
if (enableMetricsProvider.orNull == "true") {
val metricsDest = File(libraryMetricsDirectory, "compose")
compile.kotlinOptions.freeCompilerArgs +=
listOf(
"-P",
"$composeMetricsOption=${metricsDest.absolutePath}"
)
}
if ((enableReportsProvider.orNull == "true")) {
val reportsDest = File(libraryReportsDirectory, "compose")
compile.kotlinOptions.freeCompilerArgs +=
listOf(
"-P",
"$composeReportsOption=${reportsDest.absolutePath}"
)
}
if (shouldPublish) {
compile.kotlinOptions.freeCompilerArgs +=
listOf("-P", composeSourceOption)
}
}
}
}
}
/**
* Adds missing MPP sourcesets (such as commonMain) to the Lint tasks
*
* TODO: b/195329463
* Lint is not aware of MPP, and MPP doesn't configure Lint. There is no built-in
* API to adjust the default Lint task's sources, so we use this hack to manually
* add sources for MPP source sets. In the future with the new Kotlin Project Model
* (https://youtrack.jetbrains.com/issue/KT-42572) and an AGP / MPP integration
* plugin this will no longer be needed.
*/
private fun Project.configureLintForMultiplatformLibrary(
multiplatformExtension: KotlinMultiplatformExtension
) {
afterEvaluate {
// This workaround only works for libraries (apps would require changes to a different
// task). Given that we currently do not have any MPP app projects, this should never
// happen.
project.extensions.findByType<LibraryExtension>()
?: return@afterEvaluate
val androidMain = multiplatformExtension.sourceSets.findByName("androidMain")
?: return@afterEvaluate
// Get all the sourcesets androidMain transitively / directly depends on
val dependencies = androidMain.withClosure(KotlinSourceSet::dependsOn)
/**
* Helper function to add the missing sourcesets to this [VariantInputs]
*/
fun VariantInputs.addSourceSets() {
// Each variant has a source provider for the variant (such as debug) and the 'main'
// variant. The actual files that Lint will run on is both of these providers
// combined - so we can just add the dependencies to the first we see.
val sourceProvider = sourceProviders.get().firstOrNull() ?: return
dependencies.forEach { sourceSet ->
sourceProvider.javaDirectories.withChangesAllowed {
from(sourceSet.kotlin.sourceDirectories)
}
}
}
// Lint for libraries is split into two tasks - analysis, and reporting. We need to
// add the new sources to both, so all parts of the pipeline are aware.
project.tasks.withType<AndroidLintAnalysisTask>().configureEach {
it.variantInputs.addSourceSets()
}
project.tasks.withType<AndroidLintTask>().configureEach {
it.variantInputs.addSourceSets()
}
// Also configure the model writing task, so that we don't run into mismatches between
// analyzed sources in one module and a downstream module
project.tasks.withType<LintModelWriterTask>().configureEach {
it.variantInputs.addSourceSets()
}
}
}
/**
* Lint uses [ConfigurableFileCollection.disallowChanges] during initialization, which prevents
* modifying the file collection separately (there is no time to configure it before AGP has
* initialized and disallowed changes). This uses reflection to temporarily allow changes, and
* apply [block].
*/
private fun ConfigurableFileCollection.withChangesAllowed(
block: ConfigurableFileCollection.() -> Unit
) {
val disallowChanges = this::class.java.getDeclaredField("disallowChanges")
disallowChanges.isAccessible = true
disallowChanges.set(this, false)
block()
disallowChanges.set(this, true)
}
/**
* General purpose implementation of a transitive closure
* - Recursion free
* - Predictable amount of allocations
* - Handles loops and self references gracefully
* @param edges: Producer function from one node to all its children. This implementation can handle loops and self references gracefully.
* @return Note: No guarantees given about the order ot this [Set]
*/
public inline fun <reified T> transitiveClosure(seed: T, edges: T.() -> Iterable<T>): Set<T> {
// Fast path when initial edges are empty
val initialEdges = seed.edges()
if (initialEdges is Collection && initialEdges.isEmpty()) return emptySet()
val queue = deque<T>(initialEdges.count() * 2)
val results = mutableSetOf<T>()
queue.addAll(initialEdges)
while (queue.isNotEmpty()) {
// ArrayDeque implementation will optimize this call to 'removeFirst'
val resolved = queue.removeAt(0)
if (resolved != seed && results.add(resolved)) {
queue.addAll(resolved.edges())
}
}
return results.toSet()
}
@PublishedApi
internal inline fun <reified T> deque(initialSize: Int): MutableList<T> {
return if (KotlinVersion.CURRENT.isAtLeast(1, 4)) ArrayDeque(initialSize)
else ArrayList(initialSize)
}