Livery Server-driven Remote Compose templates GitHub

Getting started#

From nothing to a signed catalog on a GitHub release, read by an app, in five steps.

1. Get the tools#

The libraries are published to Maven Central as ee.schimke.composeai:<module>, from the first release on. Each release attaches the livery and livery-mcp CLIs as zips. A release publishes only the modules that changed, so modules can sit at different versions: import livery-bom and name the modules without one, and the BOM gives each the version it is published at:

dependencies {
  implementation(platform("ee.schimke.composeai:livery-bom:<version>"))
  implementation("ee.schimke.composeai:livery-client")   // fetch, verify, cache, select, bind
  implementation("ee.schimke.composeai:livery-compose")  // LiveryTemplate, LiverySlot
  // launcher and Wear widgets, notifications: livery-launcher-widgets, livery-wear-widgets, livery-notifications
  // Firebase Analytics, Remote Config experiments, Firestore frequency caps: livery-firebase
}

The Swift package isn't tagged yet; use it from a checkout. To build the CLI from source:

git clone https://github.com/yschimke/livery
cd livery
./gradlew :livery-publish:installDist
export PATH="$PWD/livery-publish/build/install/livery/bin:$PATH"
livery

Building the Android targets needs ANDROID_HOME with platforms;android-37.1 installed.

2. Make a signing key#

livery keygen acme

This writes acme.key (the private key: a CI secret, never committed) and acme.pub (the public key, which your apps embed). See Signing.

3. Write a catalog#

A catalog is a directory. The repository's samples/catalog is a complete one once built: its Compose-authored designs (banner-classic, poll-card) are Kotlin, and ./gradlew :livery-capture-cmp:sampleCatalogs captures them into livery-capture-cmp/build/samples/catalog, the tree to point livery build at (see Authoring). The smallest is:

catalog.json                        { "catalog": "acme", "channel": "main" }
contracts/banner.json               the data the template reads (see Concepts)
designs/banner-classic/design.json  { "id": "banner-classic", "contract": "promo/banner@1",
                                      "title": "Classic", "install": "Always" }
designs/banner-classic/in-app.rc    the document, from any authoring tool
designs/banner-classic/in-app.json  { "target": { "surface": "in-app", "rcApiLevel": 7,
                                      "profiles": ["androidx"] },
                                      "reads": ["headline", "cta"], "emits": ["cta"] }

livery build refuses a catalog with problems instead of publishing it: a template reading a value its contract doesn't declare, an example that doesn't fit its contract, a duplicate id. Add --inspect to also decode every .rc and check it against its sidecar.

4. Publish it as a GitHub release#

livery build catalog build/release --github acme/app-templates --tag catalog-1 \
  --key acme.key --key-id acme-1
gh release create catalog-1 build/release/* --repo acme/app-templates --title "Catalog 1"

Use a repository for catalog releases only, so its "latest" release is always the catalog. For other hosts, see Hosting.

5. Read it in the app#

Kotlin (Android, desktop, iOS): the libraries from step 1. Livery's Compose Multiplatform version is a floor for your app's, so check it matches the Compose (or androidx Compose BOM) you ship. To try unreleased changes, use a checkout through a composite build instead (includeBuild("../livery") and version 0.1.0-SNAPSHOT).

The repository below reads only the network. To also ship a copy in the app (first launch, offline), build the catalog into its assets with the ee.schimke.composeai.livery.catalog Gradle plugin and pass bundled = AndroidBundled.fromAssets(context); see Hosting.

val inApp = PlayerCapabilities(Surfaces.IN_APP, maxRcApiLevel = 7, profiles = setOf("androidx"))
// A template that uses a risky feature (shaders, haptics, sound, …) plays only where you allow it:
// PlayerCapabilities(…, features = setOf("shaders")). None by default.
val repository = TemplateRepository(
  source = GitHubReleaseSource("acme", "app-templates"),
  fetcher = KtorFetcher(),           // OkHttp on Android and the JVM, Darwin on iOS
  crypto = JavaCrypto,               // JVM and Android
  trust = TrustPolicy.Signed(listOf(TrustedKey("acme-1", Base64.decode(ACME_PUB)))),
  blobs = FileBlobStore(File(context.cacheDir, "livery/blobs")),
  kv = FileKeyValueStore(File(context.filesDir, "livery")),
  clock = { System.currentTimeMillis() },
  targets = listOf(inApp),
)
repository.refresh()                 // from your background scheduler, and at start

No blank first screen. If the app's first screen has Livery slots, keep the splash screen up until they are prepared (answers planned, documents, fonts and images in memory), so the screen appears complete. startup never waits longer than its limit (800 ms by default) and keeps preparing in the background after it:

// Android, androidx.core:core-splashscreen, in onCreate before setContent
val startup = host.resources.startup(lifecycleScope) {
  prepareSlot("home.top", data) { planner.forAppData(query, data) }   // the slot's slotId, key and call
}
installSplashScreen().setKeepOnScreenCondition { !startup.isReady }

The slot itself is LiverySlot(key = data, host = host, slotId = "home.top") { … }. On desktop, iOS and the web, startup.await() before showing the first screen.

Emoji load after the first screen (Android). Compose's text brings in androidx.emoji2, whose initializer runs at process start, on the main thread, before the first frame. Livery's manifest turns it off, and Livery loads emoji itself, off the main thread, two frames after the app's first template draws: there is nothing to call. Text drawn before then uses the platform's own emoji. An app that wants emoji sooner, or on a screen with no template, calls LiveryEmoji.load(applicationContext).

To keep the platform's initializer instead, declare it in your app's manifest:

<provider android:name="androidx.startup.InitializationProvider"
  android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge">
  <meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer"
    android:value="androidx.startup" tools:node="replace" />
</provider>

Swift (iOS, macOS):

// Package.swift
.package(url: "https://github.com/yschimke/livery", branch: "main")
let repository = TemplateRepository(
  source: GitHubReleaseSource(owner: "acme", repo: "app-templates"),
  trust: .signed([TrustedKey(keyId: "acme-1", publicKey: Data(base64Encoded: acmePub)!)]),
  blobs: FileBlobStore(directory: caches.appendingPathComponent("livery")),
  kv: FileKeyValueStore(directory: support.appendingPathComponent("livery")),
  targets: [PlayerCapabilities(surface: Surfaces.inApp, maxRcApiLevel: 7, profiles: ["core"])])
_ = await repository.refresh()

Then pick a use case: each page goes from the contract to a deployment checklist.

Rendering never waits on the network. refresh() downloads in the background; slots draw from what is on disk, then the bundled catalog, then your native fallback.

Edit this page