android: add google and foss build flavors (#7328)

* android: add google and foss build flavors

* android: get the Google Play account country in the google flavor

* android, desktop: update whats new

* temp: comment out wefunder link
This commit is contained in:
sh
2026-08-12 15:28:59 +01:00
committed by GitHub
parent 486c2abf26
commit 0a61b0ddea
24 changed files with 235 additions and 70 deletions
+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)
}
}
@@ -0,0 +1,4 @@
package chat.simplex.app
// Play Billing is only in the google flavor, so the Play country stays unknown here
fun loadPlayStoreCountry() {}
@@ -0,0 +1,31 @@
package chat.simplex.app
import chat.simplex.common.platform.androidAppContext
import chat.simplex.common.platform.androidPlayStoreCountry
import com.android.billingclient.api.*
// Requests the country of the Google Play account into [androidPlayStoreCountry].
// It stays null when Play is unavailable or the user is not signed in.
fun loadPlayStoreCountry() {
val client = BillingClient.newBuilder(androidAppContext)
.setListener { _, _ -> }
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
client.endConnection()
return
}
client.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build()) { configResult, config ->
if (configResult.responseCode == BillingClient.BillingResponseCode.OK) {
androidPlayStoreCountry.value = config?.countryCode
}
client.endConnection()
}
}
// The connection is only used for this one request, it is not retried
override fun onBillingServiceDisconnected() = client.endConnection()
})
}
@@ -341,6 +341,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidIsXiaomiDevice(): Boolean = setOf("xiaomi", "redmi", "poco").contains(Build.BRAND.lowercase())
override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry()
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
@@ -370,6 +372,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
}
}