From aaff60e162402b8d4d97b46533988a74a74fdf2d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 8 May 2026 00:17:32 +0100 Subject: [PATCH 1/6] refactor: move jacoco.gradle.kts into buildSrc Fixes a bug running `lint` after converting build.gradle to the Kotlin DSL AGP 9.0.1 Kotlin 2.3.21 lint 32.2 apply(from = "*.gradle.kts") to an Android build.gradle.kts module causes lint's K2 build-script analyzer to crash, even on an empty script Message: \\\`findFirCompiledSymbol\\\` only works on compiled declarations, but the given declaration is not compiled. Assisted-by: Claude Opus 4.7 - diagnostics --- AnkiDroid/build.gradle | 2 +- .../src/main/kotlin/ankidroid.plugins.jacoco.gradle.kts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename AnkiDroid/jacoco.gradle.kts => buildSrc/src/main/kotlin/ankidroid.plugins.jacoco.gradle.kts (100%) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index ba20335a58ff..7ab13db55e69 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -4,6 +4,7 @@ plugins { // TODO: migrate to .kts & replace the next 2 id lines with id("ankidroid.android.app") id 'com.android.application' id 'org.jetbrains.kotlin.plugin.parcelize' + id 'ankidroid.plugins.jacoco' alias(libs.plugins.kotlin.serialization) alias(libs.plugins.keeper) alias(libs.plugins.roborazzi) @@ -383,7 +384,6 @@ afterEvaluate { } apply from: "./robolectricDownloader.gradle" -apply from: "./jacoco.gradle.kts" apply from: "../lint.gradle" dependencies { diff --git a/AnkiDroid/jacoco.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.plugins.jacoco.gradle.kts similarity index 100% rename from AnkiDroid/jacoco.gradle.kts rename to buildSrc/src/main/kotlin/ankidroid.plugins.jacoco.gradle.kts From 77f316dffd71433a0bd36b226557e8725cf8cf14 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 7 May 2026 17:30:19 +0100 Subject: [PATCH 2/6] rename: build.gradle => gradle.kts [breaks build] This is prep for converting the project to .kts This commit ensures that `git blame` is maintained Issue 20910 --- AnkiDroid/{build.gradle => build.gradle.kts} | 0 AnkiDroid/proguard-rules.pro | 2 +- .../java/com/ichi2/testutils/NewCollectionPathTestRunner.kt | 2 +- gradle/libs.versions.toml | 2 +- tools/release.sh | 4 ++-- 5 files changed, 5 insertions(+), 5 deletions(-) rename AnkiDroid/{build.gradle => build.gradle.kts} (100%) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle.kts similarity index 100% rename from AnkiDroid/build.gradle rename to AnkiDroid/build.gradle.kts diff --git a/AnkiDroid/proguard-rules.pro b/AnkiDroid/proguard-rules.pro index e199c7a4732d..dc06b6941103 100644 --- a/AnkiDroid/proguard-rules.pro +++ b/AnkiDroid/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/testutils/NewCollectionPathTestRunner.kt b/AnkiDroid/src/androidTest/java/com/ichi2/testutils/NewCollectionPathTestRunner.kt index b2dd8d099bad..3928e39333cc 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/testutils/NewCollectionPathTestRunner.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/testutils/NewCollectionPathTestRunner.kt @@ -24,7 +24,7 @@ import androidx.test.runner.AndroidJUnitRunner * A test runner which sets [com.ichi2.anki.AnkiDroidApp.INSTRUMENTATION_TESTING] to true * so a test collection path is used */ -@Suppress("unused") // referenced by build.gradle +@Suppress("unused") // referenced by build.gradle.kts class NewCollectionPathTestRunner : AndroidJUnitRunner() { override fun newApplication( cl: ClassLoader?, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 950609cfd432..4363e318bf06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ compileSdk = "36" # Changing minSdk means newer AnkiDroid versions will not support older devices. # However the Play Store will keep old AnkiDroid versions available for older -# devices *if* you also change AnkiDroid/build.gradle play { retain {} } block +# devices *if* you also change AnkiDroid/build.gradle.kts play { retain {} } block # to include the version codes of the last released version for the older # minSdk. It is critical to update those version codes when you change this. minSdk = "24" diff --git a/tools/release.sh b/tools/release.sh index 74761c80d304..37a373eb37af 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -31,13 +31,13 @@ if [ "$PUBLIC" = "public" ] && ! [ -f ../ankidroiddocs/changelog.asc ]; then fi # Captured once so every APK in this release reports the same BUILD_TIME -# (consumed by AnkiDroid/build.gradle via -PbuildTime). +# (consumed by AnkiDroid/build.gradle.kts via -PbuildTime). BUILD_TIME_MS=$(date +%s000) export BUILD_TIME_MS # Define the location of the manifest file SRC_DIR="./AnkiDroid" -GRADLEFILE="$SRC_DIR/build.gradle" +GRADLEFILE="$SRC_DIR/build.gradle.kts" CHANGELOG="$SRC_DIR/src/main/assets/changelog.html" if ! VERSION=$(grep 'versionName=' $GRADLEFILE | sed -e 's/.*="//' | sed -e 's/".*//') From 75b726df6fbaa036988888b5c3e407730cfb1195 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 7 May 2026 18:47:25 +0100 Subject: [PATCH 3/6] refactor(build): convert build.gradle to Kotlin DSL `resolutionStrategy` was moved out of `dependencies`: `DependencyHandlerScope` doesn't expose configurations Part of issue 20910 - moving the project to Kotlin DSL Assisted-by: Claude Opus 4.7 - all - asked for a 1:1 conversion --- AnkiDroid/build.gradle.kts | 527 ++++++++++++++++++++----------------- tools/release.sh | 6 +- 2 files changed, 288 insertions(+), 245 deletions(-) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index 7ab13db55e69..d1dec2215f5c 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -1,45 +1,54 @@ +import com.android.build.gradle.internal.api.ApkVariantOutputImpl +import com.android.build.gradle.internal.tasks.R8Task +import java.util.Properties + plugins { // Gradle plugin portal alias(libs.plugins.tripletPlay) // TODO: migrate to .kts & replace the next 2 id lines with id("ankidroid.android.app") - id 'com.android.application' - id 'org.jetbrains.kotlin.plugin.parcelize' - id 'ankidroid.plugins.jacoco' + id("com.android.application") + id("org.jetbrains.kotlin.plugin.parcelize") + id("ankidroid.plugins.jacoco") alias(libs.plugins.kotlin.serialization) alias(libs.plugins.keeper) alias(libs.plugins.roborazzi) - id 'idea' + id("idea") } repositories { google() mavenCentral() - maven { url = "https://jitpack.io" } + maven { url = uri("https://jitpack.io") } } keeper { traceReferences { // Silence missing definitions - arguments.set(["--map-diagnostics:MissingDefinitionsDiagnostic", "error", "none"]) + arguments.set(listOf("--map-diagnostics:MissingDefinitionsDiagnostic", "error", "none")) } } idea { module { - downloadJavadoc = System.getenv("CI") != "true" - downloadSources = System.getenv("CI") != "true" + isDownloadJavadoc = System.getenv("CI") != "true" + isDownloadSources = System.getenv("CI") != "true" } } -def homePath = System.properties['user.home'] +val homePath: String = System.getProperty("user.home") + +// https://docs.gradle.org/current/userguide/configuration_cache_requirements.html#config_cache:requirements:external_processes + /** * Calculates the current git hash, invalidates configuration cache if changed * @example edf739d95bad7b370a6ed4398d46723f8219b3cd */ -// https://docs.gradle.org/current/userguide/configuration_cache_requirements.html#config_cache:requirements:external_processes -def gitCommitHash = providers.exec { - commandLine "git", "rev-parse", "HEAD" -}.standardOutput.asText.map { it.trim() } +val gitCommitHash = + providers + .exec { + commandLine("git", "rev-parse", "HEAD") + }.standardOutput.asText + .map { it.trim() } /** * Epoch millis of the build. Displayed on the 'About' screen. @@ -49,13 +58,20 @@ def gitCommitHash = providers.exec { * A local build uses a stale value (captured in the configuration cache) so the configuration cache * is not invalidated on every build. */ -def buildTimeMillis = providers.gradleProperty("buildTime") - .orElse(providers.provider { System.currentTimeMillis().toString() }) +val buildTimeMillis = + providers + .gradleProperty("buildTime") + .orElse(providers.provider { System.currentTimeMillis().toString() }) android { + val app = this + namespace = "com.ichi2.anki" - compileSdk = libs.versions.compileSdk.get().toInteger() + compileSdk = + libs.versions.compileSdk + .get() + .toInt() buildFeatures { buildConfig = true @@ -72,13 +88,13 @@ android { defaultConfig { applicationId = "com.ichi2.anki" - buildConfigField "Boolean", "CI", (System.getenv("CI") == "true").toString() - buildConfigField "String", "ACRA_URL", '"https://ankidroid.org/acra/report"' - buildConfigField "String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"" - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "false" - buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitCommitHash.get()}\"" - buildConfigField "long", "BUILD_TIME", buildTimeMillis.get() - resValue "string", "app_name", "AnkiDroid" + buildConfigField("Boolean", "CI", (System.getenv("CI") == "true").toString()) + buildConfigField("String", "ACRA_URL", "\"https://ankidroid.org/acra/report\"") + buildConfigField("String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"") + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", "false") + buildConfigField("String", "GIT_COMMIT_HASH", "\"${gitCommitHash.get()}\"") + buildConfigField("long", "BUILD_TIME", buildTimeMillis.get()) + resValue("string", "app_name", "AnkiDroid") // The version number is of the form: // ..[dev|alpha|beta|] @@ -101,104 +117,111 @@ android { // // This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is // needed for upgrades to be offered correctly. - versionCode=22400300 + versionCode = 22400300 // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... - versionName="2.24.0" - minSdk = libs.versions.minSdk.get().toInteger() + versionName = "2.24.0" + minSdk = + libs.versions.minSdk + .get() + .toInt() // After #13695: change .tests_emulator.yml - targetSdk = libs.versions.targetSdk.get().toInteger() + targetSdk = + libs.versions.targetSdk + .get() + .toInt() testApplicationId = "com.ichi2.anki.tests" vectorDrawables.useSupportLibrary = true - testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner' + testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner" } signingConfigs { - release { - def keystorePath = System.getenv("KEYSTOREPATH") - if (keystorePath != null && !keystorePath.trim().isEmpty()) { - storeFile file(keystorePath) - storePassword System.getenv("KEYSTOREPWD") ?: System.getenv("KSTOREPWD") - keyAlias System.getenv("KEYALIAS") - keyPassword System.getenv("KEYPWD") + create("release") { + val keystorePath: String? = System.getenv("KEYSTOREPATH") + if (keystorePath != null && keystorePath.trim().isNotEmpty()) { + storeFile = file(keystorePath) + storePassword = System.getenv("KEYSTOREPWD") ?: System.getenv("KSTOREPWD") + keyAlias = System.getenv("KEYALIAS") + keyPassword = System.getenv("KEYPWD") } else { - storeFile file("${rootDir}/tools/fallback-release-keystore.jks") - storePassword "Test@123" - keyAlias "my-key" - keyPassword "Test@123" + storeFile = file("$rootDir/tools/fallback-release-keystore.jks") + storePassword = "Test@123" + keyAlias = "my-key" + keyPassword = "Test@123" } } } buildTypes { - named('debug') { - versionNameSuffix "-debug" - debuggable true - applicationIdSuffix ".debug" - splits.abi.universalApk = true // Build universal APK for debug always + named("debug") { + versionNameSuffix = "-debug" + isDebuggable = true + applicationIdSuffix = ".debug" + app.splits.abi.isUniversalApk = true // Build universal APK for debug always // Check Crash Reports page on developer wiki for info on ACRA testing // buildConfigField "String", "ACRA_URL", '"https://918f7f55-f238-436c-b34f-c8b5f1331fe5-bluemix.cloudant.com/acra-ankidroid/_design/acra-storage/_update/report"' - if (project.rootProject.file('local.properties').exists()) { - Properties localProperties = new Properties() - localProperties.load(project.rootProject.file('local.properties').newDataInputStream()) + if (project.rootProject.file("local.properties").exists()) { + val localProperties = Properties() + localProperties.load(project.rootProject.file("local.properties").inputStream()) // #6009 Allow optional disabling of JaCoCo for general build (assembleDebug). // jacocoDebug task was slow, hung, and wasn't required unless I wanted coverage - testCoverageEnabled = localProperties['enable_coverage'] != "false" + enableAndroidTestCoverage = localProperties["enable_coverage"] != "false" // not profiled: optimization for build times - if (localProperties['enable_languages'] == "false") { - android.defaultConfig.resConfigs "en" + if (localProperties["enable_languages"] == "false") { + app.defaultConfig.resConfigs("en") } // allow disabling leak canary if (localProperties["enable_leak_canary"] != null) { - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", localProperties["enable_leak_canary"] + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", localProperties["enable_leak_canary"] as String) } else { - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "true" + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", "true") } } else { - testCoverageEnabled true + enableAndroidTestCoverage = true } // make the icon red if in debug mode - resValue 'color', 'anki_foreground_icon_color_0', "#FFFF0000" - resValue 'color', 'anki_foreground_icon_color_1', "#FFFF0000" + resValue("color", "anki_foreground_icon_color_0", "#FFFF0000") + resValue("color", "anki_foreground_icon_color_1", "#FFFF0000") } - named('release') { - testCoverageEnabled = testReleaseBuild - minifyEnabled System.getenv("MINIFY_ENABLED") ? System.getenv("MINIFY_ENABLED") != "false" : true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - testProguardFile 'proguard-test-rules.pro' - splits.abi.universalApk = universalApkEnabled // Build universal APK for release with `-Duniversal-apk=true` - signingConfig = signingConfigs.release + named("release") { + enableAndroidTestCoverage = rootProject.testReleaseBuild + val minifyEnv = System.getenv("MINIFY_ENABLED") + isMinifyEnabled = if (!minifyEnv.isNullOrEmpty()) minifyEnv != "false" else true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + testProguardFile("proguard-test-rules.pro") + app.splits.abi.isUniversalApk = rootProject.universalApkEnabled // Build universal APK for release with `-Duniversal-apk=true` + signingConfig = signingConfigs.getByName("release") // syntax: assembleRelease -PcustomSuffix="suffix" -PcustomName="New name" if (project.hasProperty("customSuffix")) { // the suffix needs a '.' at the start - applicationIdSuffix project.property("customSuffix").replaceFirst(/^\.*/, ".") + applicationIdSuffix = (project.property("customSuffix") as String).replaceFirst(Regex("^\\.*"), ".") } if (project.hasProperty("customName")) { - resValue "string", "app_name", project.property("customName") + resValue("string", "app_name", project.property("customName") as String) } - resValue 'color', 'anki_foreground_icon_color_0', "#FF29B6F6" - resValue 'color', 'anki_foreground_icon_color_1', "#FF0288D1" + resValue("color", "anki_foreground_icon_color_0", "#FF29B6F6") + resValue("color", "anki_foreground_icon_color_1", "#FF0288D1") } } - /** + /* * Product Flavors are used for Amazon App Store and Google Play Store. * This is because we cannot use Camera Permissions in Amazon App Store (for FireTv etc...) * Therefore, different AndroidManifest for Camera Permissions is used in Amazon flavor. */ flavorDimensions += "appStore" productFlavors { - create('play') { - getIsDefault().set(true) - dimension "appStore" + create("play") { + isDefault = true + dimension = "appStore" } - create('amazon') { - dimension "appStore" + create("amazon") { + dimension = "appStore" } // A 'full' build has no restrictions on storage/camera. Distributed on GitHub/F-Droid - create('full') { - dimension "appStore" + create("full") { + dimension = "appStore" } } @@ -211,37 +234,39 @@ android { * Upload all the APKs to the Play Store and people will download * the correct one based on the CPU architecture of their device. */ - def enableSeparateBuildPerCPUArchitecture = true + val enableSeparateBuildPerCPUArchitecture = true splits { abi { reset() - enable = enableSeparateBuildPerCPUArchitecture - //universalApk enableUniversalApk // set in debug + release config blocks above - include "armeabi-v7a", "x86", "arm64-v8a", "x86_64" + isEnable = enableSeparateBuildPerCPUArchitecture + // universalApk enableUniversalApk // set in debug + release config blocks above + include("armeabi-v7a", "x86", "arm64-v8a", "x86_64") } } // applicationVariants are e.g. debug, release - applicationVariants.configureEach { variant -> + applicationVariants.configureEach { + val variant = this // We want the same version stream for all ABIs in debug but for release we can split them - if (variant.buildType.name == 'release') { - variant.outputs.configureEach { output -> + if (variant.buildType.name == "release") { + variant.outputs.configureEach { + val output = this as ApkVariantOutputImpl // For each separate APK per architecture, set a unique version code as described here: // https://developer.android.com/studio/build/configure-apk-splits.html - def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] - def outputFile = output.outputFile - if (outputFile != null && outputFile.name.endsWith('.apk')) { - def abi = output.getFilter("ABI") - if (abi != null) { // null for the universal-debug, universal-release variants + val versionCodes = mapOf("armeabi-v7a" to 1, "x86" to 2, "arm64-v8a" to 3, "x86_64" to 4) + val outputFile = output.outputFile + if (outputFile != null && outputFile.name.endsWith(".apk")) { + val abi = output.getFilter("ABI") + if (abi != null) { // null for the universal-debug, universal-release variants // From: https://developer.android.com/studio/publish/versioning#appversioning // "Warning: The greatest value Google Play allows for versionCode is 2100000000" // AnkiDroid versionCodes have a budget 8 digits (through AnkiDroid 9) // This style does ABI version code ranges with the 9th digit as 0-4. // This consumes ~20% of the version range space, w/50 years of versioning at our major-version pace output.versionCodeOverride = - // ex: 321200106 = 3 * 100000000 + 21200106 - versionCodes.get(abi) * 100000000 + defaultConfig.versionCode + // ex: 321200106 = 3 * 100000000 + 21200106 + versionCodes.getValue(abi) * 100000000 + defaultConfig.versionCode!! } } } @@ -260,15 +285,15 @@ android { test.useJUnitPlatform { // ./gradlew testFullDebugUnitTest -Pscreenshot if (project.hasProperty("screenshot")) { - includeTags "com.ichi2.anki.ScreenshotTestCategory" + includeTags("com.ichi2.anki.ScreenshotTestCategory") } else { - excludeTags "com.ichi2.anki.ScreenshotTestCategory" + excludeTags("com.ichi2.anki.ScreenshotTestCategory") } // Ensures that no EmptyApplication test relies on AnkiDroidApp // by only running EmptyApplication tests // ./gradlew testFullDebugUnitTest -PemptyApplication if (project.hasProperty("emptyApplication")) { - includeTags "com.ichi2.anki.EmptyApplicationCategory" + includeTags("com.ichi2.anki.EmptyApplicationCategory") } } } @@ -285,26 +310,27 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - coreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } packagingOptions { resources { - excludes += ['META-INF/DEPENDENCIES'] + excludes += "META-INF/DEPENDENCIES" } } } play { - serviceAccountCredentials.set(file("${homePath}/src/AnkiDroid-GCP-Publish-Credentials.json")) - track.set('beta') + serviceAccountCredentials.set(file("$homePath/src/AnkiDroid-GCP-Publish-Credentials.json")) + track.set("beta") // any time we bump minSdk we want Play Store to retain the old artifacts by version code, // so that they remain available for older devices retain { - artifacts.set([ + artifacts.set( + listOf( 20700300L, // (2.7, minSdk 10, universal APK) 20804300L, // (2.8.4, minSdk 10, universal APK) 21004300L, // (2.10.4, minSdk 15, universal APK) @@ -317,7 +343,8 @@ play { 221905300L, // (2.19.5, minSdk 23, ABI x86) 321905300L, // (2.19.5, minSdk 23, ABI arm64-v8a) 421905300L, // (2.19.5, minSdk 23, ABI x86_64) - ]) + ), + ) } // If you retain APKs in a release with different names as we do above, @@ -328,214 +355,230 @@ play { // Install Git pre-commit hook for Ktlint // Resolve via git so worktrees (where `.git` is a file) are handled correctly. -def gitHooksDir = providers.exec { - workingDir = rootProject.rootDir - commandLine "git", "rev-parse", "--git-path", "hooks" -}.standardOutput.asText.map { rootProject.rootDir.toPath().resolve(it.trim()).toFile() } - -tasks.register('installGitHook', Copy) { - from new File(rootProject.rootDir, 'pre-commit') - into gitHooksDir +val gitHooksDir = + providers + .exec { + workingDir = rootProject.rootDir + commandLine("git", "rev-parse", "--git-path", "hooks") + }.standardOutput.asText + .map { + rootProject.rootDir + .toPath() + .resolve(it.trim()) + .toFile() + } + +tasks.register("installGitHook") { + from(File(rootProject.rootDir, "pre-commit")) + into(gitHooksDir) filePermissions { user { - read = write = execute = true + read = true + write = true + execute = true } } } // to run manually: `./gradlew installGitHook` -tasks.named('preBuild').configure { dependsOn('installGitHook') } +tasks.named("preBuild").configure { dependsOn("installGitHook") } // Issue 11078 - some emulators run, but run zero tests, and still report success -tasks.register('assertNonzeroAndroidTests') { +tasks.register("assertNonzeroAndroidTests") { // Resolve the directory at configuration time, which is Gradle Configuration Cache compatible - def resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/flavors/play") + val resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/flavors/play") doLast { // androidTest currently creates one .xml file per emulator with aggregate results in this dir - File folder = resultsDir.get().asFile - File[] listOfFiles = folder.listFiles({ d, f -> f ==~ /.*.xml/ } as FilenameFilter) - for (File file : listOfFiles) { + val folder: File = resultsDir.get().asFile + val listOfFiles: Array = folder.listFiles { _, f -> f.matches(Regex(".*.xml")) } ?: emptyArray() + for (file in listOfFiles) { // The aggregate results file currently contains a line with this pattern holding test count - String[] matches = file.readLines().findAll { it.contains('("minifyPlayReleaseAndroidTestWithR8").configure { referencedClasses.from( - files("build/intermediates/compile_app_classes_jar/playRelease/bundlePlayReleaseClassesToCompileJar/classes.jar") + files("build/intermediates/compile_app_classes_jar/playRelease/bundlePlayReleaseClassesToCompileJar/classes.jar"), ) } } } -apply from: "./robolectricDownloader.gradle" -apply from: "../lint.gradle" +apply(from = "./robolectricDownloader.gradle") +apply(from = "../lint.gradle") -dependencies { - configurations.configureEach { - resolutionStrategy { - // Timber has this as a dependency but they are not up to date. We want to force our version. - force libs.jetbrains.annotations - } +configurations.configureEach { + resolutionStrategy { + // Timber has this as a dependency but they are not up to date. We want to force our version. + force(libs.jetbrains.annotations) } - api project(":api") - implementation libs.androidx.work.runtime - lintChecks project(":lint-rules") - coreLibraryDesugaring libs.desugar.jdk.libs.nio +} - compileOnly libs.jetbrains.annotations - compileOnly libs.auto.service.annotations - annotationProcessor libs.auto.service +dependencies { + api(project(":api")) + implementation(libs.androidx.work.runtime) + lintChecks(project(":lint-rules")) + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.auto.service.annotations) + annotationProcessor(libs.auto.service) // modules - implementation project(":common") - implementation project(":common:android") - implementation project(":compat") - implementation project(":libanki") - implementation project(":vbpd") - - implementation libs.androidx.activity - implementation libs.androidx.annotation - implementation libs.androidx.appcompat - implementation libs.androidx.browser - implementation libs.androidx.core.ktx - implementation libs.androidx.draganddrop - implementation libs.androidx.exifinterface - implementation libs.androidx.fragment.ktx - implementation libs.androidx.lifecycle.process - implementation libs.androidx.media - implementation libs.androidx.preference.ktx - implementation libs.androidx.recyclerview - implementation libs.androidx.sqlite.framework - implementation libs.androidx.swiperefreshlayout - implementation libs.androidx.viewpager2 - implementation libs.androidx.constraintlayout - implementation libs.androidx.webkit - implementation libs.google.material - implementation libs.android.image.cropper - implementation libs.nanohttpd - implementation libs.kotlinx.serialization.json - implementation libs.seismic - - debugImplementation libs.androidx.fragment.testing.manifest + implementation(project(":common")) + implementation(project(":common:android")) + implementation(project(":compat")) + implementation(project(":libanki")) + implementation(project(":vbpd")) + + implementation(libs.androidx.activity) + implementation(libs.androidx.annotation) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.browser) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.draganddrop) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.media) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.sqlite.framework) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.webkit) + implementation(libs.google.material) + implementation(libs.android.image.cropper) + implementation(libs.nanohttpd) + implementation(libs.kotlinx.serialization.json) + implementation(libs.seismic) + + debugImplementation(libs.androidx.fragment.testing.manifest) // Backend libraries - implementation libs.protobuf.kotlin.lite // This is required when loading from a file + implementation(libs.protobuf.kotlin.lite) // This is required when loading from a file - Properties localProperties = new Properties() - if (project.rootProject.file('local.properties').exists()) { - localProperties.load(project.rootProject.file('local.properties').newDataInputStream()) + val localProperties = Properties() + if (project.rootProject.file("local.properties").exists()) { + localProperties.load(project.rootProject.file("local.properties").inputStream()) } - if (localProperties['local_backend'] == "true") { + if (localProperties["local_backend"] == "true") { implementation(files(rootProject.file("../Anki-Android-Backend/rsdroid/build/outputs/aar/rsdroid-release.aar"))) testImplementation(files(rootProject.file("../Anki-Android-Backend/rsdroid-testing/build/libs/rsdroid-testing.jar"))) } else { - implementation libs.ankiBackend.backend - testImplementation libs.ankiBackend.testing + implementation(libs.ankiBackend.backend) + testImplementation(libs.ankiBackend.testing) } // May need a resolution strategy for support libs to our versions - implementation libs.acra.limiter - implementation libs.acra.toast - implementation libs.acra.dialog - implementation libs.acra.http - - implementation libs.commons.compress - implementation libs.commons.collections4 // SetUniqueList - implementation libs.commons.io // FileUtils.contentEquals - implementation libs.mikehardy.google.analytics.java7 - implementation libs.okhttp - implementation libs.slf4j.timber - implementation libs.jakewharton.timber - implementation libs.jsoup - implementation libs.java.semver // For AnkiDroid JS API Versioning - implementation libs.drakeet.drawer - implementation libs.skydoves.colorpickerview - implementation libs.kotlin.reflect - implementation libs.kotlin.test - implementation libs.search.preference + implementation(libs.acra.limiter) + implementation(libs.acra.toast) + implementation(libs.acra.dialog) + implementation(libs.acra.http) + + implementation(libs.commons.compress) + implementation(libs.commons.collections4) // SetUniqueList + implementation(libs.commons.io) // FileUtils.contentEquals + implementation(libs.mikehardy.google.analytics.java7) + implementation(libs.okhttp) + implementation(libs.slf4j.timber) + implementation(libs.jakewharton.timber) + implementation(libs.jsoup) + implementation(libs.java.semver) // For AnkiDroid JS API Versioning + implementation(libs.drakeet.drawer) + implementation(libs.skydoves.colorpickerview) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.test) + implementation(libs.search.preference) // Cannot use debugImplementation since classes need to be imported in AnkiDroidApp // and there's no no-op version for release build. Usage has been disabled for release // build via AnkiDroidApp. - implementation libs.leakcanary.android + implementation(libs.leakcanary.android) - testImplementation testFixtures(project(":libanki")) - testImplementation testFixtures(project(":common")) - testImplementation testFixtures(project(":compat")) + testImplementation(testFixtures(project(":libanki"))) + testImplementation(testFixtures(project(":common"))) + testImplementation(testFixtures(project(":compat"))) // A path for a testing library which provide Parameterized Test - testImplementation libs.junit.jupiter - testImplementation libs.junit.jupiter.params - testImplementation libs.junit.vintage.engine - testImplementation libs.mockito.inline - testImplementation libs.mockito.kotlin - testImplementation libs.hamcrest + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.vintage.engine) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.hamcrest) // robolectricDownloader.gradle *may* need a new SDK jar entry if they release one or if we change targetSdk. Instructions in that gradle file. - testImplementation libs.robolectric - testImplementation libs.androidx.test.core - testImplementation libs.androidx.test.junit - testImplementation libs.kotlin.reflect - testImplementation libs.kotlin.test - testImplementation libs.kotlin.test.junit5 - testImplementation libs.kotlinx.coroutines.test - testImplementation libs.mockk - testImplementation libs.androidx.fragment.testing + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.kotlin.reflect) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlin.test.junit5) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.androidx.fragment.testing) // in a JvmTest we need org.json.JSONObject to not be mocked - testImplementation libs.json - testImplementation libs.ivanshafran.shared.preferences.mock - testImplementation libs.androidx.test.runner - testImplementation libs.androidx.test.rules - testImplementation libs.androidx.espresso.core + testImplementation(libs.json) + testImplementation(libs.ivanshafran.shared.preferences.mock) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.androidx.test.rules) + testImplementation(libs.androidx.espresso.core) testImplementation(libs.androidx.espresso.contrib) { - exclude module: "protobuf-lite" + exclude(module = "protobuf-lite") } - testImplementation libs.androidx.work.testing - testImplementation libs.roborazzi - testImplementation libs.roborazzi.rule + testImplementation(libs.androidx.work.testing) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.rule) // for testing flows - testImplementation libs.cashapp.turbine + testImplementation(libs.cashapp.turbine) // May need a resolution strategy for support libs to our versions - androidTestImplementation libs.androidx.espresso.core + androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.contrib) { - exclude module: "protobuf-lite" + exclude(module = "protobuf-lite") } - androidTestImplementation libs.androidx.test.core - androidTestImplementation libs.androidx.test.junit - androidTestImplementation libs.androidx.test.rules - androidTestImplementation libs.androidx.uiautomator - androidTestImplementation libs.kotlin.test - androidTestImplementation libs.kotlin.test.junit - androidTestImplementation libs.androidx.fragment.testing - - implementation libs.androidx.media3.exoplayer - implementation libs.androidx.media3.exoplayer.dash - implementation libs.androidx.media3.ui + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.kotlin.test.junit) + androidTestImplementation(libs.androidx.fragment.testing) + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.ui) // ---- testFixtures setup ---- - testFixturesImplementation libs.kotlin.stdlib - testFixturesImplementation libs.jakewharton.timber - testFixturesImplementation libs.hamcrest - testFixturesImplementation libs.androidx.test.junit + testFixturesImplementation(libs.kotlin.stdlib) + testFixturesImplementation(libs.jakewharton.timber) + testFixturesImplementation(libs.hamcrest) + testFixturesImplementation(libs.androidx.test.junit) // Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause // an "unresolved" warning, which is treated as an error due to allWarningsAsErrors - testFixturesImplementation libs.kotlinx.coroutines.core - + testFixturesImplementation(libs.kotlinx.coroutines.core) } + +val Project.androidTestVariantName: String get() = extra["androidTestVariantName"] as String +val Project.testReleaseBuild: Boolean get() = extra["testReleaseBuild"] as Boolean +val Project.universalApkEnabled: Boolean get() = extra["universalApkEnabled"] as Boolean diff --git a/tools/release.sh b/tools/release.sh index 37a373eb37af..e405229b92d4 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -40,7 +40,7 @@ SRC_DIR="./AnkiDroid" GRADLEFILE="$SRC_DIR/build.gradle.kts" CHANGELOG="$SRC_DIR/src/main/assets/changelog.html" -if ! VERSION=$(grep 'versionName=' $GRADLEFILE | sed -e 's/.*="//' | sed -e 's/".*//') +if ! VERSION=$(grep 'versionName =' $GRADLEFILE | sed -e 's/.*"\(.*\)".*/\1/') then echo "Unable to get current version. Is sed installed?" exit 1 @@ -87,13 +87,13 @@ if [ "$PUBLIC" != "public" ]; then # Increment version code # It is an integer in AndroidManifest that nobody actually sees. # Ex: 72 to 73 - PREVIOUS_CODE=$(grep 'versionCode=' $GRADLEFILE | sed -e 's/.*=//') + PREVIOUS_CODE=$(grep 'versionCode =' $GRADLEFILE | sed -e 's/.*= //') GUESSED_CODE=$((PREVIOUS_CODE + 1)) # Edit AndroidManifest.xml to bump version string echo "Bumping version from $PREVIOUS_VERSION$SUFFIX to $VERSION (and code from $PREVIOUS_CODE to $GUESSED_CODE)" sed -i -e s/"$PREVIOUS_VERSION""$SUFFIX"/"$VERSION"/g $GRADLEFILE - sed -i -e s/versionCode="$PREVIOUS_CODE"/versionCode="$GUESSED_CODE"/g $GRADLEFILE + sed -i -e "s/versionCode = $PREVIOUS_CODE/versionCode = $GUESSED_CODE/g" $GRADLEFILE fi # If any changes go in during the release process, pushing fails, so push immediately. From 7725845d7c169233e3e4248f1de751fbb149f375 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 7 May 2026 18:53:26 +0100 Subject: [PATCH 4/6] lint: build.gradle.kts General IDE suggestions/deprecations ExperimentalCoroutinesApi: not needed. The opt-in is applied to all but a few modules in Anki-Android/build.gradle.kts --- AnkiDroid/build.gradle.kts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index d1dec2215f5c..42ed550e1571 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -80,11 +80,7 @@ android { resValues = true } - if (rootProject.testReleaseBuild) { - testBuildType = "release" - } else { - testBuildType = "debug" - } + testBuildType = if (rootProject.testReleaseBuild) "release" else "debug" defaultConfig { applicationId = "com.ichi2.anki" @@ -275,11 +271,6 @@ android { testOptions { animationsDisabled = true - kotlin { - compilerOptions { - freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") - } - } unitTests { all { test -> test.useJUnitPlatform { @@ -305,6 +296,7 @@ android { // ⚠️ There was an in-IDE warning: "Kotlin is not configured" when editing the testFixtures // files. I ended up ignoring the warning after the 'Configure' button in Android Studio // added dependencies but didn't fix the issue. + @Suppress("UnstableApiUsage") testFixtures { enable = true } @@ -315,7 +307,7 @@ android { isCoreLibraryDesugaringEnabled = true } - packagingOptions { + packaging { resources { excludes += "META-INF/DEPENDENCIES" } @@ -350,6 +342,7 @@ play { // If you retain APKs in a release with different names as we do above, // the plugin + Play Store has no idea how to name the release except by date. // release name is developer only, but sane names really help, so set one + @Suppress("DEPRECATION") releaseName.set(android.defaultConfig.versionName) } @@ -427,7 +420,7 @@ apply(from = "../lint.gradle") configurations.configureEach { resolutionStrategy { - // Timber has this as a dependency but they are not up to date. We want to force our version. + // Timber has this as a dependency, but they are not up to date. We want to force our version. force(libs.jetbrains.annotations) } } @@ -527,7 +520,6 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.mockito.kotlin) testImplementation(libs.hamcrest) - // robolectricDownloader.gradle *may* need a new SDK jar entry if they release one or if we change targetSdk. Instructions in that gradle file. testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.junit) From a30fda5c38cc8102cffe67500768b03cbc487888 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 7 May 2026 22:15:13 +0100 Subject: [PATCH 5/6] chore: build.gradle.kts - suppress deprecation applicationVariants are not yet well handled Issue 20988 --- AnkiDroid/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index 42ed550e1571..99a0dd88afb7 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -63,6 +63,7 @@ val buildTimeMillis = .gradleProperty("buildTime") .orElse(providers.provider { System.currentTimeMillis().toString() }) +@Suppress("deprecation") // convert to configuration<> after android.newDsl=true (#20988) android { val app = this From e6826b04131403f7b7cfae9219709b291a9c5753 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 7 May 2026 23:27:11 +0100 Subject: [PATCH 6/6] build: `ankidroid.android.app` convention plugin Based on `ankidroid.android.library` Related to issue 20775 Assisted-by: Claude Opus 4.7 --- AnkiDroid/build.gradle.kts | 23 +-------- .../kotlin/ankidroid.android.app.gradle.kts | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 buildSrc/src/main/kotlin/ankidroid.android.app.gradle.kts diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index 99a0dd88afb7..128501aac62b 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -5,9 +5,7 @@ import java.util.Properties plugins { // Gradle plugin portal alias(libs.plugins.tripletPlay) - // TODO: migrate to .kts & replace the next 2 id lines with id("ankidroid.android.app") - id("com.android.application") - id("org.jetbrains.kotlin.plugin.parcelize") + id("ankidroid.android.app") id("ankidroid.plugins.jacoco") alias(libs.plugins.kotlin.serialization) alias(libs.plugins.keeper) @@ -69,11 +67,6 @@ android { namespace = "com.ichi2.anki" - compileSdk = - libs.versions.compileSdk - .get() - .toInt() - buildFeatures { buildConfig = true aidl = true @@ -117,16 +110,7 @@ android { versionCode = 22400300 // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... versionName = "2.24.0" - minSdk = - libs.versions.minSdk - .get() - .toInt() - - // After #13695: change .tests_emulator.yml - targetSdk = - libs.versions.targetSdk - .get() - .toInt() + testApplicationId = "com.ichi2.anki.tests" vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner" @@ -303,8 +287,6 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } @@ -417,7 +399,6 @@ afterEvaluate { } apply(from = "./robolectricDownloader.gradle") -apply(from = "../lint.gradle") configurations.configureEach { resolutionStrategy { diff --git a/buildSrc/src/main/kotlin/ankidroid.android.app.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.android.app.gradle.kts new file mode 100644 index 000000000000..a46c6dc164e1 --- /dev/null +++ b/buildSrc/src/main/kotlin/ankidroid.android.app.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +/* + Convention plugin: applies `com.android.application` plus Kotlin Parcelize, + and pins the settings shared with every other Android module + (compileSdk, minSdk, Java 17). Mirrors `ankidroid.android.library`. + */ + +import com.android.build.api.dsl.ApplicationExtension +import com.ichi2.anki.gradle.libsVersionFor + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.parcelize") +} + +extensions.configure { + compileSdk = libsVersionFor("compileSdk").toInt() + + defaultConfig { + minSdk = libsVersionFor("minSdk").toInt() + // After #13695: change .tests_emulator.yml + targetSdk = libsVersionFor("targetSdk").toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +// Shared project-wide lint configuration. +apply(from = "${rootDir}/lint.gradle")