diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 000000000..63944c490 --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,24 @@ +# buildSrc + +This directory contains shared build logic for the Probe multiplatform project. + +## What's Included + +### Core Classes +- **`AppConfig`** – Data class defining app configuration for different variants (OONI, DW, etc.) +- **`Organization`** – Sealed class representing build flavors/organizations, each with its own `AppConfig` + +### Utilities +- **`BuildUtils`** – Utility functions for build scripts: + - `isFdroidTaskRequested()` – Check if F-Droid build task is requested + - `isDebugTaskRequested()` – Check if Debug build task is requested + - Other helpers for platform suffixes, file copying, and .gitignore management +- **`TaskRegistration`** – Functions for registering custom Gradle tasks (Android, Desktop, Resources, etc.) + +### Plugins +- **`ConfigurationPlugin`** – Main plugin for common configuration and automatic task registration + +## How It Works +- The plugins and utilities in this directory are used to standardize build logic, configuration, and task setup across all multiplatform modules. +- Organization-specific configuration is handled via the `Organization` sealed class and its associated `AppConfig`. +- Custom tasks are registered using functions in `TaskRegistration`. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..4265d18c0 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + `kotlin-dsl` +} + +repositories { + google() + gradlePluginPortal() + mavenCentral() +} + +gradlePlugin { + plugins { + create("commonConfiguration") { + id = "ooni.common" + implementationClass = "ConfigurationPlugin" + } + } +} diff --git a/buildSrc/src/main/kotlin/AppConfig.kt b/buildSrc/src/main/kotlin/AppConfig.kt new file mode 100644 index 000000000..8ca55686f --- /dev/null +++ b/buildSrc/src/main/kotlin/AppConfig.kt @@ -0,0 +1,10 @@ +/** + * Configuration data class for different app variants. + */ +data class AppConfig( + val appId: String, + val appName: String, + val folder: String, + val supportsOoniRun: Boolean = false, + val supportedLanguages: List, +) diff --git a/buildSrc/src/main/kotlin/BuildUtils.kt b/buildSrc/src/main/kotlin/BuildUtils.kt new file mode 100644 index 000000000..787563207 --- /dev/null +++ b/buildSrc/src/main/kotlin/BuildUtils.kt @@ -0,0 +1,91 @@ +import org.gradle.api.Project +import org.gradle.internal.os.OperatingSystem +import java.io.File + +/** + * Check if F-Droid build task is requested. + */ +fun Project.isFdroidTaskRequested(): Boolean = + gradle.startParameter.taskRequests + .flatMap { it.args } + .any { it.contains("Fdroid") } + +/** + * Check if Debug build task is requested. + */ +fun Project.isDebugTaskRequested(): Boolean = + gradle.startParameter.taskRequests + .flatMap { it.args } + .any { it.contains("Debug") } + +/** + * Get the appropriate JavaFX suffix for the current OS and architecture. + */ +fun getJavaFxSuffix(): String { + val os = OperatingSystem.current() + val arch = System.getProperty("os.arch") + return when { + os.isMacOsX -> if (arch == "aarch64") "mac-aarch64" else "mac" + os.isWindows -> "win" + os.isLinux -> if (arch == "aarch64") "linux-aarch64" else "linux" + else -> throw IllegalStateException("Unknown OS: $os") + } +} + +/** + * Add a line to .gitignore if it doesn't already exist. + */ +fun ignoreCopiedFileIfNotIgnored( + gitignorePath: String, + lineToAdd: String, +) { + val file = File(gitignorePath) + if (!file.exists()) { + file.createNewFile() + } + + val fileContents = file.readText() + + if (!fileContents.contains(lineToAdd)) { + file.appendText("\n$lineToAdd") + } +} + +/** + * Copy files from one directory to another recursively. + * + * @param from The source directory. + * @param to The destination directory. + */ +fun copyRecursive( + from: File, + to: File, +) { + if (!from.exists()) { + println("Source directory does not exist: $from") + return + } + from.listFiles()?.forEach { file -> + if (file.name != ".DS_Store") { + if (file.isDirectory) { + val newDir = File(to, file.name) + newDir.mkdir() + copyRecursive(file, newDir) + } else { + val destinationFile = File(to, file.name) + if (destinationFile.exists()) { + destinationFile.delete() + } + if (!destinationFile.parentFile.exists()) { + destinationFile.parentFile.mkdirs() + } + file.copyTo(destinationFile).also { + ignoreCopiedFileIfNotIgnored( + to.absolutePath + "/.gitignore", + it.name, + ) + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/ConfigurationPlugin.kt b/buildSrc/src/main/kotlin/ConfigurationPlugin.kt new file mode 100644 index 000000000..3de13a772 --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigurationPlugin.kt @@ -0,0 +1,22 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Common configuration plugin for projects. + */ +class ConfigurationPlugin : Plugin { + override fun apply(project: Project) { + with(project) { + // Common configuration that applies to all projects + group = "org.ooni.probe" + version = project.findProperty("version") ?: "1.0.0" + + val organization = project.findProperty("organization") as? String + val config = Organization.fromKey(organization).config + + registerTasks(config) + + configureTasks() + } + } +} diff --git a/buildSrc/src/main/kotlin/Organization.kt b/buildSrc/src/main/kotlin/Organization.kt new file mode 100644 index 000000000..592e6df94 --- /dev/null +++ b/buildSrc/src/main/kotlin/Organization.kt @@ -0,0 +1,62 @@ +/** + * Represents the distinct build flavors or organizations. + * Using an enum ensures type safety when accessing configurations. + * + * @param key The string value used as a key in the configuration map. + * @param config The AppConfig associated with the organization. + */ +sealed class Organization(val key: String, val config: AppConfig) { + object Ooni : Organization( + "ooni", + AppConfig( + appId = "org.openobservatory.ooniprobe", + appName = "OONI Probe", + folder = "ooniMain", + supportsOoniRun = true, + supportedLanguages = listOf( + "ar", + "ca", + "de", + "el", + "es", + "fa", + "fr", + "hi", + "id", + "is", + "it", + "my", + "nl", + "pt-rBR", + "ro", + "ru", + "sk", + "sq", + "sw", + "th", + "tr", + "vi", + "zh-rCN", + "zh-rTW" + ), + ) + ) + + object Dw : Organization( + "dw", + AppConfig( + appId = "com.dw.ooniprobe", + appName = "News Media Scan", + folder = "dwMain", + supportsOoniRun = false, + supportedLanguages = listOf( + "de", "es", "fr", "pt-rBR", "ru", "tr" + ), + ) + ) + + companion object { + private val keyMap = listOf(Ooni, Dw).associateBy { it.key } + fun fromKey(key: String?): Organization = keyMap[key] ?: Ooni + } +} diff --git a/buildSrc/src/main/kotlin/TaskRegistration.kt b/buildSrc/src/main/kotlin/TaskRegistration.kt new file mode 100644 index 000000000..eace7b792 --- /dev/null +++ b/buildSrc/src/main/kotlin/TaskRegistration.kt @@ -0,0 +1,149 @@ +import org.gradle.api.Project +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.JavaExec +import org.gradle.kotlin.dsl.register +import org.gradle.kotlin.dsl.withType +import java.io.File +import kotlin.let + +/** + * Registers all custom tasks for the project + */ +fun Project.registerTasks(config: AppConfig) { + registerAndroidTasks(config) + registerDesktopTasks() + registerResourceTasks(config) + configureTaskDependencies() +} + +private fun Project.registerAndroidTasks(config: AppConfig) { + tasks.register("runDebug", Exec::class) { + group = "ooni" + description = "Clean, install and run the debug variant" + dependsOn("clean", "installFullDebug") + commandLine( + "adb", + "shell", + "am", + "start", + "-n", + "${config.appId}.dev/org.ooni.probe.MainActivity", + ) + } +} + +private fun Project.registerDesktopTasks() { + tasks.register("makeLibrary", Exec::class) { + group = "ooni" + description = "Build native libraries (NetworkTypeFinder and UpdateBridge)" + workingDir = file("src/desktopMain") + commandLine = listOf("make", "all") + doFirst { + println("🔨 Building native libraries...") + } + doLast { + println("✅ Native libraries built successfully") + } + } + + tasks.register("cleanLibrary", Exec::class) { + group = "ooni" + description = "Clean native library build artifacts" + workingDir = file("src/desktopMain") + commandLine = listOf("make", "clean") + } +} + +private fun Project.registerResourceTasks(config: AppConfig) { + tasks.register("copyBrandingToCommonResources") { + group = "ooni" + description = "Copy branding resources to common resources directory" + doLast { + val projectDir = project.projectDir.absolutePath + copyRecursive( + from = File(projectDir, "src/${config.folder}/res"), + to = File(projectDir, "src/commonMain/res"), + ) + copyRecursive( + from = File(projectDir, "src/${config.folder}/composeResources"), + to = File(projectDir, "src/commonMain/composeResources"), + ) + } + } + + tasks.register("cleanCopiedCommonResourcesToFlavor") { + group = "ooni" + description = "Clean copied common resources from flavor directories" + doLast { + val projectDir = project.projectDir.absolutePath + + deleteFilesFromGitIgnore("$projectDir/src/commonMain/res") + deleteFilesFromGitIgnore("$projectDir/src/commonMain/resources") + deleteFilesFromGitIgnore("$projectDir/src/commonMain/composeResources") + } + } +} + +private fun Project.configureTaskDependencies() { + // Configure existing tasks with dependencies after evaluation + afterEvaluate { + tasks.findByName("compileKotlinDesktop")?.dependsOn?.add("makeLibrary") + + tasks.findByName("preBuild")?.dependsOn("copyBrandingToCommonResources") + + tasks.findByName("clean")?.dependsOn("copyBrandingToCommonResources", "cleanCopiedCommonResourcesToFlavor") + } +} + +/** + * Configures existing tasks with OONI-specific settings + */ +fun Project.configureTasks() { + configureJavaExecTasks() +} + +private fun Project.configureJavaExecTasks() { + tasks.withType { + systemProperty( + "java.library.path", + "$projectDir/src/desktopMain/resources/macos" + + File.pathSeparator + + "$projectDir/src/desktopMain/resources/windows" + + File.pathSeparator + + "$projectDir/src/desktopMain/resources/linux" + + File.pathSeparator + + System.getProperty("java.library.path"), + ) + + // Get desktop updates public key from project properties + project.findProperty("desktopUpdatesPublicKey")?.let { key -> + systemProperty("desktopUpdatesPublicKey", key) + } + } +} + + +/** + * Configure tasks to delete files from .gitignore. + * This version matches the original implementation from the build script. + */ +fun deleteFilesFromGitIgnore(folderPath: String) { + val destinationFile = File(folderPath) + destinationFile.listFiles()?.forEach { folder -> + folder.listFiles()?.forEach { file -> + if (file.name == ".gitignore") { + file + .readText() + .lines() + .forEach { line -> + if (line.isNotEmpty()) { + println("Removing $line") + File(folder, line).deleteRecursively() + } + }.also { + file.delete() + } + } + } + } +} diff --git a/buildSrc/src/main/resources/META-INF/gradle-plugins/ooni.common.gradle.properties b/buildSrc/src/main/resources/META-INF/gradle-plugins/ooni.common.gradle.properties new file mode 100644 index 000000000..4bfad7cde --- /dev/null +++ b/buildSrc/src/main/resources/META-INF/gradle-plugins/ooni.common.gradle.properties @@ -0,0 +1 @@ +implementation-class=ConfigurationPlugin diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e7c9fa208..6e5471909 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,8 +1,5 @@ import com.android.build.api.variant.FilterConfiguration.FilterType.ABI -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import java.time.LocalDate -import org.gradle.internal.os.OperatingSystem -import org.gradle.kotlin.dsl.java import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi @@ -20,60 +17,13 @@ plugins { alias(libs.plugins.sqldelight) alias(libs.plugins.javafx) + + id("ooni.common") } val organization: String? by project -val appConfig = mapOf( - "dw" to AppConfig( - appId = "com.dw.ooniprobe", - appName = "News Media Scan", - folder = "dwMain", - supportsOoniRun = false, - supportedLanguages = listOf( - "de", - "es", - "fr", - "pt-rBR", - "ru", - "tr", - ), - ), - "ooni" to AppConfig( - appId = "org.openobservatory.ooniprobe", - appName = "OONI Probe", - folder = "ooniMain", - supportsOoniRun = true, - supportedLanguages = listOf( - "ar", - "ca", - "de", - "el", - "es", - "fa", - "fr", - "hi", - "id", - "is", - "it", - "my", - "nl", - "pt-rBR", - "ro", - "ru", - "sk", - "sq", - "sw", - "th", - "tr", - "vi", - "zh-rCN", - "zh-rTW", - ), - ), -) - -val config = appConfig[organization] ?: appConfig["ooni"]!! +val config = Organization.fromKey(organization).config val javaFxParts = listOf("base", "graphics", "controls", "media", "web", "swing") val javaFxVersion = "17" @@ -380,24 +330,15 @@ sqldelight { ktlint { filter { - exclude("**/generated/**") + exclude { + val p = it.file.path.replace('\\', '/') + p.contains("/build/generated/") + } include("**/kotlin/**") } additionalEditorconfig.put("ktlint_function_naming_ignore_when_annotated_with", "Composable") } -tasks.register("runDebug", Exec::class) { - dependsOn("clean", "installFullDebug") - commandLine( - "adb", - "shell", - "am", - "start", - "-n", - "${config.appId}.dev/org.ooni.probe.MainActivity", - ) -} - // Desktop compose.desktop { @@ -504,216 +445,3 @@ version = android.defaultConfig.versionName ?: "" dependencies { debugImplementation(compose.uiTooling) } - -// Resources - -// Fix to exclude sqldelight generated files -tasks { - listOf( - runKtlintFormatOverCommonMainSourceSet, - runKtlintCheckOverCommonMainSourceSet, - ).forEach { - it { - setSource( - kotlin.sourceSets.commonMain.map { - it.kotlin.filter { file -> !file.absolutePath.contains("generated") } - }, - ) - } - } -} - -val makeLibrary by tasks.registering(Exec::class) { - workingDir = file("src/desktopMain") - commandLine = listOf("make", "all") - description = "Build native libraries (NetworkTypeFinder and UpdateBridge)" - doFirst { - println("🔨 Building native libraries...") - } - doLast { - println("✅ Native libraries built successfully") - } -} - -val cleanLibrary by tasks.registering(Exec::class) { - workingDir = file("src/desktopMain") - commandLine = listOf("make", "clean") - description = "Clean native library build artifacts" -} - -// Ensure native libraries are built before desktop compilation -tasks.named("compileKotlinDesktop").configure { - // dependsOn(makeLibrary) -} - -tasks.withType { - systemProperty( - "java.library.path", - "$projectDir/src/desktopMain/resources/macos" + - File.pathSeparator + - "$projectDir/src/desktopMain/resources/windows" + - File.pathSeparator + - "$projectDir/src/desktopMain/resources/linux" + - File.pathSeparator + - System.getProperty("java.library.path"), - ) - systemProperty( - "desktopUpdatesPublicKey", - gradleLocalProperties(rootDir, providers).getProperty("desktopUpdatesPublicKey"), - ) -} - -tasks.register("copyBrandingToCommonResources") { - doLast { - val projectDir = project.projectDir.absolutePath - copyRecursive( - from = File(projectDir, "src/${config.folder}/res"), - to = File(projectDir, "src/commonMain/res"), - ) - copyRecursive( - from = File(projectDir, "src/${config.folder}/composeResources"), - to = File(projectDir, "src/commonMain/composeResources"), - ) - } -} - -tasks.register("cleanCopiedCommonResourcesToFlavor") { - doLast { - val projectDir = project.projectDir.absolutePath - - fun deleteFilesFromGitIgnore(folderPath: String) { - val destinationFile = File(projectDir, folderPath) - destinationFile.listFiles()?.forEach { folder -> - folder.listFiles()?.forEach { file -> - if (file.name == ".gitignore") { - file - .readText() - .lines() - .forEach { line -> - if (line.isNotEmpty()) { - println("Removing $line") - File(folder, line).deleteRecursively() - } - }.also { - file.delete() - } - } - } - } - } - deleteFilesFromGitIgnore("src/commonMain/res") - deleteFilesFromGitIgnore("src/commonMain/resources") - deleteFilesFromGitIgnore("src/commonMain/composeResources") - } -} - -/** - * Configure the prepareComposeResourcesTaskForCommonMain task to depend on the copyBrandingToCommonResources task. - * This will ensure that the common resources are copied to the correct location before the task is executed. - * - * NOTE: Current limitation is that multiple resources directories are not supported. - */ -tasks.named("preBuild").configure { - dependsOn("copyBrandingToCommonResources") -} - -tasks.named("clean").configure { - dependsOn("copyBrandingToCommonResources") -} - -tasks.named("clean").configure { - dependsOn("cleanCopiedCommonResourcesToFlavor") -} - -/** - * Ignore the copied file if it is not already ignored. - * - * @param filePath The path to the file to ignore. - * @param lineToAdd The line to add to the file. - */ -fun ignoreCopiedFileIfNotIgnored( - filePath: String, - lineToAdd: String, -) { - val file = File(filePath) - - if (!file.exists()) { - file.createNewFile() - } - - val fileContents = file.readText() - - if (!fileContents.contains(lineToAdd)) { - file.appendText("\n$lineToAdd") - } -} - -/** - * Copy files from one directory to another. - * - * @param from The source directory. - * @param to The destination directory. - */ -fun copyRecursive( - from: File, - to: File, -) { - if (!from.exists()) { - println("Source directory does not exist: $from") - return - } - from.listFiles()?.forEach { file -> - if (file.name != ".DS_Store") { - if (file.isDirectory) { - val newDir = File(to, file.name) - newDir.mkdir() - copyRecursive(file, newDir) - } else { - val destinationFile = File(to, file.name) - if (destinationFile.exists()) { - destinationFile.delete() - } - if (!destinationFile.parentFile.exists()) { - destinationFile.parentFile.mkdirs() - } - file.copyTo(destinationFile).also { - ignoreCopiedFileIfNotIgnored( - to.absolutePath + "/.gitignore", - it.name, - ) - } - } - } - } -} - -// Helpers - -data class AppConfig( - val appId: String, - val appName: String, - val folder: String, - val supportsOoniRun: Boolean = false, - val supportedLanguages: List, -) - -fun isFdroidTaskRequested(): Boolean = - gradle.startParameter.taskRequests - .flatMap { it.args } - .any { it.contains("Fdroid") } - -fun isDebugTaskRequested(): Boolean = - gradle.startParameter.taskRequests - .flatMap { it.args } - .any { it.contains("Debug") } - -fun getJavaFxSuffix(): String { - val os = OperatingSystem.current() - val arch = System.getProperty("os.arch") - return when { - os.isMacOsX -> if (arch == "aarch64") "mac-aarch64" else "mac" - os.isWindows -> "win" - os.isLinux -> if (arch == "aarch64") "linux-aarch64" else "linux" - else -> throw IllegalStateException("Unknown OS: $os") - } -}