From 401e19773a1add909bd9c1aecde8ce63dda69f5d Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 1 Aug 2026 15:35:16 +0000 Subject: [PATCH] android: add google and foss build flavors --- apps/multiplatform/README.md | 18 ++- apps/multiplatform/android/build.gradle.kts | 138 +++++++++++------- .../main/java/chat/simplex/app/SimplexApp.kt | 2 + apps/multiplatform/common/build.gradle.kts | 1 - .../chat/simplex/common/platform/Platform.kt | 2 + .../common/views/usersettings/SettingsView.kt | 3 +- apps/multiplatform/gradle.properties | 2 - apps/multiplatform/spec/architecture.md | 1 + docs/contributing/PROJECT.md | 7 +- scripts/android/build-android-bundle.sh | 7 +- scripts/android/build-android.sh | 6 +- .../simplex-chat-reproduce-builds-android.sh | 2 +- 12 files changed, 119 insertions(+), 70 deletions(-) diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index eef1048ada..54e17d4c4e 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -9,11 +9,19 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ## Build Commands ```bash -# Android debug APK -./gradlew assembleDebug +# Android debug APK, assembleGoogleDebug builds the flavor with the Play Billing dependency +./gradlew assembleFossDebug -# Android release APK -./gradlew assembleRelease +# Android release APK, distributed via F-Droid and GitHub +./gradlew assembleFossRelease + +# Android app bundle, distributed via Google Play, includes Play Billing +./gradlew bundleGoogleRelease + +# Always name the flavor for releases. The aggregate tasks (build, assemble, assembleRelease, +# bundle, bundleRelease) fail on purpose: they would package a release APK with Play Billing, +# or an app bundle without it. +# The fdroiddata recipe defaults to assembleRelease and must be changed to assembleFossRelease. # Desktop distribution (current OS) ./gradlew :desktop:packageDistributionForCurrentOS @@ -22,7 +30,7 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ./gradlew desktopTest # Run Android instrumented tests (requires connected device/emulator) -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest # Build native libraries for all platforms ./gradlew common:cmakeBuild -PcrossCompile diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 5255319194..419fef90b9 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -35,6 +35,21 @@ android { manifestPlaceholders["extract_native_libs"] = rootProject.extra["compression.level"] as Int != 0 } + // `google` is distributed via Google Play as an app bundle and includes Play Billing. + // `foss` is distributed via F-Droid and as APKs on GitHub, without Play dependencies. + flavorDimensions += "store" + productFlavors { + create("google") { + dimension = "store" + buildConfigField("boolean", "PLAY_STORE", "true") + } + create("foss") { + dimension = "store" + isDefault = true + buildConfigField("boolean", "PLAY_STORE", "false") + } + } + buildTypes { debug { applicationIdSuffix = rootProject.extra["application_id.suffix"] as String @@ -128,8 +143,28 @@ android { } } +// The graph is checked rather than the requested task, because every aggregate task +// (assemble, assembleRelease, build, bundle, ...) packages these variants too. +val projectPath = project.path +val apkTasks = setOf("packageFossDebug", "packageGoogleDebug", "packageFossRelease", "packageGoogleRelease") +val apkTaskPaths = apkTasks.map { "$projectPath:$it" }.toSet() +val bundleTaskPaths = apkTaskPaths.map { it + "Bundle" }.toSet() +gradle.taskGraph.whenReady { + if (hasTask("$projectPath:packageGoogleRelease")) { + throw GradleException("A release apk must not include Play Billing, use assembleFossRelease or bundleGoogleRelease") + } + if (hasTask("$projectPath:packageFossReleaseBundle")) { + throw GradleException("An app bundle must include Play Billing, use bundleGoogleRelease or assembleFossRelease") + } + // `isBundle` above is derived from the whole invocation, so a bundle in it disables abi splits + if (apkTaskPaths.any { hasTask(it) } && bundleTaskPaths.any { hasTask(it) }) { + throw GradleException("Build the apks and the bundle in separate invocations, the bundle disables abi splits") + } +} + dependencies { implementation(project(":common")) + "googleImplementation"("com.android.billingclient:billing:9.1.0") implementation("androidx.core:core-ktx:1.13.1") //implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}") //implementation("androidx.compose.material:material:$compose_version") @@ -160,58 +195,61 @@ dependencies { tasks { val compressApk by creating { doLast { - val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null - val buildType: String = if (isRelease) "release" else "debug" val javaHome = System.getProperties()["java.home"] ?: org.gradle.internal.jvm.Jvm.current().javaHome val sdkDir = android.sdkDirectory.absolutePath - val keyAlias: String - val keyPassword: String - val storeFile: String - val storePassword: String - if (project.properties["android.injected.signing.key.alias"] != null) { - keyAlias = project.properties["android.injected.signing.key.alias"] as String - keyPassword = project.properties["android.injected.signing.key.password"] as String - storeFile = project.properties["android.injected.signing.store.file"] as String - storePassword = project.properties["android.injected.signing.store.password"] as String - } else { - try { - val gradleConfig = android.signingConfigs.getByName(buildType) - keyAlias = gradleConfig.keyAlias!! - keyPassword = gradleConfig.keyPassword!! - storeFile = gradleConfig.storeFile!!.absolutePath - storePassword = gradleConfig.storePassword!! - } catch (e: UnknownDomainObjectException) { - // There is no signing config for current build type, can"t sign the apk - println("No signing configs for this build type: $buildType") - return@doLast + // A single invocation can package more than one variant, for example assembleDebug + gradle.taskGraph.allTasks.filter { it.path in apkTaskPaths }.forEach { packageTask -> + val variant = packageTask.name.removePrefix("package") + val buildType: String = if (variant.endsWith("Release")) "release" else "debug" + val keyAlias: String + val keyPassword: String + val storeFile: String + val storePassword: String + if (project.properties["android.injected.signing.key.alias"] != null) { + keyAlias = project.properties["android.injected.signing.key.alias"] as String + keyPassword = project.properties["android.injected.signing.key.password"] as String + storeFile = project.properties["android.injected.signing.store.file"] as String + storePassword = project.properties["android.injected.signing.store.password"] as String + } else { + try { + val gradleConfig = android.signingConfigs.getByName(buildType) + keyAlias = gradleConfig.keyAlias!! + keyPassword = gradleConfig.keyPassword!! + storeFile = gradleConfig.storeFile!!.absolutePath + storePassword = gradleConfig.storePassword!! + } catch (e: UnknownDomainObjectException) { + // There is no signing config for current build type, can"t sign the apk + println("No signing configs for this build type: $buildType") + return@forEach + } + } + val outputDir = packageTask.outputs.files.files.last() + exec { + workingDir("../../scripts/android") + environment = mapOf( + "JAVA_HOME" to "$javaHome", + "PATH" to "${System.getenv("PATH")}:$javaHome/bin" + ) + commandLine = listOf( + "./compress-and-sign-apk.sh", + "${rootProject.extra["compression.level"]}", + "$outputDir", + sdkDir, + storeFile, + storePassword, + keyAlias, + keyPassword + ) } - } - lateinit var outputDir: File - named(if (isRelease) "packageRelease" else "packageDebug") { - outputDir = outputs.files.files.last() - } - exec { - workingDir("../../scripts/android") - environment = mapOf( - "JAVA_HOME" to "$javaHome", - "PATH" to "${System.getenv("PATH")}:$javaHome/bin" - ) - commandLine = listOf( - "./compress-and-sign-apk.sh", - "${rootProject.extra["compression.level"]}", - "$outputDir", - sdkDir, - storeFile, - storePassword, - keyAlias, - keyPassword - ) - } - if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { - File(outputDir, "android-release.apk").renameTo(File(outputDir, "simplex.apk")) - File(outputDir, "android-armeabi-v7a-release.apk").renameTo(File(outputDir, "simplex-armv7a.apk")) - File(outputDir, "android-arm64-v8a-release.apk").renameTo(File(outputDir, "simplex.apk")) + if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { + val flavor = variant.removeSuffix("Release").lowercase() + mapOf("arm64-v8a" to "simplex.apk", "armeabi-v7a" to "simplex-armv7a.apk").forEach { (abi, name) -> + if (!File(outputDir, "android-$flavor-$abi-release.apk").renameTo(File(outputDir, name))) { + logger.warn("No $abi apk to rename to $name") + } + } + } } // View all gradle properties set // project.properties.each { k, v -> println "$k -> $v" } @@ -221,9 +259,7 @@ tasks { // Don"t do anything if no compression is needed if (rootProject.extra["compression.level"] as Int != 0) { whenTaskAdded { - if (name == "packageDebug") { - finalizedBy(compressApk) - } else if (name == "packageRelease") { + if (name in apkTasks) { finalizedBy(compressApk) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 83767f90d7..2772922fac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -370,6 +370,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidCreateActiveCallState(): Closeable = ActiveCallState() override val androidApiLevel: Int get() = Build.VERSION.SDK_INT + + override val androidIsPlayStoreBuild: Boolean get() = BuildConfig.PLAY_STORE } } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 98845365fc..1f55b9c660 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -189,7 +189,6 @@ buildConfig { buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") - buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 448100bc17..dc6bdf9b2f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -30,6 +30,8 @@ interface PlatformInterface { fun androidCreateActiveCallState(): Closeable = Closeable { } fun androidIsXiaomiDevice(): Boolean = false val androidApiLevel: Int? get() = null + // The build distributed via Google Play, which has to follow its policies + val androidIsPlayStoreBuild: Boolean get() = false @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true @Composable fun desktopShowAppUpdateNotice() {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c8e040c592..96f36da6d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -22,7 +22,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* @@ -143,7 +142,7 @@ fun HelpAndSupportView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_support_project)) { - if (!BuildConfigCommon.ANDROID_BUNDLE) { + if (!platform.androidIsPlayStoreBuild) { ContributeItem(uriHandler) } if (appPlatform.isAndroid) { diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b4a7b4319a..3f3bbfa31b 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -27,8 +27,6 @@ kotlin.jvm.target=11 android.version_name=7.0 android.version_code=366 -android.bundle=false - desktop.version_name=7.0 desktop.version_code=155 diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index cfef4d06c2..2cfbb182d2 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -370,6 +370,7 @@ var platform: PlatformInterface = object : PlatformInterface {} | `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | | `androidIsXiaomiDevice()` | `false` | Check device brand | | `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidIsPlayStoreBuild` | `false` | `BuildConfig.PLAY_STORE` | | `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | | `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | | `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | diff --git a/docs/contributing/PROJECT.md b/docs/contributing/PROJECT.md index 3f7e6e0e54..40417a6539 100644 --- a/docs/contributing/PROJECT.md +++ b/docs/contributing/PROJECT.md @@ -68,14 +68,15 @@ The project uses several custom forks managed via `cabal.project`: ```bash cd apps/multiplatform -# Build Android debug APK -./gradlew assembleDebug +# Build Android debug APK; `foss` ships to F-Droid/GitHub, `google` adds Play Billing. +# The aggregate tasks fail by design, see apps/multiplatform/README.md +./gradlew assembleFossDebug # Build desktop ./gradlew :desktop:packageDistributionForCurrentOS # Run Android tests -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest ``` ### iOS diff --git a/scripts/android/build-android-bundle.sh b/scripts/android/build-android-bundle.sh index b784da2aad..972fb0ee72 100755 --- a/scripts/android/build-android-bundle.sh +++ b/scripts/android/build-android-bundle.sh @@ -23,5 +23,8 @@ unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/s curl -sSf "$libsup" -o "$tmp/libsupport.zip" unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a" -gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean build -cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/release/android-release-unsigned.apk" "$PWD/simplex-chat.apk" +# Build only the arch the libs were downloaded for +sed -i.bak 's/include(.*/include("arm64-v8a")/' "$tmp/simplex-chat/apps/multiplatform/android/build.gradle.kts" + +gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease +cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-arm64-v8a-release-unsigned.apk" "$PWD/simplex-chat.apk" diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 7edee9c304..267db9f243 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -101,7 +101,7 @@ build() { sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/multiplatform/android/src/main/AndroidManifest.xml" sed -i.bak 's/jniLibs.useLegacyPackaging =.*/jniLibs.useLegacyPackaging = true/' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak '/android {/a lint {abortOnError = false}' "$folder/apps/multiplatform/android/build.gradle.kts" - sed -i.bak '/tasks/Q' "$folder/apps/multiplatform/android/build.gradle.kts" + sed -i.bak '/^tasks {/Q' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do @@ -119,7 +119,7 @@ build() { arch_map "$arch" android_tmp_folder="${tmp}/android-${arch}" - android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/release/android-${android_arch}-release-unsigned.apk" + android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-${android_arch}-release-unsigned.apk" android_apk_output_final="simplex-chat-${android_arch}.apk" libs_folder="${folder}/apps/multiplatform/common/src/commonMain/cpp/android/libs" @@ -134,7 +134,7 @@ build() { # Build only one arch sed -i.bak "s/include(.*/include(\"${android_arch}\")/" "$folder/apps/multiplatform/android/build.gradle.kts" - gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleRelease + gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease mkdir -p "$android_tmp_folder" unzip -oqd "$android_tmp_folder" "$android_apk_output" diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index f8bb3224cc..4bd7262d17 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -118,7 +118,7 @@ check_apk() { verify_apk() { apk_name="$1" - # Release APKs are packaged by AGP (gradle :android:assembleRelease; AGP version is + # Release APKs are packaged by AGP (gradle :android:assembleFossRelease; AGP version is # gradle.plugin.version in apps/multiplatform/gradle.properties), which zero-pads ZIP # alignment. Do NOT add --pad-like-apksigner (standalone apksigner >= 35.0.0-rc1 uses # the 0xd935 extra-field padding) unless AGP is bumped to a packager that uses it —