Generate a ready-to-use kiosk application with a predefined application layout and custom wallpaper. Supports managed config for ongoing customisation and management. Designed to work in tandem with EMM application policies.
The Bayton Kiosk App Generator produces a signed Android launcher application that takes over the home screen and presents a fixed grid of apps to the user. Designed for dedicated devices, single-purpose kiosks, and EMM-managed deployments where the operator wants to lock the device to a curated set of apps.
Every option in the form is also exposed as an Android managed configuration. An EMM admin can override layout, wallpaper, settings access, app list - anything - at runtime, without rebuilding the APK. The configuration baked into the APK at build time is purely the default.
Generated launchers target API 37 (Android 17) and require API 28 (Android 9 Pie) or above to install. Android 9 is the lowest level that supports every option this generator exposes; if your fleet runs older devices and you only need a subset of options, lower the minSdk in the downloaded source before building. Test on the target API level first - some surfaces (predictive back, adaptive icons, network-security-config, edge-to-edge insets) behave differently on older releases.
A KAG-generated APK is a home-app candidate, not a self-locking kiosk. End-to-end device lockdown is the responsibility of the DPC / management policy. The launcher itself only supplies the curated app grid, settings menu, and theming; everything else (Recents, the notification shade, restricting installs, sideload, factory reset) is the policy layer's job.
Important for both paths below: every app that appears in the kiosk grid must be installed AND allowlisted by the management policy in its own right. The launcher renders the icons KAG bakes into the config, but launching a tile only works if the target package is installed on the device and the policy permits it to run. A package that's in the grid but not in the policy's applications list (AMAPI) or not allowed by the DPC will show a placeholder icon and fail to launch with "App not installed".
In your device policy, add the generated package to the applications list and set its application role to Kiosk. Per the September 2025 release notes, AMAPI introduced typed application roles as the more granular successor to the combined installType: KIOSK value; the legacy form still works, but new deployments should prefer the role. Constraints to be aware of when picking how the app installs:
At the policy root, configure the lockdown surface via kioskCustomization. It applies alongside a custom kiosk launcher (KAG, via the KIOSK role or the legacy installType: KIOSK) — not only with the AMAPI-built-in launcher. Available fields:
statusBar — NOTIFICATIONS_AND_SYSTEM_INFO_DISABLED (default for kiosk mode), SYSTEM_INFO_ONLY, or NOTIFICATIONS_AND_SYSTEM_INFO_ENABLED.systemNavigation — NAVIGATION_DISABLED (default), HOME_BUTTON_ONLY, or NAVIGATION_ENABLED.deviceSettings — SETTINGS_ACCESS_BLOCKED or SETTINGS_ACCESS_ALLOWED. When KAG's settings gear is pointing at the specific system panels you want exposed, blocking the global Settings app is highly recommended: the gear's deep-link intents still launch their target activity directly, while the lockdown stops end-users from navigating sideways into Settings screens you didn't intend to expose.powerButtonActions — long-press Power behaviour. Consult the KioskCustomization reference for the current enum values.systemErrorWarnings — whether system "app crashed / not responding" dialogs are blocked. When blocked, the system force-stops the crashed app instead of prompting.For finer device-wide restrictions beyond kioskCustomization, use top-level Policy fields: screenCaptureDisabled, factoryResetDisabled, adjustVolumeDisabled, mountPhysicalMediaDisabled, etc. (the older safeBootDisabled, usbFileTransferDisabled, and modifyAccountsDisabled booleans are deprecated in favour of developerSettings, usbDataAccess, and account-restriction policies respectively — check the current Policy reference for the live names).
From your DPC, with device-owner privileges:
DevicePolicyManager.setLockTaskPackages(adminName, new String[]{ kagPackageName, ...tileTargetPackages }) — allowlist the launcher AND every tile-target package for lock-task mode. Apps absent from this list can't launch while the device is pinned.DevicePolicyManager.addPersistentPreferredActivity(adminName, homeFilter, kagComponent) with an IntentFilter containing CATEGORY_HOME + CATEGORY_DEFAULT — makes KAG the device's default home so it boots into the kiosk without a chooser.DevicePolicyManager.setLockTaskFeatures(...) to match the experience you want. The bit-flag list (LOCK_TASK_FEATURE_HOME, OVERVIEW, NOTIFICATIONS, SYSTEM_INFO, KEYGUARD, GLOBAL_ACTIONS, BLOCK_ACTIVITY_START_IN_TASK) is a DPM API exposed only to custom DPCs; AMAPI doesn't surface it directly and folds equivalents into kioskCustomization.On any Android device without a DPC enrolled: install the APK, then Settings → Apps → Default apps → Home app → select the kiosk. No lock-task mode is engaged; Recents, notifications, and the gesture nav stay live. Useful for visual validation, not for shipping.
The launcher declares QUERY_ALL_PACKAGES to resolve icons and labels for arbitrary apps in the grid. It's a restricted permission on Google Play, but Bayton-built kiosks are distributed via EMM rather than Play, and Android auto-grants restricted permissions to packages installed by a device owner / EMM — no explicit policy permissionGrants entry or runtime prompt needed.
Single-app focus and Android 16+ large screens. Earlier KAG releases set android:resizeableActivity="false" to block split-screen / freeform; that attribute is now omitted because Android 16+ ignores it on large-screen devices, and even on older releases it's only a hint - the real "launcher owns the screen" guarantee comes from lock-task mode pushed by your DPC / AMAPI policy. Practical implication: on phones and small tablets the launcher behaves as before. On Android 16+ tablets, foldables, and other large-screen form factors the system may allow the user to split-screen the launcher with another app unless lock-task is engaged. If full-screen lockdown matters on those devices, configure lock-task at the policy layer; that's the only mechanism that's guaranteed across form factors.
Each row maps one Android package to a grid cell. Add a row, paste a package name (e.g. com.android.chrome), pick a row + column. The label defaults to whatever the installed app reports; override it if you want to display something different to your users.
Tiles for apps not currently installed on the device still render (with a placeholder icon and greyed label) so the grid layout stays consistent across devices that haven't finished provisioning yet.
EMM admins can also replace the whole grid at runtime via the applications managed-config bundle array - each entry is {package, row, col, label}. Pushing a non-empty applications array replaces the generated grid wholesale; partial overrides aren't supported. To redefine the grid while preserving everything else, use the kiosk_config_json escape hatch instead.
A folder occupies a single grid cell and opens to show 2-9 contained apps in a count-sized modal overlay (2-3 columns and 1-3 rows depending on how many apps it holds; the 3×3 grid is the cap at 9 apps). The closed tile renders a Pixel-style circular backdrop with a 2×2 mini-preview of the first contained icons.
setLockTaskPackages allowlist (Custom DPC) or Application.installType policy (AMAPI). The folder UI allows the user to tap; Android's lock-task enforcement is a separate gate. Forgetting to allowlist a contained app means tapping it does nothing.com.android.chrome in a "Browse" folder and also pin it as a top-level tile - operator's choice.#rrggbb text input; leave the text blank for the default tint). When set, the colour is used as the backdrop of the open folder only - the closed mini-preview tile keeps its default grey backdrop circle so the main grid stays uniform. The folder title and app labels switch between black and white text automatically based on the backdrop's luminance, so any colour you pick stays readable.EMM admins can replace folders at runtime via the folders managed-config bundle array (each folder bundle contains a nested folder_apps bundle array). Pushing a non-empty folders array replaces the generated folder set wholesale; partial overrides aren't supported. AMAPI iframe note: nested bundle arrays may not render in every EMM console - if your console can't push folders typed, use the kiosk_config_json escape hatch and put the folders JSON directly in that blob.
Folder key names. The Android app_restrictions.xml schema must use prefixed key names (folder_name, folder_row, folder_col, folder_background_color, folder_apps, folder_app_package, folder_app_label) for folder fields, because Android lint enforces globally unique restriction keys across the whole manifest - the bare name / row / col already exist on the parent restrictions, so nested fields collide. The form-side config_json import + the EMM kiosk_config_json escape hatch don't share that constraint, so both naming conventions parse as equivalent:
| JSON field (either form is accepted) | Where the prefixed form is required |
|---|---|
name or folder_name | EMM typed restriction folders[].folder_name |
row or folder_row | EMM typed restriction folders[].folder_row |
col or folder_col | EMM typed restriction folders[].folder_col |
background_color or folder_background_color (#rrggbb) | EMM typed restriction folders[].folder_background_color |
apps or folder_apps | EMM typed restriction folders[].folder_apps |
package or folder_app_package (inside the apps array) | EMM typed restriction folders[].folder_apps[].folder_app_package |
label or folder_app_label (inside the apps array) | EMM typed restriction folders[].folder_apps[].folder_app_label |
Recommendation: author with the prefixed (folder_*) names everywhere. That's the form the EMM typed-restriction console will show you, and the JSON parser accepts it identically. You learn one schema and use it in either delivery channel. If both forms appear in the same JSON object, the prefixed form wins.
Bundle-array best practice (applies to folders and the nested folder_apps): the per-item Bundle in a bundle_array carries the inner fields at its top level - not nested under a wrapper key. The Android app_restrictions.xml schema declares a wrapper child inside each bundle_array because lint requires it (<restriction key="folder" restrictionType="bundle">), and the EMM admin console renders that key as the per-row label, but the wrapper does not appear in the delivered Bundle at runtime. The JSON path follows the same shape as the runtime: each array element is a flat object with the inner keys directly on it.
Correct (matches our parser, matches what RestrictionsManager delivers):
{
"folders": [
{
"folder_name": "Tools",
"folder_row": 1,
"folder_col": 0,
"folder_apps": [
{ "folder_app_package": "com.android.settings" },
{ "folder_app_package": "com.google.android.deskclock" }
]
}
]
}
Wrong (the wrapper keys belong only in the manifest schema, not in delivered data):
{
"folders": [
{
"folder": { // ← do not add
"folder_name": "Tools",
"folder_apps": [
{ "folder_app": { "folder_app_package": "…" } } // ← do not add
]
}
}
]
}
KAG's only nested bundle array is folders (with folder_apps inside each folder), so this is the only place the wrapper-vs-no-wrapper distinction bites - but the rule generalises to any Android managed-config schema. If you're hand-crafting AMAPI policy JSON or pushing kiosk_config_json through a console that doesn't render the typed restriction natively, keep the inner fields flat on each array element.
KAG ships a bundled default sunset gradient wallpaper so a blank-form build still looks intentional. The operator's choices on this form decide whether that default ships:
assets/wallpaper.bin. The default sunset isn't shipped.EMM admins can also push a wallpaper.url field in the managed-config payload at runtime to swap the wallpaper without rebuilding the APK. https only - kiosks live on managed networks where cleartext fetches are MITM-able, and the Android network-security-config blocks http:// by default on API 28+. The launcher fetches the URL in the background, caches the result keyed by URL hash, and falls back to the bundled file while the fetch runs. Same URL = cached forever - operators who want to swap the image push a new URL (typically with a content hash or version suffix in the path). This avoids re-downloading the same wallpaper on every launcher resume, which matters on metered EMM SIM connections. URL fetches are bounded to 10 MiB and decoded to a 4096 px long edge.
To suppress the wallpaper entirely at runtime and use the theme colour as a solid background, an EMM admin can push wallpaper.bundled_file = "" with no URL.
The launcher exposes a small overflow gear (top-right of the screen) that opens a Material-styled popup listing whichever system-settings shortcuts the operator picks: Wi-Fi, Bluetooth, Display, Sound, Language, Battery saver, Date & time, Apps. Each item launches the matching Settings.ACTION_*_SETTINGS intent.
Pick the panels you want exposed; everything else stays inaccessible from the launcher. For full lockdown, leave all of them unchecked - the gear disappears entirely.
EMM admins get a typed bundle of booleans for this in managed config: a master settings.enabled toggle plus per-panel settings.wifi, settings.bluetooth, etc. The gear is hidden unless enabled = true AND at least one panel is on. An admin pushing a bundle with all-false values is treated as "loaded defaults, didn't edit" and falls back to the generated panel set rather than blanking the gear.
Recommended: pair the gear with policy-side Settings lockdown. Exposing a panel via the gear sends the user into a real system Settings activity, and from there they can typically navigate sideways to other Settings screens you didn't intend to expose. Block that escape route at the policy layer: in AMAPI, set kioskCustomization.deviceSettings = SETTINGS_ACCESS_BLOCKED; in a custom DPC, apply the equivalent DISALLOW_* user restrictions. The gear's deep-link intents (ACTION_WIFI_SETTINGS etc.) still launch their target activity directly, but in-Settings navigation away from that activity is suppressed.
An optional short message rendered in the launcher's top strip, to the left of the settings gear. Useful for site name ("Welcome to Building A"), shared-credential hints ("Wi-Fi: lobby2024"), or short operator instructions. Capped at 80 characters - longer messages would push into the gear's space, so the launcher ellipsizes overflow on a single line.
The banner sits inside the same readability pill as tile labels when a wallpaper is loaded, so it stays legible against any image without darkening the wallpaper. With no wallpaper, the pill is dropped and the text sits directly on the theme colour.
EMM admins can swap it at runtime via the banner_text managed-config restriction with no rebuild.
Sets the splash-screen background, the system bar tint while the wallpaper decodes, and the fallback launcher background when no wallpaper is present (default-wallpaper not bundled and no EMM-pushed override). Because KAG now ships a default sunset wallpaper, the theme colour is rarely the primary visible background - it's mostly chrome and a safety net. Defaults to a dark navy that reads well behind the default wallpaper during startup.
When no wallpaper is visible (no bundled file and no wallpaper_url), text and status / nav bar tint follow the theme colour automatically via WCAG relative luminance: a dark theme gets white text and light system-bar icons, a light theme gets dark text and dark system-bar icons. The operator only picks the background colour; the readable foreground is chosen at runtime. With a wallpaper present, labels switch to white-on-pill mode so they stay readable against any image and the theme colour stops being visible.
EMM admins can also push a theme_color managed-config string at runtime as an alternative to wallpaper_url. The launcher repaints the activity background to the pushed colour and re-runs the contrast calculation. Accepts #rgb or #rrggbb; shorthand is expanded equivalent to the JSON path. Caveat: the splash screen still shows the APK-generated theme colour for a moment on cold start because the splash draws before managed config is read; the override only affects post-splash UI. When both theme_color and a wallpaper are set, the wallpaper wins visually and the theme colour stays the fallback if the wallpaper fetch fails.
Upload a 512 × 512 px PNG. The generator produces all mipmap densities and the adaptive-icon variants automatically. A default placeholder is used if you don't upload one.
Icon is not stored, and will need to be provided on subsequent builds. The config export / import flow round-trips every form field but the icon - re-attach it manually when rebuilding from a previously exported kiosk_config.json or against a typed update_code. Submit without an icon and the default is used.
From September 2026, Android enforces a developer verification requirement on certified Android 7+ devices: apps must be registered against a verified Google developer account (Play Console or Android Developer Console) to install on consumer devices. Enforcement begins in Brazil, Indonesia, Singapore, and Thailand, with global rollout from 2027. Managed-device deployments are exempt.
Every fresh build returns an update code. Store it securely - it is never shown again. Supplying it on a future build reuses the same Android package name, which is required for EMM silent-update flows.
The submission form's "Import config" button accepts a previously exported kiosk_config.json. The form is repopulated from the imported JSON, the update code is carried across, and the build resumes against the same package name. After each successful build the JSON is also available as a separate download alongside the APK. The icon is not stored server-side, so it will need to be provided again on subsequent builds; submit without one and the default is used.
Every option on this form is also a typed Android managed-config restriction. EMM consoles (Workspace ONE, Intune, Hexnode, etc.) render them as a proper form so admins don't have to hand-craft JSON. Each typed override is validated against the same schema as the generated default - invalid values fall back to the generated value per-key rather than corrupting the runtime config.
orientation)auto / portrait / landscape. Empty = use generated default.rows, cols)icon_size)small / medium / large. Empty = use generated default.wallpaper_url)https:// URL fetched at runtime, cached on internal storage indefinitely (same URL hash = same cache slot). Falls back to the bundled file while the fetch runs; if the fetch fails, the bundled file stays. Bounded to 10 MiB and decoded to a 4096 px long edge. Takes precedence over theme_color when set.theme_color)#rrggbb or #rgb; shorthand normalised to canonical 6-digit). Activity background + system-bar tint + auto-contrast text and icons (WCAG threshold 0.5). Empty or invalid = use the APK-generated colour. The splash screen still flashes the generated colour briefly on cold start because it draws before managed config is read; only post-splash UI honours the runtime override.banner_text)settings bundle)enabled) plus per-panel toggles: Wi-Fi (wifi), Bluetooth (bluetooth), Display (display), Sound (sound), Language (language), Battery saver (battery), Date & time (datetime), Apps (apps). All-false push = treated as "loaded defaults, didn't edit", falls back to generated settings.applications bundle array){package, row, col, label}. Non-empty push replaces the generated grid wholesale - partial overrides aren't supported. An array of all-invalid entries renders an empty grid (intentional: surfaces the admin's bad config rather than silently masking it).folders bundle array)folder_name, folder_row, folder_col, optional folder_background_color (#rgb or #rrggbb), and a nested folder_apps bundle array whose entries hold folder_app_package and optional folder_app_label. Keys are prefixed because Android lint enforces globally unique restriction keys across the schema. Non-empty push replaces the generated folder set wholesale. Some EMM consoles can't render nested bundle arrays - use kiosk_config_json as the escape hatch in that case.kiosk_config_json)Live reload: the launcher subscribes to ACTION_APPLICATION_RESTRICTIONS_CHANGED and re-renders on every EMM push, no app restart needed.
Download links expire 5 minutes after first click; build files are purged at that point. All builds are purged after 24 hours regardless of download activity.
The KAG server itself is versioned (semver). Every APK is stamped with the server version that produced it, plus a build timestamp. Five ways to read the stamp, ranked by ease:
<app-name>-kag-v<version>.apk (e.g. my-kiosk-kag-v1.0.0.apk). At-a-glance lookup; lost if the file is later renamed.unzip -p <file.apk> assets/kag-build.txt returns three lines: builder: kag, version: ..., build_timestamp: .... Universal unzip tool; works after rename.strings <file.apk> | grep '^builder:' surfaces the same three lines without any extraction.adb shell dumpsys package <pkg> | grep org.bayton.kag reads the manifest meta-data tags./api/status/<id> response includes builder_version; GET /api/version returns the current KAG release.Quote the version in any support ticket so the issue can be tied to the exact build of KAG that produced your artefact.