Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/android-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>:presentation:desktopTest` or `:androidApp:testDebugUnitTest`.
- All targets: `./gradlew allTests` (what CI runs). Narrower: `:features:<name>:presentation:desktopTest` or `:androidApp:testPlayDebugUnitTest`.
- Fast compile check of touched test sources: `./gradlew :features:<name>:<layer>:compileAndroidHostTest` or `compileKotlinJvm`.

## What to Test
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
20 changes: 17 additions & 3 deletions .github/workflows/pr_pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ jobs:
set -o pipefail
./gradlew \
:androidApp:assembleDebug \
:androidApp:lintDebug \
:androidApp:testDebugUnitTest \
:androidApp:lintPlayDebug \
:androidApp:testPlayDebugUnitTest \
:composeApp:desktopJar \
:composeApp:wasmJsBrowserDevelopmentExecutableDistribution \
allTests \
Expand All @@ -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
108 changes: 104 additions & 4 deletions .github/workflows/release-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -94,18 +94,110 @@ 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
uses: actions/download-artifact@v8
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
Expand Down Expand Up @@ -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 }}

Expand All @@ -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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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` |
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 118 additions & 2 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
}
}

Expand Down Expand Up @@ -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<ApplicationAndroidComponentsExtension> {
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<ComponentIdentifier>()
val queue = ArrayDeque(listOf(root))
val found = sortedSetOf<String>()
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<ResolvedDependencyResult>()
.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) }
}
}
}
Loading