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/.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 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/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..a0f3d79a --- /dev/null +++ b/androidApp/src/play/kotlin/de/tabmates/androidapp/PlatformNotifications.kt @@ -0,0 +1,148 @@ +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.annotation.RequiresApi +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 }, + ) + } + } +} + +@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) + +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 + 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/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, ) } 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` 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 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