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
+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)
}
}
@@ -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
}
}
@@ -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")
}
}
@@ -1,5 +1,7 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.DefaultTheme
@@ -30,6 +32,9 @@ else
val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND
// Country of the Google Play account, only set in the google flavor of the Android app
val androidPlayStoreCountry: MutableState<String?> = mutableStateOf(null)
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if (size > capacity) removeFirstOrNull()
@@ -29,7 +29,11 @@ interface PlatformInterface {
fun androidRestartNetworkObserver() {}
fun androidCreateActiveCallState(): Closeable = Closeable { }
fun androidIsXiaomiDevice(): Boolean = false
// Requests the Google Play account country into [androidPlayStoreCountry]
fun androidLoadPlayStoreCountry() {}
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() {}
@@ -183,6 +183,8 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
val showWhatsNew = shouldShowWhatsNew(chatModel)
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
if (showWhatsNew || showUpdatedConditions) {
// Requested here, so that the country is known by the time the modal opens
platform.androidLoadPlayStoreCountry()
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
}
@@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.*
@@ -916,6 +917,11 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v7.0",
post = null,
features = listOf(
// VersionFeature.FeatureView(
// icon = null,
// titleId = MR.strings.v7_0_invest,
// view = { _ -> InvestInSimpleXChatView() }
// ),
VersionFeature.FeatureDescription(
icon = MR.images.ic_alternate_email,
titleId = MR.strings.v7_0_simplex_names,
@@ -950,6 +956,58 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
return v != lastVersion
}
// private const val WEFUNDER_URL = "https://wefunder.com/simplexchat"
//
// @Composable
// private fun InvestInSimpleXChatView() {
// if (platform.androidIsPlayStoreBuild) {
// LaunchedEffect(Unit) { if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() }
// if (androidPlayStoreCountry.value != "US") return
// }
// val uriHandler = LocalUriHandler.current
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) {
// Column(modifier = Modifier.weight(1f)) {
// Row(
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier.padding(bottom = 4.dp)
// ) {
// Icon(painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_invest), tint = MaterialTheme.colors.secondary)
// Text(
// generalGetString(MR.strings.v7_0_invest),
// maxLines = 2,
// overflow = TextOverflow.Ellipsis,
// style = MaterialTheme.typography.h4,
// fontWeight = FontWeight.Medium,
// modifier = Modifier.padding(bottom = 6.dp)
// )
// }
// Text(generalGetString(MR.strings.v7_0_invest_descr), fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp))
// Row(
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier
// .clickable(
// interactionSource = remember { MutableInteractionSource() },
// indication = null
// ) {
// uriHandler.openExternalLink(WEFUNDER_URL)
// }
// ) {
// Text(stringResource(MR.strings.v7_0_invest_learn_more), color = MaterialTheme.colors.primary, fontSize = 15.sp)
// Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.v7_0_invest_learn_more), tint = MaterialTheme.colors.primary)
// }
// }
// if (BuildConfigCommon.SIMPLEX_ASSETS) {
// Image(
// painterResource(if (isInDarkTheme()) MR.images.own_stake_light else MR.images.own_stake),
// contentDescription = null,
// modifier = Modifier.width(80.dp)
// )
// }
// }
// }
@Composable
fun CreateUpdateAddressShortLinkView(modalManager: ModalManager) {
val clipboard = LocalClipboardManager.current
@@ -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) {
@@ -2736,6 +2736,9 @@
<string name="v6_5_safe_web_links_descr">- opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking.</string>
<string name="v6_5_non_profit_governance">Non-profit governance</string>
<string name="v6_5_non_profit_governance_descr">To make SimpleX Network last.</string>
<!-- <string name="v7_0_invest">Invest in SimpleX Chat</string> -->
<!-- <string name="v7_0_invest_descr">Equity crowdfunding launched!</string> -->
<!-- <string name="v7_0_invest_learn_more">Learn more on Wefunder</string> -->
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

-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
+2
View File
@@ -370,6 +370,8 @@ 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` |
| `androidLoadPlayStoreCountry()` | no-op | Request the Play account country (google flavor only) |
| `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` |
| `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog |
| `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+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 —