From b60b24561d1d56566b7285d94eabcbc6d1685d19 Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:09:44 +0200 Subject: [PATCH 1/7] build: add tabmates.distribution property for Play/FOSS builds F-Droid refuses proprietary dependencies, so the app needs a second Android build without Google Play Core and Firebase. Product flavors cannot express it: AGP's KMP library plugin (KotlinMultiplatformAndroidLibraryExtension) declares no productFlavors or buildTypes and exposes exactly one Android variant, and both modules holding those dependencies use it. A Gradle property drives the split instead. Unknown values fail the build rather than falling back to PLAY, so a typo cannot ship Firebase in an artifact labelled FOSS. --- .../de/tabmates/convention/Distribution.kt | 68 +++++++++++++++++++ gradle.properties | 4 ++ 2 files changed, 72 insertions(+) create mode 100644 build-logic/convention/src/main/kotlin/de/tabmates/convention/Distribution.kt diff --git a/build-logic/convention/src/main/kotlin/de/tabmates/convention/Distribution.kt b/build-logic/convention/src/main/kotlin/de/tabmates/convention/Distribution.kt new file mode 100644 index 00000000..f9529e53 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/de/tabmates/convention/Distribution.kt @@ -0,0 +1,68 @@ +package de.tabmates.convention + +import org.gradle.api.Project + +/** Gradle property selecting the [Distribution]; see [Project.distribution]. */ +const val DISTRIBUTION_PROPERTY = "tabmates.distribution" + +/** + * Which app store this build is destined for. + * + * F-Droid refuses proprietary dependencies, so [FOSS] drops Google Play Core (the in-app update + * flow) and Firebase Cloud Messaging (push) entirely. That build simply has no push: the + * controller is a no-op, `POST_NOTIFICATIONS` is neither requested nor declared, and updates fall + * back to the store-redirect dialog every non-Play platform already uses. + */ +enum class Distribution { + /** Google Play. Firebase Cloud Messaging push + Play Core in-app updates. */ + PLAY, + + /** F-Droid and other FOSS channels. No Google dependencies of any kind. */ + FOSS, + ; + + /** Android source directory suffix, e.g. `androidPlayMain`. */ + internal val sourceSetName: String get() = "android${name.lowercase().replaceFirstChar(Char::uppercase)}Main" + + /** AGP product-flavor name, e.g. `play`. */ + val flavorName: String get() = name.lowercase() +} + +/** + * The distribution this invocation builds, from the [DISTRIBUTION_PROPERTY] Gradle property + * (`gradle.properties` supplies the `play` default; CI passes `-Ptabmates.distribution=foss`). + * + * A single property drives everything, because the two modules that carry the Google dependencies + * — `:composeApp` and `:features:notifications:data` — cannot use product flavors: AGP's KMP + * library plugin (`KotlinMultiplatformAndroidLibraryExtension`) declares no `productFlavors` / + * `buildTypes` and exposes exactly one Android variant. `:androidApp` derives its single flavor + * from this same property rather than declaring both, so the manifest overlay and the source-set + * swap below can never disagree about which build this is. + * + * An unrecognised value is a hard error, not a silent fall back to [Distribution.PLAY]: a typo + * would otherwise ship Firebase in an artifact labelled FOSS. + */ +val Project.distribution: Distribution + get() { + val raw = + providers.gradleProperty(DISTRIBUTION_PROPERTY).orNull + ?: error( + "Missing \"$DISTRIBUTION_PROPERTY\". It is declared in gradle.properties; do not remove it.", + ) + return Distribution.entries.firstOrNull { it.name.equals(raw.trim(), ignoreCase = true) } + ?: error( + "Unknown $DISTRIBUTION_PROPERTY \"$raw\". Expected one of " + + Distribution.entries.joinToString { it.flavorName } + ".", + ) + } + +/** True when this build must contain no proprietary Google dependencies. See [distribution]. */ +val Project.isFossDistribution: Boolean get() = distribution == Distribution.FOSS + +/** + * The Android source directory this distribution contributes, added to `androidMain` on top of the + * shared one. Holds the `actual` declarations that differ between distributions, so a build is + * always missing either both the Google dependency and its callers, or neither — never one of the + * two. Getting it wrong is a compile error (a missing or duplicate `actual`), never a silent leak. + */ +fun Project.androidDistributionSrcDir(): String = "src/${distribution.sourceSetName}/kotlin" diff --git a/gradle.properties b/gradle.properties index b6ee9b07..028523b9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,3 +31,7 @@ APP_VERSION=1.0.0 # have to be committed and would go stale on every dependency bump. Instead we add those SDKs to # iosApp.xcodeproj as ordinary remote Swift packages, as KMPNotifier's own iOS setup documents. kotlin.disableSwiftPMImport=true +# App store this build targets: `play` (Firebase push + Play Core in-app updates) or `foss` +# (F-Droid; no Google dependencies, no push). Release CI passes -Ptabmates.distribution=foss for +# the F-Droid APK. See build-logic/.../de/tabmates/convention/Distribution.kt. +tabmates.distribution=play \ No newline at end of file From a5a5033a6394c9a88e9cc95bb1d28380bb737c21 Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:09:53 +0200 Subject: [PATCH 2/7] build(composeApp): keep the Play in-app update flow out of FOSS builds Google Play Core is proprietary. Move the Play AppUpdateHandler actual into androidPlayMain and add an androidFossMain one that delegates to the existing DefaultUpdateHandler, the same store-redirect dialog iOS and desktop use. The source directory and the dependency are selected together, so a build can never have the callers without the library. Either half missing is a compile error (a missing or duplicate actual), never a silent leak. DefaultUpdateHandler gains an updateUrlOverride: the backend answers per platform, not per distribution, so platform=android always names the Play listing, which is the wrong destination for an F-Droid install. --- composeApp/build.gradle.kts | 27 +++++++++++++--- .../update/AppUpdateHandler.android.kt | 31 +++++++++++++++++++ .../update/AppUpdateHandler.android.kt | 0 .../composeapp/update/AppUpdateGate.kt | 13 ++++++-- 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/androidFossMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt rename composeApp/src/{androidMain => androidPlayMain}/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt (100%) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index aa410dde..c8bbe93e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,4 +1,6 @@ import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryExtension +import de.tabmates.convention.androidDistributionSrcDir +import de.tabmates.convention.isFossDistribution plugins { alias(libs.plugins.tabmates.convention.cmp.application) @@ -6,6 +8,12 @@ plugins { alias(libs.plugins.tabmates.convention.koin) } +// AGP's KMP library plugin has exactly one Android variant and no product flavors, so the +// Play/FOSS split is a source-directory swap driven by the `tabmates.distribution` property. +// See build-logic/.../de/tabmates/convention/Distribution.kt. +val fossDistribution = isFossDistribution +val distributionSrcDir = androidDistributionSrcDir() + kotlin { extensions.configure { androidResources { enable = true } @@ -46,11 +54,20 @@ kotlin { implementation(libs.ksafe) implementation(libs.jetbrains.material3.adaptive) } - androidMain.dependencies { - // Google Play in-app update flow (native, Play-installed devices only). - implementation(libs.androidx.activity.compose) - implementation(libs.play.app.update) - implementation(libs.play.app.update.ktx) + androidMain { + // Supplies the AppUpdateHandler `actual`: the Play Core flow, or the store-redirect + // dialog. The directory and the dependency below move together, so a build never has + // one without the other — that mismatch is a compile error, not a silent leak. + kotlin.srcDir(distributionSrcDir) + dependencies { + implementation(libs.androidx.activity.compose) + if (!fossDistribution) { + // Google Play in-app update flow (native, Play-installed devices only). + // Proprietary, so F-Droid builds drop it and fall back to DefaultUpdateHandler. + implementation(libs.play.app.update) + implementation(libs.play.app.update.ktx) + } + } } iosMain.dependencies { // Exposes kmpnotifier iOS extension functions to the Swift AppDelegate bridge diff --git a/composeApp/src/androidFossMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt b/composeApp/src/androidFossMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt new file mode 100644 index 00000000..6fbe4cce --- /dev/null +++ b/composeApp/src/androidFossMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt @@ -0,0 +1,31 @@ +package de.tabmates.composeapp.update + +import androidx.compose.runtime.Composable +import de.tabmates.features.appupdate.domain.AppUpdateStatus + +/** + * Where the FOSS build sends a user who needs to update. + * + * The backend answers `/api/app-version?platform=android` with the Play listing, which is the + * wrong destination for someone who installed from F-Droid, so this overrides it. The releases + * page is correct from the first FOSS build onwards; point it at the F-Droid listing + * (`https://f-droid.org/packages/de.tabmates.androidapp/`) once the app is actually published + * there. + */ +private const val FOSS_STORE_URL = "https://github.com/TabMates/app/releases/latest" + +/** + * FOSS (F-Droid) update handler. + * + * Google Play Core is proprietary and absent from this build, so there is no native in-app update + * flow — the shared store-redirect dialog handles it, exactly as on iOS and desktop. F-Droid + * clients update apps themselves; this dialog exists for the forced-update gate, where the user + * has to be told the build is no longer supported. + */ +@Composable +actual fun AppUpdateHandler( + status: AppUpdateStatus, + onDismiss: () -> Unit, +) { + DefaultUpdateHandler(status = status, onDismiss = onDismiss, updateUrlOverride = FOSS_STORE_URL) +} diff --git a/composeApp/src/androidMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt b/composeApp/src/androidPlayMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt rename to composeApp/src/androidPlayMain/kotlin/de/tabmates/composeapp/update/AppUpdateHandler.android.kt diff --git a/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/update/AppUpdateGate.kt b/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/update/AppUpdateGate.kt index 90bbc5a0..7c713fe7 100644 --- a/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/update/AppUpdateGate.kt +++ b/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/update/AppUpdateGate.kt @@ -72,11 +72,18 @@ expect fun AppUpdateHandler( onDismiss: () -> Unit, ) -/** Store-redirect fallback used by every non-Play platform (and by Android when Play is unavailable). */ +/** + * Store-redirect fallback used by every non-Play platform (and by Android when Play is unavailable). + * + * [updateUrlOverride] replaces the URL the backend supplied. The server answers per *platform*, + * not per distribution, so `platform=android` always names the Play listing — which is the wrong + * destination for a build that was not installed from Play. Only the FOSS Android handler sets it. + */ @Composable internal fun DefaultUpdateHandler( status: AppUpdateStatus, onDismiss: () -> Unit, + updateUrlOverride: String? = null, ) { val uriHandler = LocalUriHandler.current when (status) { @@ -85,7 +92,7 @@ internal fun DefaultUpdateHandler( UpdateDialog( forced = false, latestVersion = status.latestVersion, - onUpdate = { uriHandler.openUri(status.updateUrl) }, + onUpdate = { uriHandler.openUri(updateUrlOverride ?: status.updateUrl) }, onDismiss = onDismiss, ) @@ -93,7 +100,7 @@ internal fun DefaultUpdateHandler( UpdateDialog( forced = true, latestVersion = status.latestVersion, - onUpdate = { uriHandler.openUri(status.updateUrl) }, + onUpdate = { uriHandler.openUri(updateUrlOverride ?: status.updateUrl) }, onDismiss = onDismiss, ) } From 52caf86a7a2913d59a74ee75b01ab593ba4d58d3 Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:09:57 +0200 Subject: [PATCH 3/7] build(notifications): drop Firebase push from FOSS builds kmpnotifier-push-firebase pulls firebase-messaging and play-services, all proprietary. The FOSS distribution therefore has no push at all: its PlatformNotificationsModule binds NoOpPushNotificationController and UnsupportedNotificationPermissionController, both already in commonMain. UNSUPPORTED rather than the real permission state is the only correct answer there. POST_NOTIFICATIONS reaches the manifest solely through the firebase-messaging and kmpnotifier-core AAR manifests, so it is absent from this build, and reading an undeclared permission always reports denied -- which the real controller would surface as a banner the user cannot resolve. androidx.core.ktx is gated too: its only consumer, AndroidNotificationPermissionController, is now Play-only. --- features/notifications/data/build.gradle.kts | 27 ++++++++++++--- .../di/PlatformNotificationsModule.android.kt | 34 +++++++++++++++++++ ...AndroidNotificationPermissionController.kt | 0 .../data/MobilePushNotificationController.kt | 0 .../di/PlatformNotificationsModule.android.kt | 0 5 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 features/notifications/data/src/androidFossMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt rename features/notifications/data/src/{androidMain => androidPlayMain}/kotlin/de/tabmates/features/notifications/data/AndroidNotificationPermissionController.kt (100%) rename features/notifications/data/src/{androidMain => androidPlayMain}/kotlin/de/tabmates/features/notifications/data/MobilePushNotificationController.kt (100%) rename features/notifications/data/src/{androidMain => androidPlayMain}/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt (100%) diff --git a/features/notifications/data/build.gradle.kts b/features/notifications/data/build.gradle.kts index a98b0695..293a5eba 100644 --- a/features/notifications/data/build.gradle.kts +++ b/features/notifications/data/build.gradle.kts @@ -1,9 +1,18 @@ +import de.tabmates.convention.androidDistributionSrcDir +import de.tabmates.convention.isFossDistribution + plugins { alias(libs.plugins.tabmates.convention.kmp.library) alias(libs.plugins.tabmates.convention.buildkonfig) alias(libs.plugins.tabmates.convention.koin) } +// AGP's KMP library plugin has exactly one Android variant and no product flavors, so the +// Play/FOSS split is a source-directory swap driven by the `tabmates.distribution` property. +// See build-logic/.../de/tabmates/convention/Distribution.kt. +val fossDistribution = isFossDistribution +val distributionSrcDir = androidDistributionSrcDir() + kotlin { sourceSets { commonMain { @@ -28,12 +37,22 @@ kotlin { } androidMain { + // Supplies the PlatformNotificationsModule `actual`: FCM push, or the shared no-op + // controllers. The directory and the dependencies below move together, so a build + // never has one without the other — that mismatch is a compile error, not a leak. + kotlin.srcDir(distributionSrcDir) dependencies { implementation(libs.ktor.client.okhttp) - // Firebase Cloud Messaging via kmpnotifier-push-firebase (also pulls kmpnotifier-local + core). - implementation(libs.kmpnotifier.push.firebase) - // NotificationManagerCompat for the notification-permission check. - implementation(libs.androidx.core.ktx) + if (!fossDistribution) { + // Firebase Cloud Messaging via kmpnotifier-push-firebase (also pulls + // kmpnotifier-local + core). Proprietary, so the F-Droid build has no push + // at all; androidFossMain binds NoOpPushNotificationController instead. + implementation(libs.kmpnotifier.push.firebase) + // NotificationManagerCompat for the notification-permission check. Its only + // consumer, AndroidNotificationPermissionController, is Play-only: the FOSS + // build reports UNSUPPORTED because it never declares POST_NOTIFICATIONS. + implementation(libs.androidx.core.ktx) + } } } diff --git a/features/notifications/data/src/androidFossMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt b/features/notifications/data/src/androidFossMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt new file mode 100644 index 00000000..206a861f --- /dev/null +++ b/features/notifications/data/src/androidFossMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt @@ -0,0 +1,34 @@ +package de.tabmates.features.notifications.data.di + +import de.tabmates.features.notifications.data.NoOpPushNotificationController +import de.tabmates.features.notifications.data.UnsupportedNotificationPermissionController +import de.tabmates.features.notifications.domain.NotificationPermissionController +import de.tabmates.features.notifications.domain.PushNotificationController +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +/** + * FOSS (F-Droid) Android notifications wiring: there is none. + * + * Push runs on Firebase Cloud Messaging, which is proprietary and therefore absent from this + * build, and no local-notification source replaces it — so the controller is the shared no-op. + * + * The permission controller reports `UNSUPPORTED` rather than reading the real + * `POST_NOTIFICATIONS` state, which keeps the settings screen honest: with nothing to deliver, a + * permission toggle would control nothing. It is also the only correct answer here, because the + * permission is not in this flavor's merged manifest at all — it reaches the Play build solely + * through the `firebase-messaging` and `kmpnotifier-core` AAR manifests, and disappears with them. + * Reading an undeclared permission always reports "denied", which the real controller would show + * as a banner the user has no way to resolve. + */ +@Module +@Configuration +actual class PlatformNotificationsModule { + @Single + fun providePushNotificationController(): PushNotificationController = NoOpPushNotificationController() + + @Single + fun provideNotificationPermissionController(): NotificationPermissionController = + UnsupportedNotificationPermissionController() +} diff --git a/features/notifications/data/src/androidMain/kotlin/de/tabmates/features/notifications/data/AndroidNotificationPermissionController.kt b/features/notifications/data/src/androidPlayMain/kotlin/de/tabmates/features/notifications/data/AndroidNotificationPermissionController.kt similarity index 100% rename from features/notifications/data/src/androidMain/kotlin/de/tabmates/features/notifications/data/AndroidNotificationPermissionController.kt rename to features/notifications/data/src/androidPlayMain/kotlin/de/tabmates/features/notifications/data/AndroidNotificationPermissionController.kt diff --git a/features/notifications/data/src/androidMain/kotlin/de/tabmates/features/notifications/data/MobilePushNotificationController.kt b/features/notifications/data/src/androidPlayMain/kotlin/de/tabmates/features/notifications/data/MobilePushNotificationController.kt similarity index 100% rename from features/notifications/data/src/androidMain/kotlin/de/tabmates/features/notifications/data/MobilePushNotificationController.kt rename to features/notifications/data/src/androidPlayMain/kotlin/de/tabmates/features/notifications/data/MobilePushNotificationController.kt diff --git a/features/notifications/data/src/androidMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt b/features/notifications/data/src/androidPlayMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt similarity index 100% rename from features/notifications/data/src/androidMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt rename to features/notifications/data/src/androidPlayMain/kotlin/de/tabmates/features/notifications/data/di/PlatformNotificationsModule.android.kt From f7234efaeef2fa770dd909e68a699970eeafdf06 Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:10:09 +0200 Subject: [PATCH 4/7] build(androidApp): add the play/foss product flavor :androidApp is a real application module, so it can do what the KMP modules cannot: overlay a manifest and carry flavored Kotlin and resources. Only one flavor is created per invocation, derived from tabmates.distribution, so the overlay and the KMP source-set swap cannot disagree about which build this is. It also puts the flavor in the output path, making a forgotten -Ptabmates.distribution=foss visible instead of silent. POST_NOTIFICATIONS is no longer declared in the main manifest. firebase- messaging and kmpnotifier-core each declare it in their AAR manifest, so it merges into the Play build with the dependency (as WAKE_LOCK and c2dm.RECEIVE already did) and is simply absent from FOSS. The Firebase meta-data does need the src/play overlay: its values are app-specific, so no AAR can supply them. The permission prompt and notification channels move to src/play behind a seam with a no-op FOSS twin. Leaving them in src/main would not just be dead code: requesting an undeclared permission is denied instantly and shouldShowRequestPermissionRationale then returns false, so the gate would hit its "permanently denied" branch and show an unavoidable "open settings" dialog on every cold start, for a feature the build does not have. The FOSS release is left unsigned -- F-Droid uses its own key, applied outside this pipeline. The explicit null matters: the previous expression would otherwise sign it with the SDK's public debug key on an artifact still named -release. checkFossClasspath covers what the compiler cannot see. The source-set swap makes "dependency gone, callers left" a compile error; a dependency that returns to the classpath ungated compiles fine and ships anyway. It walks the resolved graph rather than the artifacts, because artifact resolution must pick one published variant per dependency and the KMP libraries publish several that tie without an artifactType. --- androidApp/build.gradle.kts | 120 ++++++++++++++- .../androidapp/PlatformNotifications.kt | 25 +++ androidApp/src/main/AndroidManifest.xml | 20 ++- .../de/tabmates/androidapp/MainActivity.kt | 124 +-------------- .../androidapp/TabMatesApplication.kt | 4 +- androidApp/src/main/res/values/strings.xml | 11 -- androidApp/src/play/AndroidManifest.xml | 27 ++++ .../androidapp/NotificationChannels.kt | 0 .../androidapp/PlatformNotifications.kt | 145 ++++++++++++++++++ .../{main => play}/res/values-de/strings.xml | 0 androidApp/src/play/res/values/strings.xml | 16 ++ 11 files changed, 346 insertions(+), 146 deletions(-) create mode 100644 androidApp/src/foss/kotlin/de/tabmates/androidapp/PlatformNotifications.kt create mode 100644 androidApp/src/play/AndroidManifest.xml rename androidApp/src/{main => play}/kotlin/de/tabmates/androidapp/NotificationChannels.kt (100%) create mode 100644 androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt rename androidApp/src/{main => play}/res/values-de/strings.xml (100%) create mode 100644 androidApp/src/play/res/values/strings.xml diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index e8957e41..72fa5742 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,8 +1,27 @@ +import com.android.build.api.variant.ApplicationAndroidComponentsExtension import de.tabmates.convention.appVersion +import de.tabmates.convention.distribution +import de.tabmates.convention.isFossDistribution +import org.gradle.api.artifacts.component.ComponentIdentifier +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedDependencyResult +import org.gradle.language.base.plugins.LifecycleBasePlugin plugins { alias(libs.plugins.tabmates.convention.android.application.compose) - alias(libs.plugins.google.services) + // Declared but not applied: `plugins { }` takes no conditionals, and this one is Play-only. + // `apply false` still resolves it onto the build classpath, so pluginManager can switch it on + // below. See the FOSS note there. + alias(libs.plugins.google.services) apply false +} + +val fossDistribution = isFossDistribution +val distributionFlavor = distribution.flavorName + +// Google Services is Play-only: the plugin fails the build when google-services.json is missing, +// and the FOSS flavor ships neither that file nor any Firebase dependency to configure. +if (!fossDistribution) { + pluginManager.apply(libs.plugins.google.services.get().pluginId) } // Derive a monotonically increasing versionCode from the version name (e.g. "1.2.3" -> 10203) @@ -31,6 +50,21 @@ android { versionName = appVersion } + // Exactly one flavor exists per invocation, derived from the same `tabmates.distribution` + // property the KMP modules read — so the manifest overlay and Kotlin here can never disagree + // with the source-set swap over there about which build this is. Declaring both flavors would + // reintroduce that possibility, and the KMP modules could not honour the second one anyway. + // + // It buys the two things a property alone cannot: a manifest overlay (src/play for the + // Firebase meta-data, whose app-specific values no AAR can supply) and flavored Kotlin/res + // source sets. It also puts the flavor in the output path, so a build that forgot + // `-Ptabmates.distribution=foss` is visible as `outputs/apk/play/release/` rather than + // shipping Firebase under a FOSS label. + flavorDimensions += "distribution" + productFlavors { + create(distributionFlavor) { dimension = "distribution" } + } + signingConfigs { create("release") { storeFile = file("keystore/upload_keystore.jks") @@ -42,7 +76,18 @@ android { buildTypes { release { - signingConfig = signingConfigs.getByName(if (runsCIReleaseBuild) "release" else "debug") + // The FOSS release is deliberately left unsigned — F-Droid builds are signed with a + // separate key, out of band from this pipeline. The explicit null matters: without it + // the `else` branch would quietly sign the FOSS release with the *debug* key, whose + // keystore ships with the Android SDK and is identical for every developer on earth. + // That artifact would still be called `-release`, F-Droid would reject it, and anyone + // who sideloaded it would be trusting a publicly known signing key. + signingConfig = + when { + fossDistribution -> null + runsCIReleaseBuild -> signingConfigs.getByName("release") + else -> signingConfigs.getByName("debug") + } } } @@ -82,3 +127,74 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.ext.junit) } + +// Proves the claim the F-Droid listing rests on: no proprietary code in the shipped artifact. +// +// The source-set swap already makes "dependency gone, callers left behind" a compile error. This +// covers the other direction, which the compiler cannot see: a dependency that returns to the +// classpath — re-added ungated, or pulled in transitively by some future module — and ships in +// the APK even though nothing calls it. +if (fossDistribution) { + val forbiddenGroups = + setOf( + // Play Core (in-app updates) and everything Firebase Cloud Messaging drags in. + "com.google.android.gms", + "com.google.firebase", + "com.google.android.play", + // kmpnotifier: -local and -core reach Android only via -push-firebase. + "io.github.mirzemehdi", + ) + // com.google.android.material is deliberately absent: Apache-2.0, and fine for F-Droid. + + // Reached through the variant API rather than `configurations.named(...)`: AGP creates the + // variant classpath configurations after this script is evaluated, so looking one up by name + // here fails outright. + extensions.configure { + onVariants(selector().withBuildType("release")) { variant -> + // Walks the resolved dependency graph rather than the resolved *artifacts*: artifact + // resolution has to pick one published variant per dependency, and the KMP libraries + // publish several (jar, android-res, android-symbol, ...) that tie unless the view + // names an artifactType. The graph needs no such choice, and identifies the same + // modules. + val offenders = + variant.runtimeConfiguration.incoming.resolutionResult.rootComponent.map { root -> + val seen = mutableSetOf() + val queue = ArrayDeque(listOf(root)) + val found = sortedSetOf() + while (queue.isNotEmpty()) { + val component = queue.removeFirst() + if (!seen.add(component.id)) continue + val id = component.id + if (id is ModuleComponentIdentifier && id.group in forbiddenGroups) { + found += "${id.group}:${id.module}:${id.version}" + } + component.dependencies + .filterIsInstance() + .forEach { queue.addLast(it.selected) } + } + found.toList() + } + + val checkFossClasspath = + tasks.register("checkFossClasspath") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Fails if a proprietary dependency reaches the FOSS release runtime classpath." + doLast { + val found = offenders.get() + if (found.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("Proprietary dependencies on the FOSS release runtime classpath:") + found.forEach { appendLine(" - $it") } + appendLine() + append("F-Droid rejects these. Gate them behind `if (!fossDistribution)`.") + }, + ) + } + } + } + + tasks.named("check") { dependsOn(checkFossClasspath) } + } + } +} diff --git a/androidApp/src/foss/kotlin/de/tabmates/androidapp/PlatformNotifications.kt b/androidApp/src/foss/kotlin/de/tabmates/androidapp/PlatformNotifications.kt new file mode 100644 index 00000000..8d583a60 --- /dev/null +++ b/androidApp/src/foss/kotlin/de/tabmates/androidapp/PlatformNotifications.kt @@ -0,0 +1,25 @@ +package de.tabmates.androidapp + +import android.app.Application +import androidx.compose.runtime.Composable + +/** + * FOSS (F-Droid) notification setup: there is none, and that is deliberate. + * + * This flavor has no Firebase Cloud Messaging and no local-notification source, so nothing ever + * posts a notification — leaving the channels registered would only add empty categories to the + * system settings screen. + * + * The permission gate is not merely unnecessary, it would be a bug. `POST_NOTIFICATIONS` reaches + * the merged manifest only through the `firebase-messaging` and `kmpnotifier-core` AAR manifests, + * which this flavor does not depend on. Requesting an undeclared permission is denied instantly + * and `shouldShowRequestPermissionRationale` then returns false, so the Play gate's logic would + * land on its "permanently denied" branch and show an unavoidable "open settings" dialog on every + * cold start — for a feature this build does not have. + * + * See the `play` source set for the real implementations. + */ +internal fun Application.installNotificationChannels() = Unit + +@Composable +internal fun NotificationPermissionGate() = Unit diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 6cef547b..2a65c66c 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -2,7 +2,12 @@ - + + - - - - - - - - + (null) - - private val requestNotificationPermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPrompt = - when { - granted -> null - - // Still askable -> show rationale and let the user retry. - shouldExplainNotifications() -> NotificationPermissionPrompt.RATIONALE - - // Permanently denied -> only the system settings screen can re-enable it. - else -> NotificationPermissionPrompt.SETTINGS - } - } - } - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) handleIntent(intent) - requestNotificationPermissionIfNeeded() setContent { App() - notificationPrompt?.let { prompt -> - MaterialTheme { - NotificationPermissionDialog( - prompt = prompt, - onConfirm = { - notificationPrompt = null - when (prompt) { - NotificationPermissionPrompt.RATIONALE -> { - if (Build.VERSION.SDK_INT >= - Build.VERSION_CODES.TIRAMISU - ) { - launchNotificationRequest() - } - } - - NotificationPermissionPrompt.SETTINGS -> { - openNotificationSettings() - } - } - }, - onDismiss = { notificationPrompt = null }, - ) - } - } + // Play flavor asks for POST_NOTIFICATIONS; the FOSS flavor has no notifications and + // supplies a no-op. See PlatformNotifications.kt in src/play and src/foss. + NotificationPermissionGate() } } @@ -98,60 +34,6 @@ class MainActivity : ComponentActivity() { val uri = intent.data?.toString() ?: return DeepLinkHandler.onDeepLink(uri) } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - val granted = - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED - if (granted) return - - // If the system flags a prior denial, explain before re-asking; otherwise ask directly. - if (shouldExplainNotifications()) { - notificationPrompt = NotificationPermissionPrompt.RATIONALE - } else { - launchNotificationRequest() - } - } - - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private fun shouldExplainNotifications(): Boolean = - shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) - - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private fun launchNotificationRequest() { - requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - private fun openNotificationSettings() { - startActivity( - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - }, - ) - } -} - -@Composable -private fun NotificationPermissionDialog( - prompt: NotificationPermissionPrompt, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - val confirmLabel = - when (prompt) { - NotificationPermissionPrompt.RATIONALE -> stringResource(R.string.notification_permission_allow) - NotificationPermissionPrompt.SETTINGS -> stringResource(R.string.notification_permission_open_settings) - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.notification_permission_title)) }, - text = { Text(stringResource(R.string.notification_permission_message)) }, - confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.notification_permission_dismiss)) } - }, - ) } @Preview diff --git a/androidApp/src/main/kotlin/de/tabmates/androidapp/TabMatesApplication.kt b/androidApp/src/main/kotlin/de/tabmates/androidapp/TabMatesApplication.kt index ecdedb32..a1f9fefa 100644 --- a/androidApp/src/main/kotlin/de/tabmates/androidapp/TabMatesApplication.kt +++ b/androidApp/src/main/kotlin/de/tabmates/androidapp/TabMatesApplication.kt @@ -5,6 +5,8 @@ import android.app.Application class TabMatesApplication : Application() { override fun onCreate() { super.onCreate() - NotificationChannels.register(this) + // Play flavor registers the FCM notification channels; the FOSS flavor has no push and + // supplies a no-op. See PlatformNotifications.kt in src/play and src/foss. + installNotificationChannels() } } diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index 7306caf7..9e1f84d7 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -1,14 +1,3 @@ TabMates - - Enable notifications - TabMates uses notifications to alert you about new expenses, members joining, and settle-up reminders. - Allow - Open settings - Not now - - General - Expenses - Group members - Settle-ups diff --git a/androidApp/src/play/AndroidManifest.xml b/androidApp/src/play/AndroidManifest.xml new file mode 100644 index 00000000..4c8a4f92 --- /dev/null +++ b/androidApp/src/play/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/androidApp/src/main/kotlin/de/tabmates/androidapp/NotificationChannels.kt b/androidApp/src/play/kotlin/de/tabmates/androidapp/NotificationChannels.kt similarity index 100% rename from androidApp/src/main/kotlin/de/tabmates/androidapp/NotificationChannels.kt rename to androidApp/src/play/kotlin/de/tabmates/androidapp/NotificationChannels.kt diff --git a/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt b/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt new file mode 100644 index 00000000..dcbc1ba6 --- /dev/null +++ b/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt @@ -0,0 +1,145 @@ +package de.tabmates.androidapp + +import android.Manifest +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +/** + * Play-flavor notification setup: Firebase Cloud Messaging delivers pushes, so the app needs the + * notification channels the backend routes by and the `POST_NOTIFICATIONS` runtime permission. + * + * The FOSS flavor supplies no-op twins of both functions — see the `foss` source set. Neither + * belongs in `src/main`: `POST_NOTIFICATIONS` reaches the merged manifest only through the + * Firebase AARs, so in a FOSS build requesting it would be denied instantly and strand the user in + * the "open settings" prompt on every cold start. + */ +internal fun Application.installNotificationChannels() { + NotificationChannels.register(this) +} + +/** Which notification-permission prompt to surface, if any. */ +private enum class NotificationPermissionPrompt { + /** User denied but can be asked again — explain why, then re-request. */ + RATIONALE, + + /** Permanently denied ("don't ask again") — direct the user to app settings. */ + SETTINGS, +} + +/** + * Asks for `POST_NOTIFICATIONS` on first composition and surfaces the follow-up prompt when the + * user says no. No-op below API 33, where the permission does not exist. + */ +@Composable +internal fun NotificationPermissionGate() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + val context = LocalContext.current + val activity = LocalActivity.current ?: return + var prompt by remember { mutableStateOf(null) } + + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + prompt = + when { + granted -> null + + // Still askable -> show rationale and let the user retry. + activity.shouldExplainNotifications() -> NotificationPermissionPrompt.RATIONALE + + // Permanently denied -> only the system settings screen can re-enable it. + else -> NotificationPermissionPrompt.SETTINGS + } + } + + LaunchedEffect(Unit) { + if (context.hasNotificationPermission()) return@LaunchedEffect + + // If the system flags a prior denial, explain before re-asking; otherwise ask directly. + if (activity.shouldExplainNotifications()) { + prompt = NotificationPermissionPrompt.RATIONALE + } else { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + prompt?.let { current -> + // App() applies its own theme; this dialog sits outside it and needs one of its own. + MaterialTheme { + NotificationPermissionDialog( + prompt = current, + onConfirm = { + prompt = null + when (current) { + NotificationPermissionPrompt.RATIONALE -> { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + NotificationPermissionPrompt.SETTINGS -> { + context.openNotificationSettings() + } + } + }, + onDismiss = { prompt = null }, + ) + } + } +} + +private fun Context.hasNotificationPermission(): Boolean = + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + +private fun android.app.Activity.shouldExplainNotifications(): Boolean = + ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS) + +private fun Context.openNotificationSettings() { + startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + }, + ) +} + +@Composable +private fun NotificationPermissionDialog( + prompt: NotificationPermissionPrompt, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val confirmLabel = + when (prompt) { + NotificationPermissionPrompt.RATIONALE -> stringResource(R.string.notification_permission_allow) + NotificationPermissionPrompt.SETTINGS -> stringResource(R.string.notification_permission_open_settings) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.notification_permission_title)) }, + text = { Text(stringResource(R.string.notification_permission_message)) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.notification_permission_dismiss)) } + }, + ) +} diff --git a/androidApp/src/main/res/values-de/strings.xml b/androidApp/src/play/res/values-de/strings.xml similarity index 100% rename from androidApp/src/main/res/values-de/strings.xml rename to androidApp/src/play/res/values-de/strings.xml diff --git a/androidApp/src/play/res/values/strings.xml b/androidApp/src/play/res/values/strings.xml new file mode 100644 index 00000000..51b04dd4 --- /dev/null +++ b/androidApp/src/play/res/values/strings.xml @@ -0,0 +1,16 @@ + + + Enable notifications + TabMates uses notifications to alert you about new expenses, members joining, and settle-up reminders. + Allow + Open settings + Not now + + General + Expenses + Group members + Settle-ups + From 96abcbad3fbf9cc3d8575266e767e2b24d39f8ee Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:10:18 +0200 Subject: [PATCH 5/7] ci: build the FOSS variant and follow the flavored task names Product flavors rename the Android variant tasks, so lintDebug becomes lintPlayDebug, testDebugUnitTest becomes testPlayDebugUnitTest, bundleRelease becomes bundlePlayRelease, and the lint SARIF is lint-results-playDebug.sarif. assembleDebug survives as the aggregate over all debug variants. The PR pipeline gains a second Gradle invocation for the FOSS variant: it compiles a different Android source set, so it can break while the Play build stays green. The distribution is read at configuration time, so it cannot be switched per task within one invocation. It is kept out of build_log.txt so it does not skew the compiler-warning baseline. The release workflow gains an unsigned FOSS APK job. The token is minted there rather than passed from the AppBundle job, because a job output is stored in plain text on the workflow run and this one is secret-derived; the message is identical, so both artifacts carry the same valid token. The APK is not attached to the GitHub Release yet. It cannot be installed unsigned, and that page is where F-Droid and IzzyOnDroid fetch from; it stays a workflow artifact until it is signed. --- .github/workflows/pr_pipeline.yml | 20 ++++- .github/workflows/release-android.yml | 108 +++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr_pipeline.yml b/.github/workflows/pr_pipeline.yml index 400d0091..65e3b8f7 100644 --- a/.github/workflows/pr_pipeline.yml +++ b/.github/workflows/pr_pipeline.yml @@ -75,8 +75,8 @@ jobs: set -o pipefail ./gradlew \ :androidApp:assembleDebug \ - :androidApp:lintDebug \ - :androidApp:testDebugUnitTest \ + :androidApp:lintPlayDebug \ + :androidApp:testPlayDebugUnitTest \ :composeApp:desktopJar \ :composeApp:wasmJsBrowserDevelopmentExecutableDistribution \ allTests \ @@ -85,8 +85,22 @@ jobs: - name: Check compiler warnings against baseline run: bash .github/check-compiler-warnings.sh build_log.txt + # The F-Droid variant compiles a different Android source set with the Google dependencies + # removed, so it can break while the Play build stays green. It needs its own Gradle + # invocation: the distribution is a property read at configuration time, not a flavor the + # KMP modules could switch per task. checkFossClasspath is the guard that no proprietary + # dependency crept back in. Kept out of build_log.txt so it does not skew the warning + # baseline above. + - name: Build FOSS variant (F-Droid) + run: | + ./gradlew \ + :androidApp:assembleFossDebug \ + :androidApp:checkFossClasspath \ + -Ptabmates.distribution=foss \ + --no-daemon --stacktrace + - name: Upload Lint SARIF report uses: github/codeql-action/upload-sarif@v4 with: - sarif_file: androidApp/build/reports/lint-results-debug.sarif + sarif_file: androidApp/build/reports/lint-results-playDebug.sarif category: lint \ No newline at end of file diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index e26c4409..1285b892 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -82,7 +82,7 @@ jobs: encodedString: ${{ secrets.GOOGLE_SERVICES_JSON }} - name: Build AppBundle - run: ./gradlew :androidApp:bundleRelease --no-daemon --no-configuration-cache --no-build-cache -x test + run: ./gradlew :androidApp:bundlePlayRelease --no-daemon --no-configuration-cache --no-build-cache -x test env: ORG_GRADLE_PROJECT_APP_VERSION: ${{ inputs.app_version }} CLIENT_BUILD_TOKEN: ${{ steps.build_token.outputs.token }} @@ -94,11 +94,98 @@ jobs: uses: actions/upload-artifact@v7 with: name: appbundle - path: androidApp/build/outputs/bundle/release/androidApp-release.aab + path: androidApp/build/outputs/bundle/playRelease/androidApp-play-release.aab + + # F-Droid build: same source and same version as the Play release, but with every proprietary + # dependency gated out (`-Ptabmates.distribution=foss`). No push notifications, no Play Core + # in-app updates, and no google-services.json step: the FOSS build applies no Google Services + # plugin and would have nothing to do with the file. + # + # Unsigned on purpose — the F-Droid distribution uses its own key, applied outside this + # pipeline. The APK lands as the `foss-apk` workflow artifact, to be signed before publishing. + # + # It is an APK, not an AAB: F-Droid and IzzyOnDroid distribute APKs. + build-foss-apk: + name: Build Unsigned FOSS APK + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Set up Java 21 (Temurin) + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: true + + # Minted here rather than passed from the AppBundle job: a job output is stored in plain + # text on the workflow run, and this is a secret-derived value. The message is byte-for-byte + # the same as the AppBundle's — the backend gate keys on platform and version, and the FOSS + # build is the same `android` platform at the same version — so both artifacts carry the + # same valid token. See the AppBundle job for why `printf` and the empty-secret guard matter. + - name: Mint client build token + id: build_token + env: + CLIENT_BUILD_SECRET: ${{ secrets.CLIENT_BUILD_SECRET }} + VERSION: ${{ inputs.app_version }} + run: | + if [[ -z "$CLIENT_BUILD_SECRET" ]]; then + echo "::error::CLIENT_BUILD_SECRET is not set — the minted token would not verify." + exit 1 + fi + if [[ -z "$VERSION" ]]; then + echo "::error::app_version input is empty." + exit 1 + fi + TOKEN=$(printf 'android|%s' "$VERSION" \ + | openssl dgst -sha256 -hmac "$CLIENT_BUILD_SECRET" -binary \ + | basenc --base64url | tr -d '=') + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + # Signing is intentionally left out: the F-Droid build is signed with its own key, + # separately from this pipeline. `androidApp/build.gradle.kts` sets signingConfig = null for + # the FOSS flavor, so the output is a genuinely unsigned APK — not one quietly signed with + # the SDK's public debug key. Uncomment this step and the SIGNING_* env below (pointing at + # FOSS-specific secrets, not the Play upload keystore) to sign in CI instead. + # - name: Decode Keystore + # uses: timheuer/base64-to-file@v2 + # with: + # fileName: 'foss_keystore.jks' + # fileDir: 'androidApp/keystore/' + # encodedString: ${{ secrets.KEYSTORE_FOSS }} + + # checkFossClasspath runs in the same invocation as the build, so a proprietary dependency + # that crept back in fails the release rather than shipping to F-Droid. + - name: Build FOSS APK + run: | + ./gradlew \ + :androidApp:assembleFossRelease \ + :androidApp:checkFossClasspath \ + -Ptabmates.distribution=foss \ + --no-daemon --no-configuration-cache --no-build-cache -x test + env: + ORG_GRADLE_PROJECT_APP_VERSION: ${{ inputs.app_version }} + CLIENT_BUILD_TOKEN: ${{ steps.build_token.outputs.token }} + # SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_FOSS_PASSWORD }} + # SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_FOSS_ALIAS }} + # SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_FOSS_PASSWORD }} + + - name: Upload FOSS APK + uses: actions/upload-artifact@v7 + with: + name: foss-apk + path: androidApp/build/outputs/apk/foss/release/androidApp-foss-release-unsigned.apk release: name: Release - needs: [build-appbundle] + needs: [build-appbundle, build-foss-apk] runs-on: ubuntu-latest steps: - name: Download aab from build @@ -106,6 +193,11 @@ jobs: with: name: appbundle + - name: Download FOSS apk from build + uses: actions/download-artifact@v8 + with: + name: foss-apk + - name: Build Changelog id: github_release uses: mikepenz/release-changelog-builder-action@v6 @@ -144,10 +236,18 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The FOSS APK is attached to the release because that is where F-Droid and IzzyOnDroid + # fetch builds from. The Play AAB stays off the release: it is not installable, and Play + # gets it through the deploy step below. - name: Create GitHub Release uses: mikepenz/action-gh-release@v3 with: body: ${{ steps.github_release.outputs.changelog }} + # Re-enable once the FOSS APK is signed (see build-foss-apk). Attaching the unsigned + # artifact would publish a file nobody can install, on the page F-Droid and IzzyOnDroid + # fetch from. Until then it is available as the `foss-apk` workflow artifact, to be + # signed with the F-Droid key out of band. + # files: androidApp-foss-release-unsigned.apk env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -156,5 +256,5 @@ jobs: with: serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }} packageName: de.tabmates.androidapp - releaseFiles: androidApp-release.aab + releaseFiles: androidApp-play-release.aab track: internal From fddd589b7e959f6e7d7cf403ac12c498a648f6d7 Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:10:21 +0200 Subject: [PATCH 6/7] docs: document the Play/FOSS distributions Records the flavored Android task names, which every existing instruction for building, installing and testing the app got wrong the moment the flavor landed, and explains what the FOSS build gives up and why F-Droid needs it. --- .claude/skills/android-testing/SKILL.md | 2 +- .claude/skills/verify/SKILL.md | 4 ++-- AGENTS.md | 8 +++++--- README.md | 5 ++++- features/notifications/README.md | 24 +++++++++++++++++++++++- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.claude/skills/android-testing/SKILL.md b/.claude/skills/android-testing/SKILL.md index 6662976f..bd494195 100644 --- a/.claude/skills/android-testing/SKILL.md +++ b/.claude/skills/android-testing/SKILL.md @@ -91,7 +91,7 @@ Only inject a `CoroutineDispatcher` when the class dispatches to a non-main disp ## Running -- All targets: `./gradlew allTests` (what CI runs). Narrower: `:features::presentation:desktopTest` or `:androidApp:testDebugUnitTest`. +- All targets: `./gradlew allTests` (what CI runs). Narrower: `:features::presentation:desktopTest` or `:androidApp:testPlayDebugUnitTest`. - Fast compile check of touched test sources: `./gradlew :features:::compileAndroidHostTest` or `compileKotlinJvm`. ## What to Test diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index b259c0b0..d231a2fa 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -8,8 +8,8 @@ description: Build, install, and drive the TabMates Android app on the local emu ## Build + install + launch ```bash -./gradlew :androidApp:assembleDebug -adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk +./gradlew :androidApp:assemblePlayDebug +adb install -r androidApp/build/outputs/apk/play/debug/androidApp-play-debug.apk adb shell monkey -p de.tabmates.androidapp -c android.intent.category.LAUNCHER 1 ``` diff --git a/AGENTS.md b/AGENTS.md index 25d01101..0576a254 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,9 +178,10 @@ common ## 11. Critical Workflows - **Format:** `./gradlew ktlintFormat`. CI runs `ktlintCheck :build-logic:convention:ktlintCheck`. - **Fast verify:** compile only touched modules, e.g. `./gradlew :features:tabgroup:domain:compileKotlinJvm`. Full Android build: `./gradlew :androidApp:assembleDebug`. -- **Tests:** `./gradlew allTests` (all targets) or narrower, e.g. `:androidApp:testDebugUnitTest`. +- **Tests:** `./gradlew allTests` (all targets) or narrower, e.g. `:androidApp:testPlayDebugUnitTest`. - **Compiler warnings:** CI checks build log against `.github/compiler-warnings-baseline.txt` via `.github/check-compiler-warnings.sh` — new warnings fail the PR pipeline. Don't introduce any. -- **CI parity:** `.github/workflows/pr_pipeline.yml` = ktlint + `:androidApp:assembleDebug lintDebug testDebugUnitTest` + `:composeApp:desktopJar` + wasm distribution + `allTests`. +- **CI parity:** `.github/workflows/pr_pipeline.yml` = ktlint + `:androidApp:assembleDebug lintPlayDebug testPlayDebugUnitTest` + `:composeApp:desktopJar` + wasm distribution + `allTests`, then the F-Droid variant (`:androidApp:assembleFossDebug :androidApp:checkFossClasspath -Ptabmates.distribution=foss`). +- **Distributions:** `:androidApp` has one product flavor per invocation, derived from the `tabmates.distribution` Gradle property (`play` by default, `foss` for F-Droid), so Android task names carry it: `assemblePlayDebug`, `lintPlayDebug`, `installPlayDebug`. `assembleDebug` still works as the aggregate. `foss` drops Firebase and Play Core entirely — no push, no in-app updates. See `build-logic/.../Distribution.kt`. - **Local Config:** `local.properties` must have `API_KEY`. `CLIENT_BUILD_TOKEN` is optional (see README) — once the backend enables its version gate, native builds without a matching one get `426`. - **Sync:** `./gradlew help` (triggers sync). @@ -192,7 +193,8 @@ common | Lint (CI parity) | `./gradlew ktlintCheck :build-logic:convention:ktlintCheck` | | Compile one module | `./gradlew :features:tabgroup:domain:compileKotlinJvm` | | Android debug build | `./gradlew :androidApp:assembleDebug` | -| Android unit tests | `./gradlew :androidApp:testDebugUnitTest` | +| Android unit tests | `./gradlew :androidApp:testPlayDebugUnitTest` | +| F-Droid (FOSS) build | `./gradlew :androidApp:assembleFossDebug :androidApp:checkFossClasspath -Ptabmates.distribution=foss` | | All tests, all targets | `./gradlew allTests` | | Desktop jar | `./gradlew :composeApp:desktopJar` | | Gradle sync | `./gradlew help` | diff --git a/README.md b/README.md index bd4c690d..8b0676fb 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,10 @@ Push notifications additionally need Firebase config — see [`features/notifica ```bash # Android — install on a device/emulator (or just run :androidApp from the IDE) -./gradlew :androidApp:installDebug +./gradlew :androidApp:installPlayDebug + +# Android, F-Droid variant — no Firebase, no Play Core, no push notifications +./gradlew :androidApp:installFossDebug -Ptabmates.distribution=foss # Desktop (JVM), hot-reload enabled ./gradlew :composeApp:hotRunDesktop diff --git a/features/notifications/README.md b/features/notifications/README.md index 2d9a4663..6a723b5b 100644 --- a/features/notifications/README.md +++ b/features/notifications/README.md @@ -6,11 +6,33 @@ Android + iOS: | Platform | Mechanism | |----------|-----------| -| Android | FCM push via [kmpnotifier](https://github.com/mirzemehdi/KMPNotifier) | +| Android (Play) | FCM push via [kmpnotifier](https://github.com/mirzemehdi/KMPNotifier) | +| Android (F-Droid)| **None.** Firebase is proprietary, so the FOSS distribution has no push at all | | iOS | FCM push via kmpnotifier (Swift AppDelegate bridges APNs into Firebase) | | Desktop | No FCM client exists → backend WebSocket stream rendered as local notifications (kmpnotifier local notifier) | | Web | Firebase **JS SDK** push (service worker + VAPID), not kmpnotifier | +### The F-Droid (FOSS) Android build + +F-Droid refuses proprietary dependencies, so `-Ptabmates.distribution=foss` drops +`kmpnotifier-push-firebase` (and with it firebase-messaging and play-services) from +`androidMain`, swapping `src/androidPlayMain` for `src/androidFossMain`. That build binds +`NoOpPushNotificationController` and `UnsupportedNotificationPermissionController` — both already +in `commonMain` — so nothing else in the feature changes. + +`POST_NOTIFICATIONS` is not declared in `androidApp/src/main/AndroidManifest.xml` on purpose: the +`firebase-messaging` and `kmpnotifier-core` AAR manifests each declare it, so it merges into the +Play build with the dependency and is simply absent from the FOSS one. The permission prompt lives +in `androidApp/src/play/` for the same reason — requesting an undeclared permission is denied +instantly, which would trap a FOSS user in the "open settings" dialog on every launch. + +Desktop already delivers notifications over the backend WebSocket stream +(`DesktopPushNotificationController`). If FOSS Android should get notifications later, reusing that +controller is the cheapest option (no backend change, but delivery only while the app runs); +UnifiedPush is the proper one, and needs the backend to POST to per-device endpoint URLs. + +See `build-logic/convention/src/main/kotlin/de/tabmates/convention/Distribution.kt`. + ## Layout - **domain** — `NotificationService` (backend token registration), `PushNotificationController` From 0495fd0d6d6a75bb1aacc78aa56f9a5502a3a09c Mon Sep 17 00:00:00 2001 From: Dominik Dias Date: Fri, 4 Sep 2026 21:41:37 +0200 Subject: [PATCH 7/7] fixup! build(androidApp): add the play/foss product flavor --- .../kotlin/de/tabmates/androidapp/PlatformNotifications.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt b/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt index dcbc1ba6..a0f3d79a 100644 --- a/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt +++ b/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt @@ -10,6 +10,7 @@ import android.provider.Settings import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -107,10 +108,12 @@ internal fun NotificationPermissionGate() { } } +@RequiresApi(Build.VERSION_CODES.TIRAMISU) private fun Context.hasNotificationPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED +@RequiresApi(Build.VERSION_CODES.TIRAMISU) private fun android.app.Activity.shouldExplainNotifications(): Boolean = ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)