android: add google and foss build flavors

This commit is contained in:
shum
2026-08-01 15:37:07 +00:00
parent 61012d208e
commit 97506da99f
12 changed files with 119 additions and 70 deletions
+13 -5
View File
@@ -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
+87 -51
View File
@@ -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)
}
}
@@ -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
}
}
@@ -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")
}
}
@@ -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() {}
@@ -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) {
-2
View File
@@ -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
+1
View File
@@ -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) |
+4 -3
View File
@@ -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
+5 -2
View File
@@ -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"
+3 -3
View File
@@ -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"
@@ -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 —