From f04406ecd35f8c5eb6a0fc9a70e70de00a7635c8 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Fri, 31 Jul 2026 12:47:56 +0300 Subject: [PATCH 1/2] chore: upgrade to agp 9 and configure parallelism --- .agents/commands/release.md | 6 +- .github/workflows/e2e.yml | 12 +- .github/workflows/e2e_migration.yml | 6 +- .github/workflows/release-internal.yml | 6 +- .github/workflows/release.yml | 10 +- Justfile | 2 +- README.md | 10 +- app/build.gradle.kts | 237 +++++++++++++----- .../bitkit/build/BuildOutputContractTest.kt | 135 ++++++++++ build.gradle.kts | 1 - gradle.properties | 9 +- gradle/libs.versions.toml | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 13 files changed, 350 insertions(+), 94 deletions(-) create mode 100644 app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt diff --git a/.agents/commands/release.md b/.agents/commands/release.md index e39f686fa1..b3006e00c6 100644 --- a/.agents/commands/release.md +++ b/.agents/commands/release.md @@ -208,8 +208,8 @@ Print the path to the release notes file so the user can share it for review. just release ``` -Expected APK path: `app/build/outputs/apk/mainnet/release/bitkit-mainnet-release-{newVersionCode}-universal.apk` -Expected AAB path: `app/build/outputs/bundle/mainnetRelease/bitkit-mainnet-release-{newVersionCode}.aab` +Expected APK path: `app/build/outputs/bitkit/mainnetRelease/bitkit-mainnet-release-{newVersionCode}-universal.apk` +Expected AAB path: `app/build/outputs/bitkit/mainnetRelease/bitkit-mainnet-release-{newVersionCode}.aab` Expected native debug symbols path: `app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols-{newVersionCode}.zip` Verify all three files exist. The native debug symbols file must be from the same `just release` build as the APK/AAB. Keep the build-numbered filename, e.g. `native-debug-symbols-{newVersionCode}.zip`, so it matches the APK/AAB build number. `just release` resolves upstream native debug symbol artifacts from the Rust dependency packages, merges them into the final archive, and refuses placeholder symbols from stripped packaged `.so` files. @@ -218,7 +218,7 @@ Verify all three files exist. The native debug symbols file must be from the sam ```bash gh release upload v{newVersionName} \ - app/build/outputs/apk/mainnet/release/bitkit-mainnet-release-{newVersionCode}-universal.apk \ + app/build/outputs/bitkit/mainnetRelease/bitkit-mainnet-release-{newVersionCode}-universal.apk \ app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols-{newVersionCode}.zip ``` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 26388e7de3..b1445fea0a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -79,14 +79,14 @@ jobs: - name: Rename APK run: | - apk=$(find app/build/outputs/apk/dev/debug -name 'bitkit-dev-debug-*-universal.apk') - mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk + apk=$(find app/build/outputs/bitkit/devDebug -name 'bitkit-dev-debug-*-universal.apk') + mv "$apk" app/build/outputs/bitkit/devDebug/bitkit_e2e.apk - name: Upload APK (local) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-apk_${{ github.run_number }} - path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk + path: app/build/outputs/bitkit/devDebug/bitkit_e2e.apk build-staging: needs: detect-changes @@ -132,14 +132,14 @@ jobs: - name: Rename APK run: | - apk=$(find app/build/outputs/apk/dev/debug -name 'bitkit-dev-debug-*-universal.apk') - mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk + apk=$(find app/build/outputs/bitkit/devDebug -name 'bitkit-dev-debug-*-universal.apk') + mv "$apk" app/build/outputs/bitkit/devDebug/bitkit_e2e.apk - name: Upload APK (regtest) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-apk-regtest_${{ github.run_number }} - path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk + path: app/build/outputs/bitkit/devDebug/bitkit_e2e.apk e2e-branch: needs: detect-changes diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 0a969ea9c2..73e874b20d 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -60,14 +60,14 @@ jobs: - name: Rename APK run: | - apk=$(find app/build/outputs/apk/dev/debug -name 'bitkit-dev-debug-*-universal.apk') - mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk + apk=$(find app/build/outputs/bitkit/devDebug -name 'bitkit-dev-debug-*-universal.apk') + mv "$apk" app/build/outputs/bitkit/devDebug/bitkit_e2e.apk - name: Upload APK uses: actions/upload-artifact@v7 with: name: bitkit-e2e-apk_${{ github.run_number }} - path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk + path: app/build/outputs/bitkit/devDebug/bitkit_e2e.apk e2e-branch: if: github.event.pull_request.draft == false diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index db98ee9ad1..e5d61688a1 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -103,7 +103,7 @@ jobs: exit 1 fi done < <(find "$extract_dir" -name '*.so' -print0) - done < <(find app/build/outputs/apk/mainnet/release -type f -name '*.apk' -print0) + done < <(find app/build/outputs/bitkit/mainnetRelease -type f -name '*.apk' -print0) test "$native_count" -gt 0 - name: Verify internal release signature @@ -118,7 +118,7 @@ jobs: while IFS= read -r -d '' apk_path; do apk_count=$((apk_count + 1)) "$apksigner_path" verify --verbose --print-certs "$apk_path" - done < <(find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0) + done < <(find app/build/outputs/bitkit/mainnetRelease -name 'bitkit-mainnet-release-*.apk' -print0) test "$apk_count" -gt 0 - name: Collect internal artifacts @@ -127,7 +127,7 @@ jobs: set -euo pipefail artifact_dir="$RUNNER_TEMP/internal-release" mkdir -p "$artifact_dir" - mapfile -d '' apk_paths < <(find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0) + mapfile -d '' apk_paths < <(find app/build/outputs/bitkit/mainnetRelease -name 'bitkit-mainnet-release-*.apk' -print0) mapfile -d '' symbol_paths < <(find app/build/outputs/native-debug-symbols/mainnetRelease -name 'native-debug-symbols-*.zip' -print0) test "${#apk_paths[@]}" -gt 0 test "${#symbol_paths[@]}" -gt 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f71485ddbc..b847ca03e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: exit 1 fi done < <(find "$extract_dir" -name '*.so' -print0) - done < <(find app/build/outputs/apk/mainnet/release app/build/outputs/bundle/mainnetRelease -type f \( -name '*.apk' -o -name '*.aab' \) -print0) + done < <(find app/build/outputs/bitkit/mainnetRelease -type f \( -name '*.apk' -o -name '*.aab' \) -print0) test "$native_count" -gt 0 - name: Verify release signatures @@ -119,7 +119,7 @@ jobs: while IFS= read -r -d '' apk_path; do apk_count=$((apk_count + 1)) "$apksigner_path" verify --verbose --print-certs "$apk_path" - done < <(find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0) + done < <(find app/build/outputs/bitkit/mainnetRelease -name 'bitkit-mainnet-release-*.apk' -print0) test "$apk_count" -gt 0 bundle_count=0 @@ -141,7 +141,7 @@ jobs: exit 1 fi rm -f "$verify_output" - done < <(find app/build/outputs/bundle/mainnetRelease -name 'bitkit-mainnet-release-*.aab' -print0) + done < <(find app/build/outputs/bitkit/mainnetRelease -name 'bitkit-mainnet-release-*.aab' -print0) test "$bundle_count" -gt 0 - name: Collect release artifacts @@ -150,8 +150,8 @@ jobs: set -euo pipefail artifact_dir="$RUNNER_TEMP/release" mkdir -p "$artifact_dir" - mapfile -d '' bundle_paths < <(find app/build/outputs/bundle/mainnetRelease -name 'bitkit-mainnet-release-*.aab' -print0) - mapfile -d '' apk_paths < <(find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0) + mapfile -d '' bundle_paths < <(find app/build/outputs/bitkit/mainnetRelease -name 'bitkit-mainnet-release-*.aab' -print0) + mapfile -d '' apk_paths < <(find app/build/outputs/bitkit/mainnetRelease -name 'bitkit-mainnet-release-*.apk' -print0) mapfile -d '' symbol_paths < <(find app/build/outputs/native-debug-symbols/mainnetRelease -name 'native-debug-symbols-*.zip' -print0) test "${#bundle_paths[@]}" -gt 0 test "${#apk_paths[@]}" -gt 0 diff --git a/Justfile b/Justfile index a27f96eed9..4df82236c1 100644 --- a/Justfile +++ b/Justfile @@ -51,7 +51,7 @@ run mode="" logs="": set -eu app_id="to.bitkit.dev" - app_dir="app/build/outputs/apk/dev/debug" + app_dir="app/build/outputs/bitkit/devDebug" mode="{{ mode }}" logs="{{ logs }}" attach_logs=false diff --git a/README.md b/README.md index dbeb5cc44c..3c8ea83bab 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,8 @@ just build assembleDevRelease # just build assembleRelease # for all flavors ``` -APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'). -Example for dev: `app/build/outputs/apk/dev/release` +AGP writes its own APKs to `app/build/outputs/apk/_flavor_/release`, and the build publishes deterministically named copies to `app/build/outputs/bitkit/_variant_`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'; `_variant_` is the flavor and build type, e.g. `devRelease`). +Example for dev: `app/build/outputs/bitkit/devRelease/bitkit-dev-release-{versionCode}-universal.apk` ### Build for Release @@ -195,8 +195,8 @@ just release Release artifacts: -- APK: `app/build/outputs/apk/mainnet/release/` -- AAB: `app/build/outputs/bundle/mainnetRelease/` +- APK: `app/build/outputs/bitkit/mainnetRelease/bitkit-mainnet-release-{versionCode}-{abi}.apk` +- AAB: `app/build/outputs/bitkit/mainnetRelease/bitkit-mainnet-release-{versionCode}.aab` - Native debug symbols: `app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols-{versionCode}.zip` The native debug symbols archive must come from the same `just release` build as the APK/AAB being published. Keep the build-numbered filename, e.g. `native-debug-symbols-182.zip`, so it matches the APK/AAB build number. Native Rust dependencies publish stripped release AARs for app size and separate `native-debug-symbols` classifier artifacts for crash symbolication; `just release` merges those upstream symbol artifacts into the final archive and refuses placeholder symbols from stripped packaged `.so` files. @@ -209,7 +209,7 @@ For GitHub releases, attach `native-debug-symbols-{versionCode}.zip` alongside t #### Android App Bundle (AAB) -`just release` builds both the mainnet APK and Play Store AAB. AAB is generated in `app/build/outputs/bundle/mainnetRelease/`. +`just release` builds both the mainnet APK and Play Store AAB. AGP writes the AAB to `app/build/outputs/bundle/mainnetRelease/`, and the release-named copy lands in `app/build/outputs/bitkit/mainnetRelease/`. ### Build for E2E Testing diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29aed331ff..cc368f351c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,20 +1,24 @@ -import com.android.build.gradle.internal.api.BaseVariantOutputImpl -import com.android.build.gradle.internal.tasks.FinalizeBundleTask +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.BuildConfigField +import com.android.build.api.variant.BuiltArtifactsLoader +import com.android.build.api.variant.FilterConfiguration import io.gitlab.arturbosch.detekt.Detekt import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.provider.Provider import org.gradle.api.tasks.Sync import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.gradle.work.DisableCachingByDefault import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.FileInputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.stability.analyzer) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.hilt.android) @@ -50,10 +54,16 @@ val localProperties by lazy { } } -fun localProp(key: String): String? { - return System.getenv(key) - ?: providers.gradleProperty(key).orNull - ?: localProperties.getProperty(key) +fun localProp(key: String): Provider { + return providers.environmentVariable(key) + .orElse(providers.gradleProperty(key)) + .orElse(provider { localProperties.getProperty(key) }) +} + +fun envFlag(key: String, default: Boolean): Provider { + return providers.environmentVariable(key) + .map { it.toBoolean().toString() } + .orElse(default.toString()) } // Android resource qualifier format for androidResources.localeFilters @@ -64,11 +74,15 @@ val androidLocales = listOf( val bcp47Locales = listOf( "en", "ar", "es-419", "ca", "cs", "de", "el", "es", "es-ES", "fr", "it", "nl", "pl", "pt", "pt-BR", "ru" ) -val e2eBackendEnv = System.getenv("E2E_BACKEND") ?: "local" -val e2eHomegateUrlEnv = System.getenv("E2E_HOMEGATE_URL") ?: "http://127.0.0.1:6288" -val trezorBridgeEnv = localProp("TREZOR_BRIDGE")?.toBoolean()?.toString() ?: "false" -val trezorBridgeUrlEnv = localProp("TREZOR_BRIDGE_URL") ?: "http://10.0.2.2:21325" -val requestedNdkVersion = System.getenv("NDK_VERSION")?.takeIf { it.isNotBlank() } +val e2eEnv = envFlag("E2E", default = false) +val e2eBackendEnv = providers.environmentVariable("E2E_BACKEND").orElse("local") +val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("http://127.0.0.1:6288") +val geoEnv = envFlag("GEO", default = true) +val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) +val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") +val trezorBridgeUrlEnv = localProp("TREZOR_BRIDGE_URL").orElse("http://10.0.2.2:21325") +val networkPerFlavor = mapOf("dev" to "REGTEST", "mainnet" to "BITCOIN", "tnet" to "TESTNET") +val requestedNdkVersion = providers.environmentVariable("NDK_VERSION").orNull?.takeIf { it.isNotBlank() } val androidTestAnnotationPackage = "to.bitkit.test.annotations" val androidTestTaskPrefix = "connectedDevDebug" val androidTestTaskSuffix = "AndroidTest" @@ -178,14 +192,6 @@ android { vectorDrawables { useSupportLibrary = true } - buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false") - buildConfigField("String", "E2E_BACKEND", "\"$e2eBackendEnv\"") - buildConfigField("String", "E2E_HOMEGATE_URL", "\"$e2eHomegateUrlEnv\"") - buildConfigField("boolean", "TREZOR_BRIDGE", trezorBridgeEnv) - buildConfigField("String", "TREZOR_BRIDGE_URL", "\"$trezorBridgeUrlEnv\"") - buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true") - buildConfigField("boolean", "FEATURE_PAYKIT_UI_DISABLED", System.getenv("PAYKIT_UI_DISABLED")?.toBoolean()?.toString() ?: "false") - buildConfigField("String", "LOCALES", "\"${bcp47Locales.joinToString(",")}\"") } flavorDimensions += "network" @@ -193,7 +199,6 @@ android { create("dev") { dimension = "network" applicationIdSuffix = ".dev" - buildConfigField("String", "NETWORK", "\"REGTEST\"") resValue("string", "app_name", "Bitkit Regtest") manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_regtest" manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_regtest_round" @@ -201,7 +206,6 @@ android { create("mainnet") { dimension = "network" applicationIdSuffix = "" - buildConfigField("String", "NETWORK", "\"BITCOIN\"") resValue("string", "app_name", "Bitkit") manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_orange" manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_orange_round" @@ -209,7 +213,6 @@ android { create("tnet") { dimension = "network" applicationIdSuffix = ".tnet" - buildConfigField("String", "NETWORK", "\"TESTNET\"") resValue("string", "app_name", "Bitkit Testnet") manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_testnet" manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_testnet_round" @@ -260,19 +263,10 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_11 - freeCompilerArgs.addAll( - listOf( - "-XXLanguage:+PropertyParamAnnotationDefaultTargetMode", - ) - ) - } - } buildFeatures { buildConfig = true compose = true + resValues = true } androidResources { @Suppress("UnstableApiUsage") @@ -305,32 +299,163 @@ android { lint { abortOnError = false } - applicationVariants.all { - val variant = this - val bitkit = "bitkit" - val flavorName = variant.flavorName - val variantName = variant.buildType.name - val versionCode = defaultConfig.versionCode - outputs - .map { it as BaseVariantOutputImpl } - .forEach { output -> - val abi = output.getFilter("ABI") ?: "universal" - val apkName = "$bitkit-$flavorName-$variantName-$versionCode-$abi.apk" - output.outputFileName = apkName - } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + freeCompilerArgs.addAll( + listOf( + "-XXLanguage:+PropertyParamAnnotationDefaultTargetMode", + ) + ) + } +} + +androidComponents { + onVariants { variant -> + val buildConfigFields = requireNotNull(variant.buildConfigFields) { + "buildFeatures.buildConfig must stay enabled for '${variant.name}'." + } + buildConfigFields.put("E2E", e2eEnv.booleanField()) + buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) + buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) + buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) + buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) + buildConfigFields.put("GEO", geoEnv.booleanField()) + buildConfigFields.put("FEATURE_PAYKIT_UI_DISABLED", paykitUiDisabledEnv.booleanField()) + buildConfigFields.put("LOCALES", provider { bcp47Locales.joinToString(",") }.stringField()) + buildConfigFields.put( + "NETWORK", + provider { + requireNotNull(networkPerFlavor[variant.flavorName]) { + "Unmapped network flavor '${variant.flavorName}'." + } + }.stringField(), + ) + + val variantSuffix = variant.name.replaceFirstChar { it.uppercase() } + val outputDir = layout.buildDirectory.dir("outputs/bitkit/${variant.name}") + val mainOutput = variant.outputs.firstOrNull { it.filters.isEmpty() } ?: variant.outputs.first() + + val collectApks = tasks.register("collectBitkitApks$variantSuffix") { + group = "build" + description = "Publishes deterministically named copies of the ${variant.name} APKs." + outputDirectory.set(outputDir) + flavorName.set(variant.flavorName.orEmpty()) + buildTypeName.set(variant.buildType.orEmpty()) + fallbackVersionCode.set(mainOutput.versionCode) + builtArtifactsLoader.set(variant.artifacts.getBuiltArtifactsLoader()) + } + variant.artifacts.use(collectApks) + .wiredWith { it.apkDirectory } + .toListenTo(SingleArtifact.APK) + + val collectBundle = tasks.register("collectBitkitBundle$variantSuffix") { + group = "build" + description = "Publishes a deterministically named copy of the ${variant.name} AAB." + flavorName.set(variant.flavorName.orEmpty()) + buildTypeName.set(variant.buildType.orEmpty()) + versionCode.set(mainOutput.versionCode) + outputFile.set( + outputDir.zip(mainOutput.versionCode) { dir, code -> + dir.file("bitkit-${variant.flavorName.orEmpty()}-${variant.buildType.orEmpty()}-$code.aab") + } + ) + } + variant.artifacts.use(collectBundle) + .wiredWith { it.bundleFile } + .toListenTo(SingleArtifact.BUNDLE) + } +} + +fun Provider.stringField(): Provider> = + map { BuildConfigField(type = "String", value = "\"$it\"", comment = null) } - // Rename AAB bundle - tasks.named( - "sign${variant.name.replaceFirstChar { it.uppercase() }}Bundle", - FinalizeBundleTask::class.java, - ) { - val aabName = "$bitkit-$flavorName-$variantName-$versionCode.aab" - val outputDir = finalBundleFile.asFile.get().parentFile - finalBundleFile.set(File(outputDir, aabName)) +fun Provider.booleanField(): Provider> = + map { BuildConfigField(type = "boolean", value = it, comment = null) } + +@DisableCachingByDefault(because = "Copies existing artifacts, cheaper to redo than to cache") +abstract class CollectBitkitApks : DefaultTask() { + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val apkDirectory: DirectoryProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @get:Input + abstract val flavorName: Property + + @get:Input + abstract val buildTypeName: Property + + @get:Input + abstract val fallbackVersionCode: Property + + @get:Internal + abstract val builtArtifactsLoader: Property + + @TaskAction + fun collect() { + val target = outputDirectory.get().asFile + target.listFiles { file -> file.extension == "apk" }?.forEach { it.delete() } + target.mkdirs() + + val builtArtifacts = requireNotNull(builtArtifactsLoader.get().load(apkDirectory.get())) { + "Unable to load APK metadata from '${apkDirectory.get().asFile}'." + } + + builtArtifacts.elements.forEach { artifact -> + val abi = artifact.filters + .firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI } + ?.identifier + ?: "universal" + val versionCode = artifact.versionCode ?: fallbackVersionCode.get() + val name = "bitkit-${flavorName.get()}-${buildTypeName.get()}-$versionCode-$abi.apk" + + Files.copy( + File(artifact.outputFile).toPath(), + target.resolve(name).toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) } } } +@DisableCachingByDefault(because = "Copies an existing artifact, cheaper to redo than to cache") +abstract class CollectBitkitBundle : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val bundleFile: RegularFileProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @get:Input + abstract val flavorName: Property + + @get:Input + abstract val buildTypeName: Property + + @get:Input + abstract val versionCode: Property + + @TaskAction + fun collect() { + val target = outputFile.get().asFile + target.parentFile.mkdirs() + + Files.copy( + bundleFile.get().asFile.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } +} + composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } @@ -453,12 +578,12 @@ dependencies { ksp(libs.room.compiler) testImplementation(libs.room.testing) // Test + Debug - androidTestImplementation(kotlin("test")) + androidTestImplementation(libs.test.kotlin) androidTestImplementation(libs.test.core) androidTestImplementation(libs.test.coroutines) androidTestImplementation(libs.test.espresso.core) androidTestImplementation(libs.test.junit.ext) - testImplementation(kotlin("test")) + testImplementation(libs.test.kotlin) testImplementation(libs.test.core) testImplementation(libs.test.coroutines) testImplementation(libs.test.junit) diff --git a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt new file mode 100644 index 0000000000..a443203ae1 --- /dev/null +++ b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt @@ -0,0 +1,135 @@ +package to.bitkit.build + +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BuildOutputContractTest { + + private companion object { + val PARALLELISM_PROPERTIES = listOf( + "org.gradle.daemon=true", + "org.gradle.parallel=true", + "org.gradle.tooling.parallel=true", + "org.gradle.caching=true", + "org.gradle.configuration-cache=true", + ) + val ARTIFACT_CONSUMERS = listOf( + "Justfile", + ".github/workflows/e2e.yml", + ".github/workflows/e2e_migration.yml", + ".github/workflows/release.yml", + ".github/workflows/release-internal.yml", + ".agents/commands/release.md", + ) + } + + private val repoRoot = generateSequence( + Path(requireNotNull(System.getProperty("user.dir")) { "user.dir is required" }), + ) { it.parent } + .first { it.resolve("gradle/libs.versions.toml").exists() } + + @Test + fun `build logic uses no internal or legacy AGP variant APIs`() { + val buildFile = repoRoot.resolve("app/build.gradle.kts").readText() + + assertFalse( + buildFile.contains("com.android.build.gradle.internal"), + "Build logic must not reference internal AGP APIs, they are removed in AGP 9.", + ) + assertFalse( + buildFile.contains("applicationVariants"), + "Build logic must not use the legacy variant API, it is removed in AGP 9.", + ) + assertFalse( + buildFile.contains("org.jetbrains.kotlin.android"), + "Built-in Kotlin replaces the kotlin-android plugin in AGP 9.", + ) + } + + @Test + fun `artifacts are collected through public AGP artifact APIs`() { + val buildFile = repoRoot.resolve("app/build.gradle.kts").readText() + + assertTrue( + buildFile.contains("toListenTo(SingleArtifact.APK)"), + "APK copies must be collected by listening to the public APK artifact.", + ) + assertTrue( + buildFile.contains("toListenTo(SingleArtifact.BUNDLE)"), + "AAB copies must be collected by listening to the public BUNDLE artifact.", + ) + assertTrue( + buildFile.contains("BuiltArtifactsLoader"), + "APK metadata must be read through the public BuiltArtifactsLoader.", + ) + assertTrue( + buildFile.contains("""layout.buildDirectory.dir("outputs/bitkit/${'$'}{variant.name}")"""), + "Collected artifacts must be published under outputs/bitkit/.", + ) + } + + @Test + fun `generated BuildConfig fields are declared per variant`() { + val buildFile = repoRoot.resolve("app/build.gradle.kts").readText() + val fields = listOf( + "E2E", + "E2E_BACKEND", + "E2E_HOMEGATE_URL", + "TREZOR_BRIDGE", + "TREZOR_BRIDGE_URL", + "GEO", + "FEATURE_PAYKIT_UI_DISABLED", + "LOCALES", + "NETWORK", + ) + + assertFalse( + buildFile.contains("buildConfigField("), + "BuildConfig fields must come from androidComponents.onVariants, not the legacy DSL.", + ) + fields.forEach { field -> + assertTrue( + Regex("""buildConfigFields\.put\(\s*"$field"""").containsMatchIn(buildFile), + "BuildConfig field '$field' must be preserved on the variant API.", + ) + } + } + + @Test + fun `parallelism properties are declared exactly once`() { + val properties = repoRoot.resolve("gradle.properties").readText().lines().map { it.trim() } + + PARALLELISM_PROPERTIES.forEach { property -> + assertEquals( + 1, + properties.count { it == property }, + "'$property' must be declared exactly once.", + ) + } + } + + @Test + fun `artifact consumers read the collected outputs`() { + ARTIFACT_CONSUMERS.forEach { consumer -> + val content = repoRoot.resolve(consumer).readText() + + assertTrue( + content.contains("app/build/outputs/bitkit/"), + "'$consumer' must consume the collected artifacts.", + ) + assertFalse( + content.contains("app/build/outputs/apk/mainnet/release"), + "'$consumer' must not read release APKs from the AGP-native output directory.", + ) + assertFalse( + content.contains("app/build/outputs/bundle/mainnetRelease/bitkit"), + "'$consumer' must not read renamed AABs from the AGP-native output directory.", + ) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 6b4fab0dac..db2d4ea7bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.compose.stability.analyzer) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.hilt.android) apply false // https://github.com/google/dagger/releases/ - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false // https://github.com/google/ksp/releases alias(libs.plugins.room) apply false diff --git a/gradle.properties b/gradle.properties index b7e64b3d75..0bbd672c22 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,10 +7,6 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn @@ -21,9 +17,10 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.nonFinalResIds=false +# Task parallelism, plus Tooling API parallelism required explicitly since Gradle 9.4 org.gradle.daemon=true org.gradle.parallel=true +org.gradle.tooling.parallel=true org.gradle.caching=true -org.gradle.configuration.cache=true +org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e9d283eca..277a81ddb9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -agp = "8.13.2" +agp = "9.3.0" camera = "1.5.2" coil = "3.2.0" detekt = "1.23.8" glance = "1.2.0-rc01" -hilt = "2.57.2" +hilt = "2.60.1" hiltAndroidx = "1.3.0" kotlin = "2.3.21" ktor = "3.3.3" @@ -84,6 +84,7 @@ test-core = { module = "androidx.test:core", version = "1.7.0" } test-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test" } test-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" } test-junit = { group = "junit", name = "junit", version = "4.13.2" } +test-kotlin = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } test-junit-ext = { module = "androidx.test.ext:junit", version = "1.3.0" } test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.2.2" } test-robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } @@ -105,7 +106,6 @@ compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analy detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } google-services = { id = "com.google.gms.google-services", version = "4.4.4" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version = "2.3.2" } +ksp = { id = "com.google.devtools.ksp", version = "2.3.10" } room = { id = "androidx.room", version.ref = "room" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ac070f9362..309256375d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Mar 21 13:35:39 GMT 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 8a2421da859082ba047823365408fb434449f9e7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 2 Aug 2026 01:06:34 +0200 Subject: [PATCH 2/2] fix: complete agp build wiring --- app/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc368f351c..44595d6f54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -446,6 +446,9 @@ abstract class CollectBitkitBundle : DefaultTask() { @TaskAction fun collect() { val target = outputFile.get().asFile + target.parentFile + .listFiles { file -> file.extension == "aab" } + ?.forEach { Files.delete(it.toPath()) } target.parentFile.mkdirs() Files.copy( @@ -555,7 +558,9 @@ dependencies { ksp(libs.hilt.android.compiler) ksp(libs.hilt.compiler) androidTestImplementation(libs.hilt.android.testing) // instrumented tests + kspAndroidTest(libs.hilt.android.compiler) testImplementation(libs.hilt.android.testing) // robolectric tests + kspTest(libs.hilt.android.compiler) // WorkManager implementation(libs.hilt.work) implementation(libs.work.runtime.ktx)