fontan
Join waitlist
Docs

Android SDK and CI callbacks

Put the ideas board inside your Android app, then let your own CI move each card from building to beta to shipped. This page describes what the code does today, nothing more.

Quick start

  1. Copy the fontan-board module into your project and include it (see Install from source).
  2. Call Fontan.init once in your Application with the board URL and the public SDK key from the dashboard.
  3. Show FontanBoard(onBack = ...) in Compose, or call Fontan.openBoard(activity) from any Activity.
  4. Pick FontanMode.Play for builds on Google Play, FontanMode.Direct for builds you distribute yourself.
  5. Have your CI report building, beta, failed and the merged release to the board.

Want to look first? Leave baseUrl empty, or use FontanConfig.demo(). The board then runs on built in demo data and sends no requests.

Install from source

The SDK is not published to Maven or any other repository yet. Until it is, you add it as a Gradle module from the Fontan source, folder fontan/android-sdk/fontan-board in the public repository.

The module was built with AGP 8.9.1, Kotlin 2.1.21, the Compose BOM 2025.05.01, Gradle 8.14.3 and JDK 17. It needs compileSdk 35 and minSdk 26.

1. Copy the module

Copy the fontan-board folder next to your app module and include it.

settings.gradle.kts
include(":app")
include(":fontan-board")

2. Add the version catalog entries

The module's build.gradle.kts uses these aliases. Add any that your gradle/libs.versions.toml does not have yet. If you already use other versions, keep yours, as long as they meet the minimums above.

gradle/libs.versions.toml
[versions]
agp = "8.9.1"
kotlin = "2.1.21"
composeBom = "2025.05.01"
activityCompose = "1.10.1"
coreKtx = "1.16.0"
junit = "4.13.2"
json = "20250517"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
json = { group = "org.json", name = "json", version.ref = "json" }
junit = { group = "junit", name = "junit", version.ref = "junit" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

The root build.gradle.kts declares the plugins with apply false, as in any multi module project.

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
}

3. Depend on it

app/build.gradle.kts
dependencies {
    implementation(project(":fontan-board"))
}

Check the setup with ./gradlew :app:assembleDebug :fontan-board:testDebugUnitTest.

Initialise

Call Fontan.init once, before the board is shown. The Application class is the natural place.

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        val mode = FontanMode.Play(testUrl = "https://play.google.com/apps/testing/com.example.app")
        val config = FontanConfig(
            baseUrl = "https://board.example.com",
            appKey = "pk_live_xxx",
            mode = mode,
        )
        Fontan.init(this, config)
    }
}
FieldMeaning
baseUrlYour board URL from the dashboard. Must start with https://. Empty means demo data.
appKeyThe public SDK key from the dashboard. It only identifies your app, so shipping it inside the APK is expected. Never put the CI secret here.
modeFontanMode.Play(testUrl) or FontanMode.Direct. See Direct and Play builds.
themeOptional FontanTheme. By default the colours come from the surrounding MaterialTheme.
betaPostOptional. In a beta build, the id of the post it was built from. See Beta builds.

What goes over the wire

Every request goes to baseUrl plus /v1/... and carries two headers:

  • X-Fontan-Key: the public SDK key.
  • X-Fontan-Device: a random UUID the SDK creates on first launch and keeps in the app's private preferences. It is not tied to any account.

New posts also send the app version name and code, the Android API level, the device model and the locale, so you can see what a report came from. The SDK reads the installed versionCode itself, and beta ratings are bound to it.

Show the board

Jetpack Compose

FontanBoard is a full screen composable with its own navigation between the list, a post and the new post form.

MaterialTheme {
    FontanBoard(onBack = { navController.popBackStack() })
}

Activity, without Compose in your code

Call openBoard from any Activity. The SDK starts its own FontanBoardActivity, which follows the system light or dark setting.

Fontan.openBoard(this)

The library manifest adds the INTERNET permission, the non exported FontanBoardActivity and a non exported install result receiver. Nothing else is needed for Play mode. There is no XML View wrapper besides openBoard.

Direct and Play builds

The mode decides what "Try this beta" and "Go back to stable" do on the phone.

ModeBeta buttonBack to stableExtra permission
FontanMode.Play(testUrl)"Join the test" opens your Play testing opt in linkThe user leaves the test on the same page, then updates from PlayNone
FontanMode.Direct"Try this version" downloads and installs the beta APKInstalls the rollback APK of the stable releaseREQUEST_INSTALL_PACKAGES

Use Play mode for every build that ships through Google Play. Play policy does not let an app install APKs from outside Play. Use Direct mode only for builds you distribute yourself.

For Direct mode, declare the permission in the app manifest, ideally only in the flavour that is distributed outside Play:

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

Fontan.init reads the merged manifest and throws if Direct mode is chosen without it. Play mode needs a testing link that starts with https://.

Beta builds

When CI builds a beta for a post, pass that post id as betaPost, for example from a BuildConfig field your CI sets. The board then shows the testing banner, marks the device as a tester of the running versionCode, and lets the user rate it "Works" or "Something's off".

val config = FontanConfig(
    baseUrl = BuildConfig.FONTAN_URL,
    appKey = BuildConfig.FONTAN_KEY,
    mode = FontanMode.Play(testUrl = BuildConfig.FONTAN_TEST_URL),
    betaPost = BuildConfig.FONTAN_BETA_POST,
)

Leave it at 0 in stable builds. The versionCode of the beta APK must be the same number CI reports in the beta callback, otherwise ratings are refused.

