diff --git a/.gitignore b/.gitignore index 90578c2..4a86f38 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,7 @@ modules.xml ### Gradle ### .gradle **/build/ -!src/**/build/ +!**/src/**/build/ # Ignore Gradle GUI config gradle-app.setting diff --git a/conventions/build.gradle.kts b/conventions/build.gradle.kts index 040ecdc..027cb0b 100644 --- a/conventions/build.gradle.kts +++ b/conventions/build.gradle.kts @@ -16,9 +16,37 @@ publishing { dependencies { compileOnly(gradleApi()) + implementation(libs.gradlePlugin.android) implementation(libs.gradlePlugin.dependencyAnalysis) implementation(libs.gradlePlugin.doctor) implementation(libs.gradlePlugin.githubRelease) + implementation(libs.gradlePlugin.kotlin) implementation(libs.gradlePlugin.mavenPublish) implementation(libs.gradlePlugin.reckon) } + +// Add a source set for the functional test suite +val functionalTestSourceSet = sourceSets.create("functionalTest") { +} + +configurations["functionalTestImplementation"].extendsFrom(configurations["testImplementation"]) + +// Add a task to run the functional tests +val functionalTest by tasks.registering(Test::class) { + testClassesDirs = functionalTestSourceSet.output.classesDirs + classpath = functionalTestSourceSet.runtimeClasspath + useJUnitPlatform() +} + +gradlePlugin.testSourceSets.add(functionalTestSourceSet) + +tasks.named("check") { + // Run the functional tests as part of `check` + dependsOn(functionalTest) +} + +tasks.named("test") { + // Use JUnit Jupiter for unit tests. + useJUnitPlatform() +} + diff --git a/conventions/src/main/kotlin/PluginAccessors.kt b/conventions/src/main/kotlin/PluginAccessors.kt index 39307e6..bd9839c 100644 --- a/conventions/src/main/kotlin/PluginAccessors.kt +++ b/conventions/src/main/kotlin/PluginAccessors.kt @@ -1,8 +1,16 @@ +@file:Suppress("unused") + import org.gradle.plugin.use.PluginDependenciesSpec import org.gradle.plugin.use.PluginDependencySpec +val PluginDependenciesSpec.`polyworld-android-app`: PluginDependencySpec + get() = polyworld("android-app") + +val PluginDependenciesSpec.`polyworld-android-lib`: PluginDependencySpec + get() = polyworld("android-lib") val PluginDependenciesSpec.`polyworld-root`: PluginDependencySpec get() = polyworld("root") + internal fun PluginDependenciesSpec.polyworld( name: String, version: String? = null diff --git a/conventions/src/main/kotlin/com.github.kyhule.polyworld.build.android-app.gradle.kts b/conventions/src/main/kotlin/com.github.kyhule.polyworld.build.android-app.gradle.kts new file mode 100644 index 0000000..7b1b6ce --- /dev/null +++ b/conventions/src/main/kotlin/com.github.kyhule.polyworld.build.android-app.gradle.kts @@ -0,0 +1,33 @@ +import com.github.kyhule.polyworld.build.jdkVersion +import com.github.kyhule.polyworld.build.compileSdkVersion as polyCompileSdkVersion +import com.github.kyhule.polyworld.build.minSdkVersion +import com.github.kyhule.polyworld.build.targetSdkVersion + +plugins { + id("com.android.application") + id("kotlin-android") +} + +android { + compileSdk = polyCompileSdkVersion + + defaultConfig { + minSdk = minSdkVersion + targetSdk = targetSdkVersion + } + + buildTypes { + debug { + matchingFallbacks.add("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(jdkVersion) + targetCompatibility = JavaVersion.toVersion(jdkVersion) + } + + kotlinOptions { + jvmTarget = jdkVersion.toString() + } +} diff --git a/conventions/src/main/kotlin/com.github.kyhule.polyworld.build.android-lib.gradle.kts b/conventions/src/main/kotlin/com.github.kyhule.polyworld.build.android-lib.gradle.kts new file mode 100644 index 0000000..d088c03 --- /dev/null +++ b/conventions/src/main/kotlin/com.github.kyhule.polyworld.build.android-lib.gradle.kts @@ -0,0 +1,33 @@ +import com.github.kyhule.polyworld.build.jdkVersion +import com.github.kyhule.polyworld.build.compileSdkVersion as polyCompileSdkVersion +import com.github.kyhule.polyworld.build.minSdkVersion +import com.github.kyhule.polyworld.build.targetSdkVersion + +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + compileSdk = polyCompileSdkVersion + + defaultConfig { + minSdk = minSdkVersion + targetSdk = targetSdkVersion + } + + buildTypes { + debug { + matchingFallbacks.add("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(jdkVersion) + targetCompatibility = JavaVersion.toVersion(jdkVersion) + } + + kotlinOptions { + jvmTarget = jdkVersion.toString() + } +} diff --git a/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/PolyworldProperties.kt b/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/PolyworldProperties.kt new file mode 100644 index 0000000..355fc9d --- /dev/null +++ b/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/PolyworldProperties.kt @@ -0,0 +1,32 @@ +package com.github.kyhule.polyworld.build + +import com.github.kyhule.polyworld.build.util.* +import com.github.kyhule.polyworld.build.util.booleanProperty +import com.github.kyhule.polyworld.build.util.longProperty +import org.gradle.api.Project + + +val Project.compileSdkVersion: Int + get() = intProperty("polyworld.compileSdkVersion", defaultValue = 33) + +val Project.minSdkVersion: Int + get() = intProperty("polyworld.minSdkVersion", defaultValue = 24) + +val Project.targetSdkVersion: Int + get() = intProperty("polyworld.targetSdkVersion", defaultValue = 33) + +val Project.jdkVersion: Int + get() = intProperty("polyworld.jdkVersion", defaultValue = 17) + +/** Flag to enable verbose logging in unit tests. */ +val Project.testVerboseLogging: Boolean + get() = booleanProperty("polyworld.test.verboseLogging") + +/** + * Property corresponding to the number unit tests to run on a fork before it is disposed. + * This helps when tests leak memory. + * + * **See Also:** [Forking options](https://docs.gradle.org/current/userguide/performance.html#forking_options) + */ +val Project.testForkEvery: Long + get() = longProperty("polyworld.test.forkEvery", defaultValue = 100L) diff --git a/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/util/HasConfigurableValues.kt b/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/util/HasConfigurableValues.kt new file mode 100644 index 0000000..9428a16 --- /dev/null +++ b/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/util/HasConfigurableValues.kt @@ -0,0 +1,73 @@ +package com.github.kyhule.polyworld.build.util + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.provider.SetProperty + +/* + * APIs adapted from `HasConfigurableValues.kt` in AGP. Copied for binary safety. + */ + +internal fun ConfigurableFileCollection.fromDisallowChanges(vararg arg: Any) { + from(*arg) + disallowChanges() +} + +internal fun Property.setDisallowChanges(value: T?) { + set(value) + disallowChanges() +} + +internal fun Property.setDisallowChanges(value: Provider) { + set(value) + disallowChanges() +} + +internal fun ListProperty.setDisallowChanges(value: Provider>) { + set(value) + disallowChanges() +} + +internal fun ListProperty.setDisallowChanges(value: Iterable?) { + set(value) + disallowChanges() +} + +internal fun MapProperty.setDisallowChanges(map: Provider>) { + set(map) + disallowChanges() +} + +internal fun MapProperty.setDisallowChanges(map: Map?) { + set(map) + disallowChanges() +} + +internal fun SetProperty.setDisallowChanges(value: Provider>) { + set(value) + disallowChanges() +} + +internal fun SetProperty.setDisallowChanges(value: Iterable?) { + set(value) + disallowChanges() +} + +internal fun ListProperty.setDisallowChanges( + value: Provider>?, + handleNullable: ListProperty.() -> Unit +) { + value?.let { set(value) } ?: handleNullable() + disallowChanges() +} + +internal fun MapProperty.setDisallowChanges( + map: Provider>?, + handleNullable: MapProperty.() -> Unit +) { + map?.let { set(map) } ?: handleNullable() + disallowChanges() +} diff --git a/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/util/PropertyUtil.kt b/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/util/PropertyUtil.kt new file mode 100644 index 0000000..eead7f9 --- /dev/null +++ b/conventions/src/main/kotlin/com/github/kyhule/polyworld/build/util/PropertyUtil.kt @@ -0,0 +1,247 @@ +package com.github.kyhule.polyworld.build.util + +import java.util.Properties +import kotlin.contracts.contract +import org.gradle.StartParameter +import org.gradle.api.Project +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ValueSource +import org.gradle.api.provider.ValueSourceParameters +import org.gradle.api.provider.ValueSourceSpec +import kotlin.contracts.ExperimentalContracts + +/* + * APIs adapted from `PropertyUtil.kt` in slack-gradle-plugin. + */ + +// Gradle's map {} APIs sometimes are interpreted by Kotlin to be non-null only but legally allow +// null returns. This +// abuses kotlin contracts to safe cast without a null check. +// https://github.com/gradle/gradle/issues/12388 +internal fun sneakyNull(value: T? = null): T { + markAsNonNullForGradle(value) + return value +} + +// Gradle's map {} APIs sometimes are interpreted by Kotlin to be non-null only but legally allow +// null returns. This +// abuses kotlin contracts to safe cast without a null check. +@OptIn(ExperimentalContracts::class) +internal fun markAsNonNullForGradle(value: T?) { + contract { returns() implies (value != null) } +} + +/** Implementation of provider holding a start parameter's parsed project properties. */ +internal abstract class StartParameterProperties : + ValueSource, StartParameterProperties.Parameters> { + interface Parameters : ValueSourceParameters { + val properties: MapProperty + } + + override fun obtain(): Map? { + return parameters.properties.getOrElse(emptyMap()) + } +} + +/** Implementation of provider holding a local properties file's parsed [Properties]. */ +internal abstract class LocalProperties : ValueSource { + interface Parameters : ValueSourceParameters { + val propertiesFile: RegularFileProperty + } + + override fun obtain(): Properties? { + val provider = parameters.propertiesFile + if (!provider.isPresent) { + return null + } + val propertiesFile = provider.asFile.get() + if (!propertiesFile.exists()) { + return null + } + return Properties().apply { propertiesFile.inputStream().use(::load) } + } +} + +/** Gets or creates a cached extra property. */ +internal fun Project.getOrCreateExtra(key: String, body: (Project) -> T): T { + with(project.extensions.extraProperties) { + if (!has(key)) { + set(key, body(project)) + } + @Suppress("UNCHECKED_CAST") + return get(key) as? T ?: body(project) // Fallback if multiple class loaders are involved + } +} + +/** + * Abstraction for loading a [Map] provider that handles caching automatically per root project. + * This way properties are only ever parsed at most once per root project. The goal for this is to + * build on top of [LocalProperties] and provide a more convenient API for accessing properties from + * multiple sources in a configuration-caching-compatible way. Start parameters are special because + * they come from [StartParameter.projectProperties] and are intended to supersede any other + * property values. + */ +private fun Project.startParameterProperties(key: String): Provider { + val provider = + project.rootProject.getOrCreateExtra("slack.properties.provider.start-properties") { + val startParameters = project.gradle.startParameter.projectProperties + it.providers.of(StartParameterProperties::class.java) { + parameters.properties.setDisallowChanges(startParameters) + } + } + return provider.map { sneakyNull(it[key]) } +} + +/** + * Abstraction for loading a [Properties] provider that handles caching automatically per-project. + * This way files are only ever parsed at most once per project. The goal for this is to build on + * top of [LocalProperties] and provide a more convenient API for accessing properties from multiple + * sources in a configuration-caching-compatible way. + */ +private fun Project.localPropertiesProvider( + key: String, + cacheKey: String, + valueSourceSpec: ValueSourceSpec.() -> Unit +): Provider { + val provider = + project.getOrCreateExtra(cacheKey) { + it.providers.of(LocalProperties::class.java, valueSourceSpec) + } + return provider.map { it.getProperty(key) } +} + +/** Returns a provider of a property _only_ contained in this project's local.properties. */ +internal fun Project.localProperty(key: String): Provider { + return localPropertiesProvider(key, "slack.properties.provider.local-properties") { + parameters.propertiesFile.setDisallowChanges( + project.layout.projectDirectory.file("local.properties") + ) + } +} + +/** Returns a provider of a property _only_ contained in this project's local gradle.properties. */ +// Local gradle properties are not compatible with configuration caching and thus not accessible +// from +// providers.gradleProperty -_-. https://github.com/gradle/gradle/issues/13302 +internal fun Project.localGradleProperty(key: String): Provider { + return localPropertiesProvider(key, "slack.properties.provider.local-gradle-properties") { + parameters.propertiesFile.setDisallowChanges( + project.layout.projectDirectory.file("gradle.properties") + ) + } +} + +/** + * A "safe" property access mechanism that handles multiple property sources. + * + * This checks in the following order of priority + * - project-local `local.properties` + * - project-local `gradle.properties` + * - root-project `local.properties` + * - root-project/global `gradle.properties` + */ +public fun Project.safeProperty(key: String): Provider { + return rootProject + .startParameterProperties(key) // start parameters + .orElse(localProperty(key)) // project-local `local.properties` + .orElse(localGradleProperty(key)) // project-local `gradle.properties` + .orElse(rootProject.localProperty(key)) // root-project `local.properties` + .orElse(providers.gradleProperty(key)) // root-project/global `gradle.properties` +} + +internal fun Provider.mapToBoolean(): Provider { + return map(String::toBoolean) +} + +internal fun Provider.mapToInt(): Provider { + return map(String::toInt) +} + +internal fun Provider.mapToLong(): Provider { + return map(String::toLong) +} + +@Suppress("RedundantNullableReturnType") +internal fun Project.synchronousEnvProperty(env: String, default: String? = null): String? { + return providers.environmentVariable(env).getOrElse(sneakyNull(default)) +} + +// TODO rename these scalar types to Value +internal fun Project.booleanProperty(key: String, defaultValue: Boolean = false): Boolean { + return booleanProvider(key, defaultValue).get() +} + +internal fun Project.booleanProperty(key: String, defaultValue: Provider): Boolean { + return booleanProvider(key, defaultValue).get() +} + +internal fun Project.booleanProvider( + key: String, + defaultValue: Boolean = false +): Provider { + return booleanProvider(key, provider { defaultValue }) +} + +internal fun Project.booleanProvider( + key: String, + defaultValue: Provider +): Provider { + return booleanProvider(key).orElse(defaultValue) +} + +internal fun Project.booleanProvider( + key: String, +): Provider { + return safeProperty(key).mapToBoolean() +} + +internal fun Project.intProperty(key: String, defaultValue: Int = -1): Int { + return intProvider(key, defaultValue).get() +} + +internal fun Project.intProperty(key: String, defaultValue: Provider): Int { + return intProvider(key, defaultValue).get() +} + +internal fun Project.intProvider(key: String, defaultValue: Int = -1): Provider { + return intProvider(key, provider { defaultValue }) +} + +internal fun Project.intProvider(key: String, defaultValue: Provider): Provider { + return safeProperty(key).mapToInt().orElse(defaultValue) +} + +internal fun Project.longProperty(key: String, defaultValue: Long = -1): Long { + return longProperty(key, provider { defaultValue }) +} + +internal fun Project.longProperty(key: String, defaultValue: Provider): Long { + return safeProperty(key).mapToLong().orElse(defaultValue).get() +} + +internal fun Project.stringProperty(key: String): String { + return optionalStringProperty(key) + ?: error("No property for $key found and no default value was provided.") +} + +internal fun Project.stringProperty(key: String, defaultValue: String): String { + return optionalStringProperty(key, defaultValue) + ?: error("No property for $key found and no default value was provided.") +} + +internal fun Project.optionalStringProperty(key: String, defaultValue: String? = null): String? { + return safeProperty(key).orNull ?: defaultValue +} + +internal fun Project.optionalStringProvider(key: String): Provider { + return optionalStringProvider(key, null) +} + +internal fun Project.optionalStringProvider( + key: String, + defaultValue: String? = null +): Provider { + return safeProperty(key).let { defaultValue?.let { provider { defaultValue } } ?: it } +}