aboutsummaryrefslogtreecommitdiff
path: root/build-logic/src/main/kotlin
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2022-12-02 01:57:02 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2022-12-02 01:57:02 +0530
commitafd0eebdd3287d932c53879fa1ac88f430080ef5 (patch)
tree141b9147325b96f4ab3a49927e4a1d997f3c37be /build-logic/src/main/kotlin
parent54bb4676a7c16d959769e522ac018f3e3e822797 (diff)
refactor(build-logic): move all code to a single top-level project
Diffstat (limited to 'build-logic/src/main/kotlin')
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt55
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt81
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/DependencyUpdatesPlugin.kt33
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/GitHooksPlugin.kt18
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinAndroidPlugin.kt22
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinCommonPlugin.kt68
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinKaptPlugin.kt55
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinLibraryPlugin.kt27
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt32
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/LibraryPlugin.kt15
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/PublishedAndroidLibraryPlugin.kt47
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/RenameArtifactsPlugin.kt40
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/SentryPlugin.kt44
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectApksTask.kt44
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectBundleTask.kt33
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinExtension.kt20
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinPlugin.kt140
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/ProductFlavors.kt15
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/SlimTests.kt44
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt68
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt3
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt35
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt56
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PSLUpdateTask.kt116
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PublicSuffixListPlugin.kt20
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/signing/AppSigning.kt36
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/snapshot/SnapshotExtension.kt10
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/tasks/GitHooks.kt54
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/Constants.kt14
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningPlugin.kt88
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningTask.kt50
31 files changed, 1383 insertions, 0 deletions
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt
new file mode 100644
index 00000000..5e38b6eb
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt
@@ -0,0 +1,55 @@
+package app.passwordstore.gradle
+
+import app.passwordstore.gradle.flavors.configureSlimTests
+import com.android.build.gradle.TestedExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.tasks.testing.Test
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.withType
+
+@Suppress("UnstableApiUsage")
+object AndroidCommon {
+ fun configure(project: Project) {
+ project.extensions.configure<TestedExtension> {
+ setCompileSdkVersion(33)
+ defaultConfig {
+ minSdk = 23
+ targetSdk = 31
+ }
+
+ sourceSets {
+ named("main") { java.srcDirs("src/main/kotlin") }
+ named("test") { java.srcDirs("src/test/kotlin") }
+ named("androidTest") { java.srcDirs("src/androidTest/kotlin") }
+ }
+
+ packagingOptions {
+ resources.excludes.add("**/*.version")
+ resources.excludes.add("**/*.txt")
+ resources.excludes.add("**/*.kotlin_module")
+ resources.excludes.add("**/plugin.properties")
+ resources.excludes.add("**/META-INF/AL2.0")
+ resources.excludes.add("**/META-INF/LGPL2.1")
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ testOptions {
+ animationsDisabled = true
+ unitTests.isReturnDefaultValues = true
+ }
+ project.tasks.withType<Test> {
+ jvmArgs(
+ "--add-opens=java.base/java.lang=ALL-UNNAMED",
+ "--add-opens=java.base/java.util=ALL-UNNAMED",
+ )
+ }
+
+ project.configureSlimTests()
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt
new file mode 100644
index 00000000..bf759b3c
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt
@@ -0,0 +1,81 @@
+@file:Suppress("UnstableApiUsage")
+
+package app.passwordstore.gradle
+
+import app.passwordstore.gradle.flavors.FlavorDimensions
+import app.passwordstore.gradle.flavors.ProductFlavors
+import app.passwordstore.gradle.signing.configureBuildSigning
+import app.passwordstore.gradle.snapshot.SnapshotExtension
+import com.android.build.gradle.AppPlugin
+import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.the
+
+@Suppress("Unused")
+class ApplicationPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.apply(AppPlugin::class)
+ AndroidCommon.configure(project)
+ project.extensions.getByType<BaseAppModuleExtension>().run {
+ val minifySwitch = project.providers.environmentVariable("DISABLE_MINIFY")
+
+ adbOptions.installOptions("--user 0")
+
+ dependenciesInfo {
+ includeInBundle = false
+ includeInApk = false
+ }
+
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
+
+ buildTypes {
+ named("release") {
+ isMinifyEnabled = !minifySwitch.isPresent
+ setProguardFiles(
+ listOf(
+ "proguard-android-optimize.txt",
+ "proguard-rules.pro",
+ "proguard-rules-missing-classes.pro",
+ )
+ )
+ buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "${project.isSnapshot()}")
+ }
+ named("debug") {
+ applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
+ isMinifyEnabled = false
+ buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "true")
+ }
+ }
+
+ flavorDimensions.add(FlavorDimensions.FREE)
+ productFlavors {
+ register(ProductFlavors.FREE) {}
+ register(ProductFlavors.NON_FREE) {}
+ }
+
+ project.configureBuildSigning()
+ }
+
+ project.dependencies {
+ extensions.add("snapshot", SnapshotExtension::class.java)
+ the<SnapshotExtension>().snapshot = project.isSnapshot()
+ }
+ }
+
+ private fun Project.isSnapshot(): Boolean {
+ with(project.providers) {
+ val workflow = environmentVariable("GITHUB_WORKFLOW")
+ val snapshot = environmentVariable("SNAPSHOT")
+ return workflow.isPresent || snapshot.isPresent
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/DependencyUpdatesPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/DependencyUpdatesPlugin.kt
new file mode 100644
index 00000000..6dff57a1
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/DependencyUpdatesPlugin.kt
@@ -0,0 +1,33 @@
+package app.passwordstore.gradle
+
+import com.github.benmanes.gradle.versions.VersionsPlugin
+import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
+import nl.littlerobots.vcu.plugin.VersionCatalogUpdateExtension
+import nl.littlerobots.vcu.plugin.VersionCatalogUpdatePlugin
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.withType
+
+@Suppress("Unused")
+class DependencyUpdatesPlugin : Plugin<Project> {
+ override fun apply(project: Project) {
+ project.pluginManager.apply(VersionsPlugin::class)
+ project.pluginManager.apply(VersionCatalogUpdatePlugin::class)
+ project.tasks.withType<DependencyUpdatesTask>().configureEach {
+ rejectVersionIf {
+ when (candidate.group) {
+ "commons-codec",
+ "com.android.tools.build",
+ "org.eclipse.jgit" -> true
+ else -> false
+ }
+ }
+ checkForGradleUpdate = false
+ }
+ project.extensions.getByType<VersionCatalogUpdateExtension>().run {
+ keep.keepUnusedLibraries.set(true)
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/GitHooksPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/GitHooksPlugin.kt
new file mode 100644
index 00000000..f1b7c39d
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/GitHooksPlugin.kt
@@ -0,0 +1,18 @@
+package app.passwordstore.gradle
+
+import app.passwordstore.gradle.tasks.GitHooks
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.register
+
+@Suppress("Unused")
+class GitHooksPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.tasks.register<GitHooks>("installGitHooks") {
+ val projectDirectory = project.layout.projectDirectory
+ hookSource.set(projectDirectory.file("scripts/pre-push-hook.sh"))
+ hookOutput.set(projectDirectory.file(".git/hooks/pre-push"))
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinAndroidPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinAndroidPlugin.kt
new file mode 100644
index 00000000..49f207a1
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinAndroidPlugin.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper
+
+@Suppress("Unused")
+class KotlinAndroidPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.run {
+ apply(KotlinAndroidPluginWrapper::class)
+ apply(KotlinCommonPlugin::class)
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinCommonPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinCommonPlugin.kt
new file mode 100644
index 00000000..34d4675b
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinCommonPlugin.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle
+
+import io.gitlab.arturbosch.detekt.DetektPlugin
+import io.gitlab.arturbosch.detekt.extensions.DetektExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.tasks.compile.JavaCompile
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.logging.TestLogEvent
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.withType
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+@Suppress("Unused")
+class KotlinCommonPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.apply(DetektPlugin::class.java)
+ project.extensions.configure<DetektExtension> {
+ parallel = true
+ ignoredBuildTypes = listOf("release")
+ ignoredFlavors = listOf("free")
+ basePath = project.layout.projectDirectory.toString()
+ baseline =
+ project.rootProject.layout.projectDirectory
+ .dir("detekt-baselines")
+ .file("${project.name}.xml")
+ .asFile
+ }
+ project.tasks.run {
+ project.pluginManager.withPlugin("base") {
+ named(LifecycleBasePlugin.CHECK_TASK_NAME).configure { this.dependsOn(named("detekt")) }
+ }
+ withType<JavaCompile>().configureEach {
+ sourceCompatibility = JavaVersion.VERSION_11.toString()
+ targetCompatibility = JavaVersion.VERSION_11.toString()
+ }
+ withType<KotlinCompile>().configureEach {
+ kotlinOptions {
+ allWarningsAsErrors = true
+ jvmTarget = JavaVersion.VERSION_11.toString()
+ freeCompilerArgs = freeCompilerArgs + ADDITIONAL_COMPILER_ARGS
+ languageVersion = "1.5"
+ }
+ }
+ withType<Test>().configureEach {
+ maxParallelForks = Runtime.getRuntime().availableProcessors() * 2
+ testLogging { events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) }
+ }
+ }
+ }
+
+ private companion object {
+ private val ADDITIONAL_COMPILER_ARGS =
+ listOf(
+ "-opt-in=kotlin.RequiresOptIn",
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=1.7.22",
+ )
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinKaptPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinKaptPlugin.kt
new file mode 100644
index 00000000..a798ec8e
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinKaptPlugin.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.getByType
+import org.jetbrains.kotlin.gradle.internal.Kapt3GradleSubplugin
+import org.jetbrains.kotlin.gradle.plugin.KaptExtension
+import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper
+
+@Suppress("Unused")
+class KotlinKaptPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.run {
+ apply(KotlinAndroidPluginWrapper::class)
+ apply(Kapt3GradleSubplugin::class)
+ }
+ project.afterEvaluate {
+ project.extensions.getByType<KaptExtension>().run {
+ javacOptions {
+ if (hasDaggerCompilerDependency()) {
+ // https://dagger.dev/dev-guide/compiler-options#fastinit-mode
+ option("-Adagger.fastInit=enabled")
+ // Enable the better, experimental error messages
+ // https://github.com/google/dagger/commit/0d2505a727b54f47b8677f42dd4fc5c1924e37f5
+ option("-Adagger.experimentalDaggerErrorMessages=enabled")
+ // Share test components for when we start leveraging Hilt for tests
+ // https://github.com/google/dagger/releases/tag/dagger-2.34
+ option("-Adagger.hilt.shareTestComponents=true")
+ // KAPT nests errors causing real issues to be suppressed in CI logs
+ option("-Xmaxerrs", 500)
+ // Enables per-module validation for faster error detection
+ // https://github.com/google/dagger/commit/325b516ac6a53d3fc973d247b5231fafda9870a2
+ option("-Adagger.moduleBindingValidation=ERROR")
+ }
+ }
+ }
+ }
+ project.tasks
+ .matching { it.name.startsWith("kapt") && it.name.endsWith("UnitTestKotlin") }
+ .configureEach { enabled = false }
+ }
+
+ private fun Project.hasDaggerCompilerDependency(): Boolean {
+ return configurations.any {
+ it.dependencies.any { dependency -> dependency.name == "hilt-compiler" }
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinLibraryPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinLibraryPlugin.kt
new file mode 100644
index 00000000..ad6ffc0b
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/KotlinLibraryPlugin.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+@Suppress("Unused")
+class KotlinLibraryPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.apply(KotlinCommonPlugin::class)
+ project.tasks.withType<KotlinCompile>().configureEach {
+ kotlinOptions {
+ if (!name.contains("test", ignoreCase = true)) {
+ freeCompilerArgs = freeCompilerArgs + listOf("-Xexplicit-api=strict")
+ }
+ }
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt
new file mode 100644
index 00000000..74e8ba95
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/KtfmtPlugin.kt
@@ -0,0 +1,32 @@
+package app.passwordstore.gradle
+
+import app.passwordstore.gradle.ktfmt.KtfmtCheckTask
+import app.passwordstore.gradle.ktfmt.KtfmtFormatTask
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.register
+
+class KtfmtPlugin : Plugin<Project> {
+
+ override fun apply(target: Project) {
+ target.tasks.register<KtfmtFormatTask>("ktfmtFormat") {
+ source =
+ project.layout.projectDirectory.asFileTree
+ .filter { file ->
+ file.extension == "kt" ||
+ file.extension == "kts" && !file.canonicalPath.contains("build")
+ }
+ .asFileTree
+ }
+ target.tasks.register<KtfmtCheckTask>("ktfmtCheck") {
+ source =
+ project.layout.projectDirectory.asFileTree
+ .filter { file ->
+ file.extension == "kt" ||
+ file.extension == "kts" && !file.canonicalPath.contains("build")
+ }
+ .asFileTree
+ projectDirectory.set(target.layout.projectDirectory)
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/LibraryPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/LibraryPlugin.kt
new file mode 100644
index 00000000..22cc8ca3
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/LibraryPlugin.kt
@@ -0,0 +1,15 @@
+package app.passwordstore.gradle
+
+import com.android.build.gradle.LibraryPlugin
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+
+@Suppress("Unused")
+class LibraryPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.apply(LibraryPlugin::class)
+ AndroidCommon.configure(project)
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/PublishedAndroidLibraryPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/PublishedAndroidLibraryPlugin.kt
new file mode 100644
index 00000000..542bfeb6
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/PublishedAndroidLibraryPlugin.kt
@@ -0,0 +1,47 @@
+@file:Suppress("UnstableApiUsage")
+
+package app.passwordstore.gradle
+
+import com.vanniktech.maven.publish.MavenPublishBaseExtension
+import com.vanniktech.maven.publish.MavenPublishPlugin
+import com.vanniktech.maven.publish.SonatypeHost
+import me.tylerbwong.gradle.metalava.Documentation
+import me.tylerbwong.gradle.metalava.extension.MetalavaExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.provideDelegate
+import org.gradle.plugins.signing.SigningExtension
+import org.gradle.plugins.signing.SigningPlugin
+
+@Suppress("Unused")
+class PublishedAndroidLibraryPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.plugins.run {
+ apply(LibraryPlugin::class)
+ apply(MavenPublishPlugin::class)
+ apply(SigningPlugin::class)
+ apply("me.tylerbwong.gradle.metalava")
+ }
+ project.extensions.getByType<MavenPublishBaseExtension>().run {
+ publishToMavenCentral(SonatypeHost.DEFAULT, true)
+ signAllPublications()
+ }
+ project.afterEvaluate {
+ project.extensions.getByType<SigningExtension>().run {
+ val signingKey: String? by project
+ val signingPassword: String? by project
+ useInMemoryPgpKeys(signingKey, signingPassword)
+ }
+ }
+ project.extensions.getByType<MetalavaExtension>().run {
+ documentation.set(Documentation.PUBLIC)
+ inputKotlinNulls.set(true)
+ outputKotlinNulls.set(true)
+ reportLintsAsErrors.set(true)
+ reportWarningsAsErrors.set(true)
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/RenameArtifactsPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/RenameArtifactsPlugin.kt
new file mode 100644
index 00000000..6807b6ef
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/RenameArtifactsPlugin.kt
@@ -0,0 +1,40 @@
+package app.passwordstore.gradle
+
+import app.passwordstore.gradle.artifacts.CollectApksTask
+import app.passwordstore.gradle.artifacts.CollectBundleTask
+import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.api.variant.VariantOutputConfiguration
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.register
+
+@Suppress("Unused")
+class RenameArtifactsPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.withPlugin("com.android.application") {
+ project.extensions.getByType<ApplicationAndroidComponentsExtension>().run {
+ onVariants { variant ->
+ project.tasks.register<CollectApksTask>("collect${variant.name.capitalize()}Apks") {
+ variantName.set(variant.name)
+ apkFolder.set(variant.artifacts.get(SingleArtifact.APK))
+ builtArtifactsLoader.set(variant.artifacts.getBuiltArtifactsLoader())
+ outputDirectory.set(project.layout.projectDirectory.dir("outputs"))
+ }
+ project.tasks.register<CollectBundleTask>("collect${variant.name.capitalize()}Bundle") {
+ val mainOutput =
+ variant.outputs.single {
+ it.outputType == VariantOutputConfiguration.OutputType.SINGLE
+ }
+ variantName.set(variant.name)
+ versionName.set(mainOutput.versionName)
+ bundleFile.set(variant.artifacts.get(SingleArtifact.BUNDLE))
+ outputDirectory.set(project.layout.projectDirectory.dir("outputs"))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/SentryPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/SentryPlugin.kt
new file mode 100644
index 00000000..034c62f9
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/SentryPlugin.kt
@@ -0,0 +1,44 @@
+package app.passwordstore.gradle
+
+import app.passwordstore.gradle.flavors.FlavorDimensions
+import app.passwordstore.gradle.flavors.ProductFlavors
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import io.sentry.android.gradle.SentryPlugin
+import io.sentry.android.gradle.extensions.SentryPluginExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.getByType
+
+@Suppress("Unused")
+class SentryPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ project.pluginManager.withPlugin("com.android.application") {
+ project.extensions.getByType<ApplicationAndroidComponentsExtension>().run {
+ onVariants(selector().withFlavor(FlavorDimensions.FREE to ProductFlavors.NON_FREE)) {
+ variant ->
+ val sentryDsn = project.providers.environmentVariable(SENTRY_DSN_PROPERTY)
+ if (sentryDsn.isPresent) {
+ variant.manifestPlaceholders.put("sentryDsn", sentryDsn.get())
+ }
+ }
+ }
+ project.plugins.apply(SentryPlugin::class)
+ project.extensions.getByType<SentryPluginExtension>().run {
+ autoUploadProguardMapping.set(
+ project.providers.gradleProperty(SENTRY_UPLOAD_MAPPINGS_PROPERTY).isPresent
+ )
+ ignoredBuildTypes.set(setOf("debug"))
+ ignoredFlavors.set(setOf(ProductFlavors.FREE))
+ tracingInstrumentation { enabled.set(false) }
+ autoInstallation.enabled.set(false)
+ }
+ }
+ }
+
+ private companion object {
+ private const val SENTRY_DSN_PROPERTY = "SENTRY_DSN"
+ private const val SENTRY_UPLOAD_MAPPINGS_PROPERTY = "sentryUploadMappings"
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectApksTask.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectApksTask.kt
new file mode 100644
index 00000000..4f74136c
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectApksTask.kt
@@ -0,0 +1,44 @@
+package app.passwordstore.gradle.artifacts
+
+import com.android.build.api.variant.BuiltArtifactsLoader
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+/** Task to collect APKs in a given [outputDirectory]. */
+@CacheableTask
+abstract class CollectApksTask : DefaultTask() {
+ @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) abstract val apkFolder: DirectoryProperty
+
+ @get:Input abstract val variantName: Property<String>
+
+ @get:Internal abstract val builtArtifactsLoader: Property<BuiltArtifactsLoader>
+
+ @get:OutputDirectory abstract val outputDirectory: DirectoryProperty
+
+ @TaskAction
+ fun run() {
+ val outputDir = outputDirectory.asFile.get()
+ outputDir.mkdirs()
+ val builtArtifacts =
+ builtArtifactsLoader.get().load(apkFolder.get()) ?: throw RuntimeException("Cannot load APKs")
+ builtArtifacts.elements.forEach { artifact ->
+ Files.copy(
+ Paths.get(artifact.outputFile),
+ outputDir.resolve("APS-${variantName.get()}-${artifact.versionName}.apk").toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectBundleTask.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectBundleTask.kt
new file mode 100644
index 00000000..b627a674
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/artifacts/CollectBundleTask.kt
@@ -0,0 +1,33 @@
+package app.passwordstore.gradle.artifacts
+
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+
+abstract class CollectBundleTask : DefaultTask() {
+ @get:InputFile abstract val bundleFile: RegularFileProperty
+
+ @get:Input abstract val variantName: Property<String>
+
+ @get:Input abstract val versionName: Property<String>
+
+ @get:OutputDirectory abstract val outputDirectory: DirectoryProperty
+
+ @TaskAction
+ fun taskAction() {
+ val outputDir = outputDirectory.asFile.get()
+ outputDir.mkdirs()
+ Files.copy(
+ bundleFile.get().asFile.toPath(),
+ outputDir.resolve("APS-${variantName.get()}-${versionName.get()}.aab").toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinExtension.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinExtension.kt
new file mode 100644
index 00000000..3d45aebc
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinExtension.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.crowdin
+
+/** Extension for configuring [CrowdinDownloadPlugin] */
+interface CrowdinExtension {
+
+ /** Configure the project name on Crowdin */
+ var projectName: String
+
+ /**
+ * Don't delete downloaded and extracted translation archives from build directory.
+ *
+ * Useful for debugging.
+ */
+ var skipCleanup: Boolean
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinPlugin.kt
new file mode 100644
index 00000000..98882af5
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/crowdin/CrowdinPlugin.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.crowdin
+
+import de.undercouch.gradle.tasks.download.Download
+import java.io.File
+import java.util.concurrent.TimeUnit
+import javax.xml.parsers.DocumentBuilderFactory
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.gradle.api.GradleException
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.tasks.Copy
+import org.gradle.kotlin.dsl.create
+import org.gradle.kotlin.dsl.register
+import org.w3c.dom.Document
+
+private const val EXCEPTION_MESSAGE =
+ """Applying `crowdin-plugin` requires a projectName to be configured via the "crowdin" extension."""
+private const val CROWDIN_BUILD_API_URL =
+ "https://api.crowdin.com/api/project/%s/export?login=%s&account-key=%s"
+
+@Suppress("Unused")
+class CrowdinDownloadPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ with(project) {
+ val buildDirectory = layout.buildDirectory.asFile.get()
+ val extension = extensions.create<CrowdinExtension>("crowdin")
+ afterEvaluate {
+ val projectName = extension.projectName
+ if (projectName.isEmpty()) {
+ throw GradleException(EXCEPTION_MESSAGE)
+ }
+ val buildOnApi =
+ tasks.register("buildOnApi") {
+ doLast {
+ val login = providers.environmentVariable("CROWDIN_LOGIN")
+ val key = providers.environmentVariable("CROWDIN_PROJECT_KEY")
+ if (!login.isPresent) {
+ throw GradleException("CROWDIN_LOGIN environment variable must be set")
+ }
+ if (!key.isPresent) {
+ throw GradleException("CROWDIN_PROJECT_KEY environment variable must be set")
+ }
+ val client =
+ OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.MINUTES)
+ .writeTimeout(5, TimeUnit.MINUTES)
+ .readTimeout(5, TimeUnit.MINUTES)
+ .callTimeout(10, TimeUnit.MINUTES)
+ .build()
+ val url = CROWDIN_BUILD_API_URL.format(projectName, login.get(), key.get())
+ val request = Request.Builder().url(url).get().build()
+ client.newCall(request).execute().close()
+ }
+ }
+ val downloadCrowdin =
+ tasks.register<Download>("downloadCrowdin") {
+ dependsOn(buildOnApi)
+ src("https://crowdin.com/backend/download/project/$projectName.zip")
+ dest("$buildDirectory/translations.zip")
+ overwrite(true)
+ }
+ val extractCrowdin =
+ tasks.register<Copy>("extractCrowdin") {
+ dependsOn(downloadCrowdin)
+ doFirst { File(buildDir, "translations").deleteRecursively() }
+ from(zipTree("$buildDirectory/translations.zip"))
+ into("$buildDirectory/translations")
+ }
+ val extractStrings =
+ tasks.register<Copy>("extractStrings") {
+ dependsOn(extractCrowdin)
+ from("$buildDirectory/translations/")
+ into("${projectDir}/src/")
+ setFinalizedBy(setOf("removeIncompleteStrings"))
+ }
+ tasks.register("removeIncompleteStrings") {
+ doLast {
+ val sourceSets = arrayOf("main", "nonFree")
+ for (sourceSet in sourceSets) {
+ val fileTreeWalk = projectDir.resolve("src/$sourceSet").walkTopDown()
+ val valuesDirectories =
+ fileTreeWalk.filter { it.isDirectory }.filter { it.name.startsWith("values") }
+ val stringFiles = fileTreeWalk.filter { it.name == "strings.xml" }
+ val sourceFile =
+ stringFiles.firstOrNull { it.path.endsWith("values/strings.xml") }
+ ?: throw GradleException("No root strings.xml found in '$sourceSet' sourceSet")
+ val sourceDoc = parseDocument(sourceFile)
+ val baselineStringCount = countStrings(sourceDoc)
+ val threshold = 0.80 * baselineStringCount
+ stringFiles.forEach { file ->
+ if (file != sourceFile) {
+ val doc = parseDocument(file)
+ val stringCount = countStrings(doc)
+ if (stringCount < threshold) {
+ file.delete()
+ }
+ }
+ }
+ valuesDirectories.forEach { dir ->
+ if (dir.listFiles().isNullOrEmpty()) {
+ dir.delete()
+ }
+ }
+ }
+ }
+ }
+ tasks.register("crowdin") {
+ dependsOn(extractStrings)
+ if (!extension.skipCleanup) {
+ doLast {
+ File("$buildDirectory/translations").deleteRecursively()
+ File("$buildDirectory/nonFree-translations").deleteRecursively()
+ File("$buildDirectory/translations.zip").delete()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun parseDocument(file: File): Document {
+ val dbFactory = DocumentBuilderFactory.newInstance()
+ val documentBuilder = dbFactory.newDocumentBuilder()
+ return documentBuilder.parse(file)
+ }
+
+ private fun countStrings(document: Document): Int {
+ // Normalization is beneficial for us
+ // https://stackoverflow.com/questions/13786607/normalization-in-dom-parsing-with-java-how-does-it-work
+ document.documentElement.normalize()
+ return document.getElementsByTagName("string").length
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/ProductFlavors.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/ProductFlavors.kt
new file mode 100644
index 00000000..e4b5c739
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/ProductFlavors.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.flavors
+
+object FlavorDimensions {
+ const val FREE = "free"
+}
+
+object ProductFlavors {
+ const val FREE = "free"
+ const val NON_FREE = "nonFree"
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/SlimTests.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/SlimTests.kt
new file mode 100644
index 00000000..8755a872
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/flavors/SlimTests.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.flavors
+
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.api.variant.LibraryAndroidComponentsExtension
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.findByType
+import org.gradle.language.nativeplatform.internal.BuildType
+
+/**
+ * When the "slimTests" project property is provided, disable the unit test tasks on `release` build
+ * type and `nonFree` product flavor to avoid running the same tests repeatedly in different build
+ * variants.
+ *
+ * Examples: `./gradlew test -PslimTests` will run unit tests for `nonFreeDebug` and `debug` build
+ * variants in Android App and Library projects, and all tests in JVM projects.
+ */
+internal fun Project.configureSlimTests() {
+ if (providers.gradleProperty(SLIM_TESTS_PROPERTY).isPresent) {
+ // disable unit test tasks on the release build type for Android Library projects
+ extensions.findByType<LibraryAndroidComponentsExtension>()?.run {
+ beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) {
+ it.enableUnitTest = false
+ it.enableAndroidTest = false
+ }
+ }
+
+ // disable unit test tasks on the release build type and free flavor for Android Application
+ // projects.
+ extensions.findByType<ApplicationAndroidComponentsExtension>()?.run {
+ beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { it.enableUnitTest = false }
+ beforeVariants(selector().withFlavor(FlavorDimensions.FREE to ProductFlavors.NON_FREE)) {
+ it.enableUnitTest = false
+ it.enableAndroidTest = false
+ }
+ }
+ }
+}
+
+private const val SLIM_TESTS_PROPERTY = "slimTests"
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt
new file mode 100644
index 00000000..a50c8494
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtCheckTask.kt
@@ -0,0 +1,68 @@
+package app.passwordstore.gradle.ktfmt
+
+import com.facebook.ktfmt.format.Formatter
+import com.facebook.ktfmt.format.FormattingOptions
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.runBlocking
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileCollection
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SourceTask
+import org.gradle.api.tasks.TaskAction
+
+@OptIn(ExperimentalCoroutinesApi::class)
+abstract class KtfmtCheckTask : SourceTask() {
+
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ @get:InputFiles
+ @get:IgnoreEmptyDirectories
+ protected val inputFiles: FileCollection
+ get() = super.getSource()
+
+ @get:Internal abstract val projectDirectory: DirectoryProperty
+
+ @TaskAction
+ fun execute() {
+ runBlocking(Dispatchers.IO.limitedParallelism(PARALLEL_TASK_LIMIT)) {
+ coroutineScope {
+ val results = inputFiles.map { async { checkFile(it) } }.awaitAll()
+ if (results.any { (notFormatted, _) -> notFormatted }) {
+ results
+ .map { (_, diffs) -> diffs }
+ .forEach { diffs -> KtfmtDiffer.printDiff(diffs, logger) }
+ error("[ktfmt] Found unformatted files")
+ }
+ }
+ }
+ }
+
+ private fun checkFile(input: File): Pair<Boolean, List<KtfmtDiffEntry>> {
+ val originCode = input.readText()
+ val formattedCode =
+ Formatter.format(
+ FormattingOptions(
+ style = FormattingOptions.Style.GOOGLE,
+ maxWidth = 100,
+ continuationIndent = 2,
+ ),
+ originCode
+ )
+ val pathNormalizer = { file: File -> file.toRelativeString(projectDirectory.asFile.get()) }
+ return (originCode != formattedCode) to
+ KtfmtDiffer.computeDiff(input, formattedCode, pathNormalizer)
+ }
+
+ companion object {
+
+ private const val PARALLEL_TASK_LIMIT = 4
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt
new file mode 100644
index 00000000..44d1a967
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffEntry.kt
@@ -0,0 +1,3 @@
+package app.passwordstore.gradle.ktfmt
+
+data class KtfmtDiffEntry(val input: String, val lineNumber: Int, val message: String)
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt
new file mode 100644
index 00000000..936596cd
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtDiffer.kt
@@ -0,0 +1,35 @@
+package app.passwordstore.gradle.ktfmt
+
+import com.github.difflib.DiffUtils
+import com.github.difflib.patch.ChangeDelta
+import com.github.difflib.patch.DeleteDelta
+import com.github.difflib.patch.InsertDelta
+import java.io.File
+import org.gradle.api.logging.Logger
+
+object KtfmtDiffer {
+ fun computeDiff(
+ inputFile: File,
+ formattedCode: String,
+ pathNormalizer: (File) -> String
+ ): List<KtfmtDiffEntry> {
+ val originCode = inputFile.readText()
+ return DiffUtils.diff(originCode, formattedCode, null).deltas.map {
+ val line = it.source.position + 1
+ val message: String =
+ when (it) {
+ is ChangeDelta -> "Line changed: ${it.source.lines.first()}"
+ is DeleteDelta -> "Line deleted"
+ is InsertDelta -> "Line added"
+ else -> ""
+ }
+ KtfmtDiffEntry(pathNormalizer(inputFile), line, message)
+ }
+ }
+
+ fun printDiff(entries: List<KtfmtDiffEntry>, logger: Logger) {
+ entries.forEach { entry ->
+ logger.error("${entry.input}:${entry.lineNumber} - ${entry.message}")
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt
new file mode 100644
index 00000000..82ce4ca3
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ktfmt/KtfmtFormatTask.kt
@@ -0,0 +1,56 @@
+package app.passwordstore.gradle.ktfmt
+
+import com.facebook.ktfmt.format.Formatter
+import com.facebook.ktfmt.format.FormattingOptions
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.runBlocking
+import org.gradle.api.file.FileCollection
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SourceTask
+import org.gradle.api.tasks.TaskAction
+
+@OptIn(ExperimentalCoroutinesApi::class)
+abstract class KtfmtFormatTask : SourceTask() {
+
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ @get:InputFiles
+ @get:IgnoreEmptyDirectories
+ protected val inputFiles: FileCollection
+ get() = super.getSource()
+
+ @TaskAction
+ fun execute() {
+ runBlocking(Dispatchers.IO.limitedParallelism(PARALLEL_TASK_LIMIT)) {
+ coroutineScope { inputFiles.map { async { formatFile(it) } }.awaitAll() }
+ }
+ }
+
+ private fun formatFile(input: File) {
+ val originCode = input.readText()
+ val formattedCode =
+ Formatter.format(
+ FormattingOptions(
+ style = FormattingOptions.Style.GOOGLE,
+ maxWidth = 100,
+ continuationIndent = 2,
+ ),
+ originCode
+ )
+ if (originCode != formattedCode) {
+ input.writeText(formattedCode)
+ }
+ }
+
+ companion object {
+
+ private const val PARALLEL_TASK_LIMIT = 4
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PSLUpdateTask.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PSLUpdateTask.kt
new file mode 100644
index 00000000..a5de3d49
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PSLUpdateTask.kt
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package app.passwordstore.gradle.psl
+
+import java.util.TreeSet
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okio.ByteString
+import okio.ByteString.Companion.encodeUtf8
+import okio.buffer
+import okio.sink
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFile
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Based on PublicSuffixListGenerator from OkHttp:
+ * https://github.com/square/okhttp/blob/3ad1912f783e108b3d0ad2c4a5b1b89b827e4db9/okhttp/src/jvmTest/java/okhttp3/internal/publicsuffix/PublicSuffixListGenerator.java
+ */
+abstract class PSLUpdateTask : DefaultTask() {
+ @get:OutputFile abstract val outputFile: RegularFileProperty
+
+ @TaskAction
+ fun updatePSL() {
+ val pslData = fetchPublicSuffixList()
+ writeListToDisk(outputFile.get(), pslData)
+ }
+
+ private fun fetchPublicSuffixList(): PublicSuffixListData {
+ val client = OkHttpClient.Builder().build()
+
+ val request =
+ Request.Builder().url("https://publicsuffix.org/list/public_suffix_list.dat").build()
+
+ client.newCall(request).execute().use { response ->
+ val source = requireNotNull(response.body).source()
+
+ val data = PublicSuffixListData()
+
+ while (!source.exhausted()) {
+ val line = source.readUtf8LineStrict()
+
+ if (line.trim { it <= ' ' }.isEmpty() || line.startsWith("//")) {
+ continue
+ }
+
+ if (line.contains(WILDCARD_CHAR)) {
+ assertWildcardRule(line)
+ }
+
+ var rule = line.encodeUtf8()
+
+ if (rule.startsWith(EXCEPTION_RULE_MARKER)) {
+ rule = rule.substring(1)
+ // We use '\n' for end of value.
+ data.totalExceptionRuleBytes += rule.size + 1
+ data.sortedExceptionRules.add(rule)
+ } else {
+ data.totalRuleBytes += rule.size + 1 // We use '\n' for end of value.
+ data.sortedRules.add(rule)
+ }
+ }
+ return data
+ }
+ }
+
+ @Suppress("TooGenericExceptionThrown", "ThrowsCount")
+ private fun assertWildcardRule(rule: String) {
+ if (rule.indexOf(WILDCARD_CHAR) != 0) {
+ throw RuntimeException("Wildcard is not not in leftmost position")
+ }
+
+ if (rule.indexOf(WILDCARD_CHAR, 1) != -1) {
+ throw RuntimeException("Rule contains multiple wildcards")
+ }
+
+ if (rule.length == 1) {
+ throw RuntimeException("Rule wildcards the first level")
+ }
+ }
+
+ private fun writeListToDisk(destination: RegularFile, data: PublicSuffixListData) {
+ val fileSink = destination.asFile.sink()
+
+ fileSink.buffer().use { sink ->
+ sink.writeInt(data.totalRuleBytes)
+
+ for (domain in data.sortedRules) {
+ sink.write(domain).writeByte('\n'.toInt())
+ }
+
+ sink.writeInt(data.totalExceptionRuleBytes)
+
+ for (domain in data.sortedExceptionRules) {
+ sink.write(domain).writeByte('\n'.toInt())
+ }
+ }
+ }
+
+ data class PublicSuffixListData(
+ var totalRuleBytes: Int = 0,
+ var totalExceptionRuleBytes: Int = 0,
+ val sortedRules: TreeSet<ByteString> = TreeSet(),
+ val sortedExceptionRules: TreeSet<ByteString> = TreeSet()
+ )
+
+ private companion object {
+ private const val WILDCARD_CHAR = "*"
+ private val EXCEPTION_RULE_MARKER = "!".encodeUtf8()
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PublicSuffixListPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PublicSuffixListPlugin.kt
new file mode 100644
index 00000000..2efeb4dd
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/psl/PublicSuffixListPlugin.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.psl
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.register
+
+/** Gradle plugin to update the public suffix list used by the `autofill-parser` library. */
+@Suppress("Unused")
+class PublicSuffixListPlugin : Plugin<Project> {
+ override fun apply(project: Project) {
+ project.tasks.register<PSLUpdateTask>("updatePSL") {
+ outputFile.set(project.layout.projectDirectory.file("src/main/assets/publicsuffixes"))
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/signing/AppSigning.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/signing/AppSigning.kt
new file mode 100644
index 00000000..4b5c7130
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/signing/AppSigning.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.signing
+
+import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
+import java.util.Properties
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+
+private const val KEYSTORE_CONFIG_PATH = "keystore.properties"
+
+/** Configure signing for all build types. */
+@Suppress("UnstableApiUsage")
+internal fun Project.configureBuildSigning() {
+ val keystoreConfigFile = rootProject.layout.projectDirectory.file(KEYSTORE_CONFIG_PATH)
+ if (keystoreConfigFile.asFile.exists()) {
+ extensions.configure<BaseAppModuleExtension> {
+ val contents = providers.fileContents(keystoreConfigFile).asText
+ val keystoreProperties = Properties()
+ keystoreProperties.load(contents.get().byteInputStream())
+ signingConfigs {
+ register("release") {
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ }
+ }
+ val signingConfig = signingConfigs.getByName("release")
+ buildTypes.all { setSigningConfig(signingConfig) }
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/snapshot/SnapshotExtension.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/snapshot/SnapshotExtension.kt
new file mode 100644
index 00000000..191620d1
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/snapshot/SnapshotExtension.kt
@@ -0,0 +1,10 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.snapshot
+
+abstract class SnapshotExtension {
+ abstract var snapshot: Boolean
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/tasks/GitHooks.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/tasks/GitHooks.kt
new file mode 100644
index 00000000..3ffb4c71
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/tasks/GitHooks.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.tasks
+
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.nio.file.attribute.PosixFilePermission.GROUP_EXECUTE
+import java.nio.file.attribute.PosixFilePermission.GROUP_READ
+import java.nio.file.attribute.PosixFilePermission.OTHERS_EXECUTE
+import java.nio.file.attribute.PosixFilePermission.OTHERS_READ
+import java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE
+import java.nio.file.attribute.PosixFilePermission.OWNER_READ
+import java.nio.file.attribute.PosixFilePermission.OWNER_WRITE
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+@CacheableTask
+abstract class GitHooks : DefaultTask() {
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val hookSource: RegularFileProperty
+
+ @get:OutputFile abstract val hookOutput: RegularFileProperty
+
+ @TaskAction
+ fun install() {
+ Files.copy(
+ hookSource.asFile.get().toPath(),
+ hookOutput.asFile.get().toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ Files.setPosixFilePermissions(
+ hookOutput.asFile.get().toPath(),
+ setOf(
+ OWNER_READ,
+ OWNER_WRITE,
+ OWNER_EXECUTE,
+ GROUP_READ,
+ GROUP_EXECUTE,
+ OTHERS_READ,
+ OTHERS_EXECUTE,
+ )
+ )
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/Constants.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/Constants.kt
new file mode 100644
index 00000000..52746c37
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/Constants.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.versioning
+
+const val VERSIONING_PROP_FILE = "version.properties"
+const val VERSIONING_PROP_VERSION_NAME = "versioning-plugin.versionName"
+const val VERSIONING_PROP_VERSION_CODE = "versioning-plugin.versionCode"
+const val VERSIONING_PROP_COMMENT =
+ """#
+# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
+#"""
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningPlugin.kt
new file mode 100644
index 00000000..5fda1a25
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningPlugin.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.versioning
+
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.api.variant.VariantOutputConfiguration
+import com.android.build.gradle.internal.plugins.AppPlugin
+import com.vdurmont.semver4j.Semver
+import java.util.Properties
+import java.util.concurrent.atomic.AtomicBoolean
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.register
+import org.gradle.kotlin.dsl.withType
+
+/**
+ * A Gradle [Plugin] that takes a [Project] with the [AppPlugin] applied and dynamically sets the
+ * versionCode and versionName properties based on values read from a [VERSIONING_PROP_FILE] file in
+ * the [Project.getBuildDir] directory. It also adds Gradle tasks to bump the major, minor, and
+ * patch versions along with one to prepare the next snapshot.
+ */
+@Suppress("Unused")
+class VersioningPlugin : Plugin<Project> {
+
+ override fun apply(project: Project) {
+ with(project) {
+ val androidAppPluginApplied = AtomicBoolean(false)
+ val propFile = layout.projectDirectory.file(VERSIONING_PROP_FILE)
+ require(propFile.asFile.exists()) {
+ "A 'version.properties' file must exist in the project subdirectory to use this plugin"
+ }
+ val contents = providers.fileContents(propFile).asText
+ val versionProps = Properties().also { it.load(contents.get().byteInputStream()) }
+ val versionName =
+ requireNotNull(versionProps.getProperty(VERSIONING_PROP_VERSION_NAME)) {
+ "version.properties must contain a '$VERSIONING_PROP_VERSION_NAME' property"
+ }
+ val versionCode =
+ requireNotNull(versionProps.getProperty(VERSIONING_PROP_VERSION_CODE).toInt()) {
+ "version.properties must contain a '$VERSIONING_PROP_VERSION_CODE' property"
+ }
+ project.plugins.withType<AppPlugin> {
+ androidAppPluginApplied.set(true)
+ extensions.getByType<ApplicationAndroidComponentsExtension>().onVariants { variant ->
+ val mainOutput =
+ variant.outputs.single { it.outputType == VariantOutputConfiguration.OutputType.SINGLE }
+ mainOutput.versionName.set(versionName)
+ mainOutput.versionCode.set(versionCode)
+ }
+ }
+ val version = Semver(versionName)
+ tasks.register<VersioningTask>("clearPreRelease") {
+ description = "Remove the pre-release suffix from the version"
+ semverString.set(version.withClearedSuffix().toString())
+ propertyFile.set(propFile)
+ }
+ tasks.register<VersioningTask>("bumpMajor") {
+ description = "Increment the major version"
+ semverString.set(version.withIncMajor().withClearedSuffix().toString())
+ propertyFile.set(propFile)
+ }
+ tasks.register<VersioningTask>("bumpMinor") {
+ description = "Increment the minor version"
+ semverString.set(version.withIncMinor().withClearedSuffix().toString())
+ propertyFile.set(propFile)
+ }
+ tasks.register<VersioningTask>("bumpPatch") {
+ description = "Increment the patch version"
+ semverString.set(version.withIncPatch().withClearedSuffix().toString())
+ propertyFile.set(propFile)
+ }
+ tasks.register<VersioningTask>("bumpSnapshot") {
+ description = "Increment the minor version and add the `SNAPSHOT` suffix"
+ semverString.set(version.withIncMinor().withSuffix("SNAPSHOT").toString())
+ propertyFile.set(propFile)
+ }
+ afterEvaluate {
+ check(androidAppPluginApplied.get()) {
+ "Plugin 'com.android.application' must be applied to ${project.displayName} to use the Versioning Plugin"
+ }
+ }
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningTask.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningTask.kt
new file mode 100644
index 00000000..7eb19a89
--- /dev/null
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/versioning/VersioningTask.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.gradle.versioning
+
+import com.vdurmont.semver4j.Semver
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+
+@CacheableTask
+abstract class VersioningTask : DefaultTask() {
+ @get:Input abstract val semverString: Property<String>
+
+ @get:OutputFile abstract val propertyFile: RegularFileProperty
+
+ /** Generate the Android 'versionCode' property */
+ private fun Semver.androidCode(): Int {
+ return major * 1_00_00 + minor * 1_00 + patch
+ }
+
+ private fun Semver.toPropFileText(): String {
+ val newVersionCode = androidCode()
+ val newVersionName = toString()
+ return buildString {
+ appendLine(VERSIONING_PROP_COMMENT)
+ append(VERSIONING_PROP_VERSION_CODE)
+ append('=')
+ appendLine(newVersionCode)
+ append(VERSIONING_PROP_VERSION_NAME)
+ append('=')
+ appendLine(newVersionName)
+ }
+ }
+
+ override fun getGroup(): String {
+ return "versioning"
+ }
+
+ @TaskAction
+ fun execute() {
+ propertyFile.get().asFile.writeText(Semver(semverString.get()).toPropFileText())
+ }
+}