diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 717829c..58ff007 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ on: - patch - minor - major + default: 'minor' version-properties-file: description: 'Path to properties file containing version' required: false diff --git a/README.md b/README.md index 49bb2c9..4bd477c 100644 --- a/README.md +++ b/README.md @@ -13,30 +13,52 @@ dependencies, and configurations used across Stream's Android projects. ## Available Plugins +- **`io.getstream.project`** - Root project configuration (apply to root `build.gradle.kts`) - **`io.getstream.android.library`** - For Android library modules - **`io.getstream.android.application`** - For Android application modules +- **`io.getstream.android.test`** - For Android test modules - **`io.getstream.java.library`** - For Java/Kotlin JVM library modules ## Usage -Add the plugin to your project's build file: +### 1. Root Project Configuration + +Apply the root plugin in your root `build.gradle.kts`: ```kotlin plugins { - id("io.getstream.android.library") version "" - // or - id("io.getstream.android.application") version "" - // or - id("io.getstream.java.library") version "" + id("io.getstream.project") version "" +} + +streamProject { + // Repository name for GitHub URLs and license headers (default: project name) + repositoryName = "stream-chat-android" + + spotless { + // Choose formatter (default: false = ktlint) + useKtfmt = false + + // Exclude specific modules from Spotless formatting (default: empty) + ignoredModules = setOf("some-module") + + // Exclude file patterns from Spotless formatting (default: empty) + excludePatterns = setOf("**/generated/**") + } } ``` -## Distribution +### 2. Module Configuration -These plugins are published to: +Apply the appropriate plugin to each module: -- [Maven Central](https://central.sonatype.com/) -- [Gradle Plugin Portal](https://plugins.gradle.org/) +```kotlin +plugins { + id("io.getstream.android.library") + // or id("io.getstream.android.application") + // or id("io.getstream.android.test") + // or id("io.getstream.java.library") +} +``` ## License diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59711c0..559c729 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,14 +2,15 @@ agp = "8.11.1" kotlin = "2.0.21" detekt = "1.23.8" -spotless = "7.2.1" +spotless = "8.0.0" kotlinDokka = "2.0.0" gradlePluginPublish = "2.0.0" -mavenPublish = "0.32.0" +mavenPublish = "0.34.0" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +spotless-gradle-plugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 443934f..f636d86 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -1,6 +1,5 @@ import com.vanniktech.maven.publish.GradlePlugin import com.vanniktech.maven.publish.JavadocJar -import com.vanniktech.maven.publish.SonatypeHost import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -24,6 +23,7 @@ dependencies { compileOnly(gradleKotlinDsl()) compileOnly(libs.android.gradle.plugin) compileOnly(libs.kotlin.gradle.plugin) + implementation(libs.spotless.gradle.plugin) } val repoId = "GetStream/stream-build-conventions-android" @@ -34,6 +34,14 @@ gradlePlugin { vcsUrl = repoUrl plugins { + create("root") { + id = "io.getstream.project" + implementationClass = "io.getstream.android.RootConventionPlugin" + displayName = "Stream Root Convention Plugin" + description = + "Root convention plugin for Stream projects - configures project-wide settings" + tags = listOf("stream", "conventions", "configuration") + } create("androidLibrary") { id = "io.getstream.android.library" implementationClass = "io.getstream.android.AndroidLibraryConventionPlugin" @@ -48,6 +56,13 @@ gradlePlugin { description = "Convention plugin for Stream Android application modules" tags = listOf("android", "application", "convention", "stream", "kotlin") } + create("androidTest") { + id = "io.getstream.android.test" + implementationClass = "io.getstream.android.AndroidTestConventionPlugin" + displayName = "Stream Android Test Convention Plugin" + description = "Convention plugin for Stream Android test modules" + tags = listOf("android", "test", "convention", "stream", "kotlin") + } create("javaLibrary") { id = "io.getstream.java.library" implementationClass = "io.getstream.android.JavaLibraryConventionPlugin" @@ -59,7 +74,7 @@ gradlePlugin { } mavenPublishing { - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + publishToMavenCentral(automaticRelease = true) configure(GradlePlugin(javadocJar = JavadocJar.Javadoc(), sourcesJar = true)) pom { diff --git a/plugin/src/main/kotlin/io/getstream/android/GenerateLicenseFileTask.kt b/plugin/src/main/kotlin/io/getstream/android/GenerateLicenseFileTask.kt new file mode 100644 index 0000000..61aaf08 --- /dev/null +++ b/plugin/src/main/kotlin/io/getstream/android/GenerateLicenseFileTask.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE + * + * 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 io.getstream.android + +import java.io.BufferedReader +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Task that generates license header files from templates bundled in the plugin. + * + * This task uses [@Classpath] to track the plugin JAR that contains the template resources. When + * the plugin is updated with new template content, Gradle will automatically detect the change and + * regenerate the license files. + */ +@CacheableTask +abstract class GenerateLicenseFileTask : DefaultTask() { + @get:Input abstract val repositoryName: Property + + @get:Input abstract val templateName: Property + + /** + * The classpath containing the plugin resources (the plugin JAR itself). Used to track changes + * to the template files so the task is re-run. + */ + @get:Classpath abstract val pluginClasspath: ConfigurableFileCollection + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val templateContent = + javaClass.classLoader + .getResourceAsStream(templateName.get()) + ?.bufferedReader() + ?.use(BufferedReader::readText) + ?: throw IllegalStateException( + "Could not find bundled ${templateName.get()} resource" + ) + + outputFile + .get() + .asFile + .writeText(templateContent.replace("\$PROJECT", repositoryName.get())) + } +} diff --git a/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt b/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt new file mode 100644 index 0000000..e38f42a --- /dev/null +++ b/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE + * + * 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 io.getstream.android + +import io.getstream.android.spotless.SpotlessOptions +import javax.inject.Inject +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.property + +/** + * Extension for configuring Stream project-wide settings. Apply the `io.getstream.project` plugin + * to the root project to use this extension. + */ +abstract class StreamProjectExtension +@Inject +constructor(project: Project, objects: ObjectFactory) { + + /** The repository name used for inferring the repository URL. Example: "stream-core-android" */ + val repositoryName: Property = + objects.property().convention(project.provider { project.rootProject.name }) + + /** Spotless formatting configuration */ + val spotless: SpotlessOptions = objects.newInstance(SpotlessOptions::class.java) + + /** Configure Spotless formatting */ + fun spotless(action: Action) = action.execute(spotless) +} + +internal fun Project.createProjectExtension(): StreamProjectExtension = + extensions.create("streamProject") + +internal fun Project.requireStreamProjectExtension(): StreamProjectExtension = + requireNotNull(rootProject.extensions.findByType()) { + "${StreamProjectExtension::class.simpleName} not found. " + + "Apply the io.getstream.project plugin to the root project" + } diff --git a/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt b/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt index d1ac24b..298c648 100644 --- a/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt +++ b/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt @@ -17,9 +17,27 @@ package io.getstream.android import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.dsl.TestExtension +import io.getstream.android.spotless.configureSpotless import org.gradle.api.Plugin import org.gradle.api.Project +/** + * Root-level convention plugin for Stream projects. Apply this plugin to the root project to + * configure project-wide settings. + */ +class RootConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + require(this == rootProject) { + "The io.getstream.project plugin should be applied to the root project only" + } + + createProjectExtension() + } + } +} + class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { @@ -27,6 +45,7 @@ class AndroidApplicationConventionPlugin : Plugin { configureAndroid() configureKotlin() + configureSpotless() } } } @@ -38,6 +57,19 @@ class AndroidLibraryConventionPlugin : Plugin { configureAndroid() configureKotlin() + configureSpotless() + } + } +} + +class AndroidTestConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.test") + + configureAndroid() + configureKotlin() + configureSpotless() } } } @@ -49,6 +81,7 @@ class JavaLibraryConventionPlugin : Plugin { configureJava() configureKotlin() + configureSpotless() } } } diff --git a/plugin/src/main/kotlin/io/getstream/android/spotless/SpotlessConfiguration.kt b/plugin/src/main/kotlin/io/getstream/android/spotless/SpotlessConfiguration.kt new file mode 100644 index 0000000..9604863 --- /dev/null +++ b/plugin/src/main/kotlin/io/getstream/android/spotless/SpotlessConfiguration.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE + * + * 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 io.getstream.android.spotless + +import com.diffplug.gradle.spotless.SpotlessExtension +import io.getstream.android.GenerateLicenseFileTask +import io.getstream.android.requireStreamProjectExtension +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.TaskContainer +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register + +private const val KTLINT_VERSION = "0.50.0" + +internal fun Project.configureSpotless() { + pluginManager.apply("com.diffplug.spotless") + + afterEvaluate { + val projectExtension = requireStreamProjectExtension() + + // Check if Spotless should be disabled for this module + if (name in projectExtension.spotless.ignoredModules.get()) { + return@afterEvaluate + } + + val repositoryName = + projectExtension.repositoryName.orNull + ?: error("streamProject.repositoryName must be configured in the root project") + + val useKtfmt = projectExtension.spotless.useKtfmt.get() + + val generateKotlinLicenseTask = + registerLicenseGenerationTask( + taskName = "generateKotlinLicenseHeader", + repositoryName = repositoryName, + templateName = "license-header.txt", + fileName = "license.kt", + ) + + val generateXmlLicenseTask = + registerLicenseGenerationTask( + taskName = "generateXmlLicenseHeader", + repositoryName = repositoryName, + templateName = "license-header.xml", + fileName = "license.xml", + ) + + extensions.configure { + val kotlinLicenseFile = generateKotlinLicenseTask.get().outputFile.get().asFile + val xmlLicenseFile = generateXmlLicenseTask.get().outputFile.get().asFile + + encoding = Charsets.UTF_8 + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + + projectExtension.spotless.excludePatterns.orNull + ?.takeUnless(Set::isEmpty) + ?.let { patterns -> targetExclude(*patterns.toTypedArray()) } + + if (useKtfmt) { + ktfmt().kotlinlangStyle() + } else { + // For now, we are fixing the ktlint version to the one currently used by Chat & + // Video to dodge this issue: https://github.com/diffplug/spotless/issues/1913 + ktlint(KTLINT_VERSION) + } + + trimTrailingWhitespace() + endWithNewline() + licenseHeaderFile(kotlinLicenseFile) + } + java { + importOrder() + removeUnusedImports() + googleJavaFormat() + trimTrailingWhitespace() + endWithNewline() + licenseHeaderFile(kotlinLicenseFile) + } + kotlinGradle { + target("*.gradle.kts") + ktlint(KTLINT_VERSION) + trimTrailingWhitespace() + endWithNewline() + } + format("xml") { + target("**/*.xml") + targetExclude("**/build/**/*.xml", "**/detekt-baseline.xml") + licenseHeaderFile(xmlLicenseFile, "(<[^!?])") + } + } + + // Make Spotless tasks depend on license generation tasks + tasks + .matching { it.name.startsWith("spotless") } + .configureEach { dependsOn(generateKotlinLicenseTask, generateXmlLicenseTask) } + } +} + +/** + * Registers a task to generate a license file from a bundled template. Safe to call from multiple + * subprojects, as it will reuse the task if it already exists. + */ +private fun Project.registerLicenseGenerationTask( + taskName: String, + repositoryName: String, + templateName: String, + fileName: String, +) = + rootProject.tasks.findOrRegister(taskName) { + this.repositoryName.set(repositoryName) + this.templateName.set(templateName) + + // Set the plugin's classpath as an input so Gradle tracks when the plugin JAR changes + pluginClasspath.from( + GenerateLicenseFileTask::class.java.protectionDomain.codeSource.location + ) + + // Set the output file location + val outputDir = rootProject.layout.buildDirectory.dir("stream-spotless-config") + outputFile.set(outputDir.map { it.file(fileName) }) + } + +private inline fun TaskContainer.findOrRegister( + name: String, + noinline configuration: T.() -> Unit, +) = findByName(name)?.let { named(name) } ?: register(name, configuration) diff --git a/plugin/src/main/kotlin/io/getstream/android/spotless/SpotlessOptions.kt b/plugin/src/main/kotlin/io/getstream/android/spotless/SpotlessOptions.kt new file mode 100644 index 0000000..592bc0e --- /dev/null +++ b/plugin/src/main/kotlin/io/getstream/android/spotless/SpotlessOptions.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE + * + * 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 io.getstream.android.spotless + +import javax.inject.Inject +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import org.gradle.kotlin.dsl.property +import org.gradle.kotlin.dsl.setProperty + +abstract class SpotlessOptions @Inject constructor(objects: ObjectFactory) { + + /** Whether to apply ktfmt formatting instead of ktlint to Kotlin files. Default: false */ + val useKtfmt: Property = objects.property().convention(false) + + /** Modules to exclude from Spotless formatting. Default: none */ + val ignoredModules: SetProperty = objects.setProperty().convention(emptySet()) + + /** File patterns to exclude from Spotless formatting beyond build files. Default: none */ + val excludePatterns: SetProperty = objects.setProperty().convention(emptySet()) +} diff --git a/plugin/src/main/resources/license-header.txt b/plugin/src/main/resources/license-header.txt new file mode 100644 index 0000000..591427d --- /dev/null +++ b/plugin/src/main/resources/license-header.txt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2014-$YEAR Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/$PROJECT/blob/main/LICENSE + * + * 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. + */ + diff --git a/plugin/src/main/resources/license-header.xml b/plugin/src/main/resources/license-header.xml new file mode 100644 index 0000000..f51e87f --- /dev/null +++ b/plugin/src/main/resources/license-header.xml @@ -0,0 +1,16 @@ + +