CI callbacks

Your pipeline tells the board what happened. Every call is a JSON POST with the header X-Fontan-CI-Secret, the per app CI secret from the dashboard. Keep it in a masked, protected CI variable, never in the app.

POST /v1/ci/status

{
  "postId": 12,
  "state": "beta",
  "pipelineId": 123,
  "versionCode": 1231,
  "versionName": "1.4.0-beta.12",
  "apkUrl": "https://example.com/beta.apk",
  "rollbackApkUrl": "https://example.com/rollback.apk",
  "mrUrl": "https://gitlab.com/acme/app/-/merge_requests/7"
}
  • postId is a JSON number. A string such as "12" is rejected with 400. Instead of postId you can send issueIid, the GitLab issue the board opened for the post.
  • state is building, beta or failed.
  • versionCode is required for beta. A higher one replaces the current beta and its rating counts start again.
  • pipelineId is optional. A callback older than the last accepted one, or a beta with a lower versionCode, returns 200 with ignored and changes nothing, so retries are safe.
  • failed keeps the card where it is, sets the note "Build failed" and marks the current beta as needing an update.

POST /v1/ci/release

Send it from the job that builds your stable release on the default branch.

{ "versionCode": 1230, "versionName": "1.4.0", "apkUrl": "https://example.com/app.apk", "issueIid": 12, "merged": true }

This sets the latest stable release that Direct mode rolls back to. With merged: true and a postId or issueIid, a post in beta or ready to ship becomes shipped. Without merged: true nothing ships. Send merged: true only after a real merge: a closed, unmerged request should leave the post where it is.

Using the runner templates

The Fontan runner templates for GitHub Actions and GitLab CI include fontan-callback.sh. It reads FONTAN_CALLBACK_URL (https only), FONTAN_CI_SECRET and FONTAN_POST_ID, sends postId as a number, and adds the pipeline id from CI_PIPELINE_ID or GITHUB_RUN_ID. The templates already report building when a run starts and when the pull or merge request opens, and failed when a job fails. Your build job reports the beta once the APK exists:

export FONTAN_VERSION_CODE=1231
export FONTAN_VERSION_NAME=1.4.0-beta.12
export FONTAN_APK_URL=https://example.com/beta.apk
export FONTAN_ROLLBACK_APK_URL=https://example.com/rollback.apk
bash .fontan/fontan-callback.sh beta "$MR_URL"

The script has no release command, so call /v1/ci/release yourself, for example on GitLab:

MERGED_IID=$(printf '%s' "$CI_COMMIT_MESSAGE" | sed -nE "s/.*Merge branch 'post-([0-9]+)-.*/\1/p" | head -n 1)
BODY=$(jq -cn --argjson versionCode "$STABLE_CODE" --arg versionName "$STABLE_NAME" --arg apkUrl "$STABLE_URL" \
  '{versionCode: $versionCode, versionName: $versionName, apkUrl: $apkUrl}')
if [ -n "$MERGED_IID" ]; then
  BODY=$(printf '%s' "$BODY" | jq -c --argjson issueIid "$MERGED_IID" '. + {issueIid: $issueIid, merged: true}')
fi
curl --fail -sS --max-time 20 --retry 3 -X POST \
  -H "Content-Type: application/json" \
  -H "X-Fontan-CI-Secret: $FONTAN_CI_SECRET" \
  --data "$BODY" "${FONTAN_CALLBACK_URL%/}/v1/ci/release"

Post branches are named post-<id>-<slug>, which is how the snippet finds the issue in a merge commit message. The job image needs jq and curl.

Troubleshooting

  • Gradle: unresolved libs.plugins.android.library or a similar aliasThe version catalog is missing an entry the module uses. Add the lines from step 2.
  • Fontan config: baseUrl must start with https://Plain http is refused. Use the https board URL, or an empty baseUrl for demo data.
  • Fontan config: appKey is emptyA real baseUrl needs the public SDK key from the dashboard.
  • Fontan config: Play mode needs the https testing opt-in linkPass the Play testing link of your app, the one that starts with https://play.google.com/apps/testing/.
  • Fontan Direct mode needs these permissions in the app manifestAdd REQUEST_INSTALL_PACKAGES to the flavour you distribute outside Play, or switch that build to Play mode.
  • Call Fontan.init before showing the boardThe board was shown before Fontan.init ran. Initialise in Application.onCreate.
  • The board shows sample postsbaseUrl is empty, so the SDK is in demo mode.
  • Requests fail with 401The X-Fontan-Key is missing or not known to the board. Copy the key again from the dashboard. A 400 means the device header is missing or not a UUID.
  • Rating a beta fails with not_tried or newer_betaThe installed versionCode is not the one CI reported as the current beta. Make the beta APK and the beta callback use the same number.
  • CI callback returns 400Usually postId sent as a string, or beta without versionCode. 401 is a wrong CI secret, 404 a post of another app, 409 invalid_transition a state the post cannot move to from where it is.
  • CI callback returns 200 but nothing changedThe response says ignored: the pipelineId or versionCode is older than what the board already has.
  • fontan-callback: no callback url or secret, skippingFONTAN_CALLBACK_URL or FONTAN_CI_SECRET is not visible to that job. On GitLab, check that protected variables reach the branch and environment the job runs on.

Known limits

  • Android only. There is no iOS, web or Flutter SDK.
  • No Maven package yet.
  • UI strings are English only.
  • No report or hide action for posts in the SDK yet.