Quick start
- Copy the
fontan-boardmodule into your project and include it (see Install from source). - Call
Fontan.initonce in yourApplicationwith the board URL and the public SDK key from the dashboard. - Show
FontanBoard(onBack = ...)in Compose, or callFontan.openBoard(activity)from any Activity. - Pick
FontanMode.Playfor builds on Google Play,FontanMode.Directfor builds you distribute yourself. - Have your CI report
building,beta,failedand 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.
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.
[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.ktsdependencies {
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)
}
}
| Field | Meaning |
|---|---|
baseUrl | Your board URL from the dashboard. Must start with https://. Empty means demo data. |
appKey | The 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. |
mode | FontanMode.Play(testUrl) or FontanMode.Direct. See Direct and Play builds. |
theme | Optional FontanTheme. By default the colours come from the surrounding MaterialTheme. |
betaPost | Optional. 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.
| Mode | Beta button | Back to stable | Extra permission |
|---|---|---|---|
FontanMode.Play(testUrl) | "Join the test" opens your Play testing opt in link | The user leaves the test on the same page, then updates from Play | None |
FontanMode.Direct | "Try this version" downloads and installs the beta APK | Installs the rollback APK of the stable release | REQUEST_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"
}
postIdis a JSON number. A string such as"12"is rejected with400. Instead ofpostIdyou can sendissueIid, the GitLab issue the board opened for the post.stateisbuilding,betaorfailed.versionCodeis required forbeta. A higher one replaces the current beta and its rating counts start again.pipelineIdis optional. A callback older than the last accepted one, or a beta with a lowerversionCode, returns200withignoredand changes nothing, so retries are safe.failedkeeps 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.libraryor 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
baseUrlfor demo data. - Fontan config: appKey is emptyA real
baseUrlneeds 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_PACKAGESto 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.initran. Initialise inApplication.onCreate. - The board shows sample posts
baseUrlis empty, so the SDK is in demo mode. - Requests fail with 401The
X-Fontan-Keyis missing or not known to the board. Copy the key again from the dashboard. A400means the device header is missing or not a UUID. - Rating a beta fails with not_tried or newer_betaThe installed
versionCodeis not the one CI reported as the current beta. Make the beta APK and thebetacallback use the same number. - CI callback returns 400Usually
postIdsent as a string, orbetawithoutversionCode.401is a wrong CI secret,404a post of another app,409 invalid_transitiona state the post cannot move to from where it is. - CI callback returns 200 but nothing changedThe response says
ignored: thepipelineIdorversionCodeis older than what the board already has. - fontan-callback: no callback url or secret, skipping
FONTAN_CALLBACK_URLorFONTAN_CI_SECRETis 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.