Android reference
The details behind the quickstart for Android: what the com.hotlayout:hotlayout-android library takes, what it does with documents, themes, data, actions, fonts, language, pictures and the network, what it reports, and how it decides whether it can read a document. The quickstart is the path from an empty app to a published screen; this page is the reference, ordered by topic. The README of the library is a short introduction.
What each schema minor added is in the contract changelog, and which library version reads which minor is in Which package reads which schema. What changed in each library version is on the releases of the library.
Related pages: quickstart for Android · widgets · compatibility · placements · localization · theme · data dictionary
The library#
| Contract it reads | document contract 9.34.0. Every widget of the contract is drawn, and the rules of the contract (versions, data, conditions, text, numbers, dates, plurals, locale, actions and navigation) pass the shared case tables |
| The rule | the major version must match: a 9.x reader draws any 9.x document and no 8.x one. A document that uses a field newer than the reader still opens, and the reader skips the field. See Version compatibility |
| Requires | Android 8.0 (API 26) and later, Jetpack Compose. No third-party dependencies besides AndroidX and Compose; the Compose compiler plugin is needed, as in any Compose app |
| Install | Maven Central, com.hotlayout:hotlayout-android; the steps are in the quickstart |
| The package | the whole public API is in the Kotlin package com.hotlayout |
| Permission | the library does not declare INTERNET. The default picture loader downloads over the network, so an app that shows pictures from https: addresses adds <uses-permission android:name="android.permission.INTERNET" /> to its own manifest. An app that loads pictures through its own client (ImagePolicy.fetch) does not need it for the reader |
| Licence notices | Apache License 2.0. The icon drawings include icons from Lucide (ISC); the notices are in THIRD-PARTY-NOTICES.md of the library: include them in your app's licences screen |
Status: complete for contract 9.34.0. The drawn widgets include text and rich text, buttons, lists and list rows, stacks and layers, progress bars and rings, the form controls and text fields (with forms and their validation), the screen anatomy (app bar, body, bottom zone and navigation bar), modal layers with the system back key, carousels, node motion and glass, images, and the adaptive width classes, with the alignment rules on both axes.
The public API#
HotlayoutScreen |
the composable that draws a document — see Drawing a document |
loadDocument, HotlayoutDocument, HotlayoutThemeSet.read, HotlayoutLoadException |
read a document or a theme; see Loading a document and a theme |
checkDocumentVersion, VersionVerdict, HotlayoutVersions |
the version rule, without reading the whole document |
resolveLocale, LocaleResolution |
which translation, number format and direction a device locale gets |
HotlayoutAction, StandardAction, StandardActions.url, runStandardAction |
what reaches onAction, and the helpers for the standard actions |
TrackEvent, ScreenShownEvent, NavigateRequest, ActionSource |
what the other callbacks receive |
ImagePolicy, WritablePaths, HotlayoutFonts, HotlayoutLabels |
decisions of the host: pictures, writable data paths, fonts, texts of the reader |
dataScopeOf, JsonValue |
the data you pass in |
HotlayoutReport |
what the reader skipped or replaced; never shown to the person |
HotlayoutPlacement, PlacementSource, loadPlacement, PlacementLoad, PlacementStore, PlacementTransport |
placements — see Placements |
Drawing a document#
HotlayoutScreen(document = …) draws a document. The quickstart shows it with the usual callbacks; this table is the whole list.
| Parameter | What you pass |
|---|---|
document (required) |
a HotlayoutDocument from loadDocument. Reading is work for a background thread |
modifier |
the box the screen is drawn in; the width class of the screen comes from this box, not from the window |
theme |
a HotlayoutThemeSet; HotlayoutThemeSet.builtIn without one |
themeId |
the theme of the set to draw with; the set's default without it |
data |
the values for the document's bindings, a flat map from a data path to a value; build it with dataScopeOf(…) — see Data |
locale |
the device locale (ar-EG, pt_BR); the first locale of the configuration without it — see Language and right-to-left |
entry |
a named entry point of the document. A name the document does not have starts on the initial screen |
timeZone |
the zone dates are written in; the zone of the device without it |
now |
"now" in epoch milliseconds for dates and counted days; without it the clock of the device, read once when the document is first drawn. With a moment given you own the clock: a time left does not count down on its own |
images |
an ImagePolicy — see Images |
fonts |
HotlayoutFonts: the families the theme names, from your resources; the system font otherwise — see Fonts |
labels |
HotlayoutLabels: the few texts the reader says to assistive technology, in your language — see Accessibility and labels |
onAction |
an action the reader does not perform itself, already checked, with an ActionSource — see Actions and navigation |
onActionTask |
your answer to an action as a task (Job?): a swipe-to-confirm button stays busy until it ends |
onTrack |
the analytics event of a press (TrackEvent), once per press, before the action |
onHaptic |
the haptic word a press asks for; the reader never vibrates by itself |
onNavigate |
called before the reader changes screens by itself; return false to cancel |
onWrite, writablePaths |
what the user typed or chose, and which paths a document may write — see Writes |
autofill |
false hands the platform no autofill hints from the text fields |
handleSystemBack |
whether the reader takes the system back key — see The system back key |
onScreenChange |
the top of the stack changed (the screen was shown, with the cause) |
onReport |
what the reader skipped or replaced — see What the reader reports |
safeAreaTop, safeAreaBottom |
insets you have already accounted for — see Window insets |
onScreenCovered |
for a host with its own stack of screens — see Swipe fill |
The reader keeps its own stack of screens, bound to the documentId and the entry, not to the document object: a host that reads the JSON again on every composition stays on the screen it is on. It performs navigation inside the document and draws only the document.
Loading a document and a theme#
val document by produceState<HotlayoutDocument?>(null, documentJson) {
value = withContext(Dispatchers.Default) {
try {
loadDocument(documentJson) // tolerant read + version gate
} catch (error: HotlayoutLoadException) {
null // show your own screen
}
}
}
val theme = remember(themeJson) { HotlayoutThemeSet.read(themeJson) ?: HotlayoutThemeSet.builtIn }loadDocument throws HotlayoutLoadException on a document the reader cannot use: MalformedJson, NotADocument, MaxDepthExceeded or IncompatibleVersion (a newer major version). HotlayoutThemeSet.read returns null for a theme it cannot read: use HotlayoutThemeSet.builtIn, a theme that cannot be read is not a reason to refuse a screen. Where the document and the theme come from is your choice: your backend after a signed webhook, or the published address of the project. See Delivery options.
Version compatibility#
checkDocumentVersion applies the version rule without reading the whole document, for a host that wants to decide before it loads (and keep the old screen when the new document is too new). It takes JSON text, bytes or a JsonValue and returns a VersionVerdict: Ok, NewerMinor, NewerMajor, OlderMajor or Unparsable. The rule is the same in every reader and is described in the compatibility policy: the major must match; a newer minor opens and the reader skips what it does not know.
| Library version | Schema | Platform |
|---|---|---|
| 0.5.0 | 9.34.0 | Android 8.0 (API 26)+, Jetpack Compose |
Older library versions and what they read are in Which package reads which schema.
Data#
data maps paths to values, dataScopeOf("user.name" to "Maya", "user.points" to 1850). The rules are the same for every reader: a number stays a number, dates are ISO 8601 strings with time and zone, a missing value shows the fallback of the document, and a list draws at most 50 records. Which paths exist is defined by the project's data dictionary; what your backend returns is in the backend guide. The reader takes the values you pass; it never calls your backend.
Language and right-to-left#
locale defaults to the device locale. The reader picks the document's language by the contract's rule, formats numbers and dates for it and mirrors the layout for right-to-left scripts. resolveLocale tells which translation, number format and direction a locale gets. See Localization.
Actions and navigation#
- Navigation inside a document (
navigate,navigate.back,navigate.section) is done by the reader;onNavigatemay cancel it by returningfalse. It is called after the haptic request and the analytics event of the same press, which are sent either way. openUrl,deeplink,closeand the actions of your app go toonAction. The reader opens nothing itself. A standard action arrives checked:runStandardAction(context, action)andStandardActions.urlcheck it again before they open it (the form of the address, the scheme of a deeplink against the allowed ones, noinvalid), so an action assembled by hand is not trusted because of its type.- A swipe-to-confirm button stays busy until the
JobyouronActionTaskreturns ends.
Swipe fill#
A swipe button with confirmTransition: 'ripple' that leads to another screen fills the screen with its colour before the screen changes. By default the fill lies over the HotlayoutScreen and the reader changes its own screens under it. If your app keeps its own stack of screens (you cancel the change with onNavigate and show your own view), pass onScreenCovered: the fill then lies in a layer over the whole window, above your views. The handler gets true when a fill starts to cover the screen, before the action reaches onNavigate or onAction (change your screen then, without a transition of your own), and false once the last fill is gone.
The system back key#
With handleSystemBack = true (the default) the reader takes the system back key (button, gesture, predictive back) as one step back of its own stack while the stack holds more than one screen and the top layer may be dismissed. With false, or at the first screen, the key goes to the host. A BackHandler the host registers after the reader's wins.
Writes#
Fields, setValue and toggleValue write to the session of the screen and to onWrite, already put into the session. writablePaths says which paths a document may write; by default every path of the form of a data path is allowed.
Images#
Images load only over https: by default. Pass an ImagePolicy as images to decide yourself. Its limits:
ImagePolicy |
|
|---|---|
resolve |
your decision about an address |
maxDownloadBytes |
10 MB: a larger picture is drawn as the placeholder, announced or streamed |
maxPixelSide |
4096: a picture is decoded sampled down so its longest side is at most this, the ratio kept |
totalDeadlineMs |
30 s: the whole transfer, not only the silence between two reads, so a server that drips bytes is disconnected |
fetch |
your own transport, suspend (address, maxBytes) -> ByteArray?, called with an address that has passed resolve. With it the reader makes no request of its own, so use it to give pictures a client with its own cookie jar, proxy and certificates. Return at most maxBytes; the call is held to totalDeadlineMs |
- The default loader sends and stores no cookies.
HttpURLConnectioncannot skip a global handler, so if your app has installed ajava.net.CookieHandler(CookieHandler.getDefault() != null), the default loader refuses to fetch: the placeholder is drawn and the reason goes toonReport. Hosts with a global cookie handler passfetch. data:addresses never touch the network (the cap andmaxPixelSideapply).
Fonts#
Fonts are yours: the theme names a family, HotlayoutFonts gives the FontFamily:
fonts = HotlayoutFonts { name -> if (name == "Inter") inter else null }A family you do not give is drawn with the system font. If the family has no Arabic (or other) letters, the platform draws them with a system fallback font, chosen by the weight of the typeface. Static font files, one per weight, keep the weight. A variable font must be wrapped so that each weight is its own typeface, built with Typeface.Builder(...).setFontVariationSettings("'wght' N").setWeight(N) (an AndroidFont with a TypefaceLoader); Font(resId, weight, variationSettings = …) alone draws bold Arabic as regular.
Accessibility and labels#
The few texts the reader says to assistive technology (the name of the back arrow) are not in the document. Pass HotlayoutLabels(back = …) in the language of your app as labels; the defaults are English.
Window insets#
The reader adds the status bar, the cutout, the navigation bar and the keyboard itself, minus whatever an ancestor already consumed (safeDrawingPadding(), a Scaffold, consumeWindowInsets). The recommended host draws edge to edge (enableEdgeToEdge()) and lets the reader add them; a host that pads works as well and gets nothing added twice. safeAreaTop and safeAreaBottom still override the window's values.
Give the activity android:windowSoftInputMode="adjustResize" (with an edge-to-edge window it only reports the keyboard as an inset and does not resize anything). With the default the system may pan the whole window when the keyboard comes, and a field low on the screen is then shifted instead of scrolled above the keyboard.
What the reader reports#
onReport receives a list of HotlayoutReport: what the reader skipped or replaced while reading the document, the theme or the data, for your log or analytics. It is never shown to the person.
- Documents and themes are untrusted input. A piece of the wrong shape is skipped and reported, never a crash.
- Depth. A screen deeper than 32 levels is skipped (and reported); the other screens of the document still load.
- Theme numbers. A number of a theme outside the bounds of the theme schema (or not finite) is replaced by the value of the same token in the built-in theme of the same brightness, otherwise by the nearest bound, and reported. Every length of a document is made finite, not negative and bounded.
- Haptic feedback is asked of the host (
onHaptic); the reader never vibrates.
The trust boundary and the tests that pin it are described in the security notes of the library.
Placements#
A placement is a named place of your app (home.banner). Which published document stands in it is decided in the admin, not in your code: you write the name once, and the screen changes without a release. The files and the rules of the map are in Placements; this part is what the Android reader does with them.
// Once, in your application object: one source serves every placement of the app.
val placements = PlacementSource(
projectBase = "https://storage.example/orgs/acme/projects/app",
bundledMap = context.assets.open("placements.json").bufferedReader().readText(), // optional: the map of this release
)
@Composable
fun HomeBanner() {
HotlayoutPlacement(
name = "home.banner",
source = placements,
onAction = { action, _ -> /* as for HotlayoutScreen */ },
onPlacement = { load -> if (load.report != null) Log.i("placement", "${load.name}: ${load.report}") },
)
}HotlayoutPlacement draws the document of the place with HotlayoutScreen (it takes the same parameters, and starts on the entry point the map names) and nothing when the place is empty, unknown, unreadable, or its document cannot be had. onPlacement hears every result with a PlacementLoad: report is Empty, Unknown, Unreadable, DocumentMissing, DocumentUnreadable, or EntryMissing (the entry point is gone from the document, which is drawn from its first screen), and null when all is well, so you can hide the frame around a place that draws nothing. Without Compose, loadPlacement(source, name) is the same as a suspend function; it runs off the main thread and never throws on a bad answer.
- The map is
placements/published.jsonunderprojectBase, read without a token and asked again everyrefreshIntervalMs(a minute by default,nullfor once; not below 5 seconds) with itsETag, so an unchanged map costs no body. - The network wins. When it is silent, broken, or of another major version, the last known map is used, then
bundledMap. Only a fresh, readable map from the network becomes the last known one. A fresh map is the whole answer: a place it does not have isUnknown, it is not looked up in an older map. PlacementLoad.mapStatustells the cases apart:Offline(the network was silent),Corrupt(it answered with something that is not a map),Unsupported(another major),NotPublished(404: the project has no placements).- Documents go the same way: the network, the last known copy, then
bundledDocuments. The last known map and documents live in memory; givePlacementStorea file or preferences to keep them over a restart. - A name that is not the name of a place is
Unknownwithout a look at the map; a place of the map is found only by its own key. The map and the documents come from the network, so their ids are checked by form before they become an address. - The default transport is
httpsonly, follows no redirects, sends no credentials of its own (an app-wideCookieHandlerdoes not stop it and may add cookies the app itself stored for the storage host), gives a request 20 seconds from start to the end of the body, and reads at most 256 KB (262 144 bytes) of map. GivePlacementTransportto use your own client. A map body must be UTF-8 (one leading byte order mark is skipped) and at most 32 levels deep, or it isCorrupt. onPlacementis called for the first result and whenever the place changes: what is drawn, the report, the map status, the document or the entry point. A placement that comes back to a shown document is told too (report == null). The last known copies are kept under the address of their file, and a document that answered 404 is dropped from the store (PlacementStore.remove).
Schedule#
A place may carry a schedule (placement map 1.1.0): windows of time in which another document, or nothing, stands in it instead of its permanent content. Your code does not change: HotlayoutPlacement reads the place against the clock and swaps the document at the right moment, with no network at that moment.
- An instant is UTC, whole seconds (
2026-11-27T22:00:00Z); a window lasts from itsfrom(included) to itsuntil(excluded). A window with no document is a pause: the place isEmptyfor its length, and then the permanent content is back. When two windows overlap, the one that started later wins. - The clock is the device clock. Pass
clock = { … }(milliseconds since the epoch) toPlacementSourceto pin or shift time in tests.loadPlacementreads the place at that clock too. HotlayoutPlacementsets one timer at the next boundary of the schedule and decides again there, on every new reading of the map, and when the app returns to the foreground (the clock may have jumped). The map is not asked again at a boundary. A broken window is skipped on its own; the place stays as it was.onPlacementhears the swap like any other change (documentId/entry, andEmptywith the document back). A place that isEmptyin a pause is not an error: hide the frame around it and its own padding or margin, and keepHotlayoutPlacementitself in the composition, so the banner comes back on time:
var banner by remember { mutableStateOf(true) }
Column(Modifier.padding(horizontal = 16.dp)) {
Box(Modifier.then(if (banner) Modifier.padding(bottom = 12.dp) else Modifier)) {
HotlayoutPlacement("home.banner", placements, onPlacement = { banner = it.document != null })
}
}A reader older than 1.1.0 of the map shows the permanent content of a place and ignores its schedule.
Limitations#
- Blur needs API 31. The blur under a modal layer and the glass of a node need a render effect, which exists from API 31 (Android 12). Below that the picture stays sharp and nothing is blurred: the page under a sheet is only dimmed and glass is drawn without blur.
- Fonts are yours (see Fonts); the library ships none.
- Where the Android platform and the shared case tables differ, and what the reader does, is in the platform notes of the library.
Next#
- Quickstart for Android — the path from an empty app to a published screen.
- Widget reference — what each widget takes.
- Compatibility policy — what a reader does with a document newer than itself.