Developers
Kiosk update protocol
On this page
This page describes, from end to end, how a new version of the kiosk app gets from your build machine onto a frame: what the server offers, what the frame checks before it installs, what happens if the new version does not work, and what the frame reports back. It is for developers who change the updater, the release endpoints or the boot script, and for reviewers who want to check the security claims.
If you only want to publish an update, follow Update the kiosk. For why each check exists, read Security.
The idea in four lines
- The server only offers. It stores signed builds and tells each frame which one to take.
- The device decides. The frame installs a build only if it passes every check on the device itself.
- Two keys, both off the server. Android's package signing key keeps the app lineage. A separate, offline release key approves each specific build.
- A build that does not prove itself is undone. A root script on the frame reinstalls the last build that ran well.
Who does what
| Part | Where | Role |
|---|---|---|
Build script (kiosk/build.sh) |
Your machine | Builds and signs the APK; writes the detached release signature |
Release endpoints (routes/admin_kiosk.php, kiosk_update.php) |
Server | Check, store and offer releases; track what frames report |
Updater (Updater.java) |
Frame, app | Downloads, verifies, installs, reports |
UpdateResultReceiver, ReplacedReceiver |
Frame, app (not exported) | Learn the installer's verdict; restart the app after a replace |
Boot script (device/frame-fixmac.sh) |
Frame, root | The rollback watchdog |
End-to-end sequence
sequenceDiagram
participant B as Build machine
participant A as Admin
participant S as Server
participant K as Kiosk (Updater)
participant P as Android installer
participant W as Boot script (root)
B->>B: build APK, sign it (v1 and v2)
B->>B: sign "sha256:versionCode" with the release key
A->>S: POST /api/kiosk-releases (APK, X-Release-Signature)
S->>S: check, store as var/kiosk/sha256.apk
A->>S: PATCH /api/frames/id {kiosk_target_id}
Note over S: or POST /api/kiosk-releases/id/rollouts
K->>S: GET /api/v1/frames/id (Authorization header)
S-->>K: kiosk_update {version_code, sha256, size, signature, attempt}
K->>K: check signature, size, device owner
K->>S: GET /api/v1/frames/id/kiosk-releases/sha256
S-->>K: the APK
K->>K: check hash, ZIP layout, package, version, certificates
K->>K: wait for a quiet moment, check again
K->>K: write files/update/state
K->>P: session, write APK, commit
P->>P: verify certificate, replace app
P-->>K: app restarts (ReplacedReceiver)
K->>S: self GET, dashboard loads
K->>K: write files/update/healthy, delete state
K->>S: PUT telemetry {version_code}
Note over S: release is now proven
W->>W: every 10 s: compare healthy, state, installed
1. Publish a release
Build
kiosk/build.sh makes the APK and, when RELEASE_KEY is set, a detached signature in frame-kiosk.apk.sig.
- The APK is signed with both the v1 (JAR) and v2 schemes, with
--min-sdk-version 23, and the script verifies both. Android 6 checks only v1; newer versions prefer v2. - The release signature is ECDSA P-256 with SHA-256 over these ASCII bytes:
<sha256 hex of the APK>:<versionCode in decimal>, for exampleab12…ef:15. The file holds the base64 of the signature's DER encoding. - The matching public key (
RELEASE_PUBKEY) is compiled into the kiosk asBuildCfg.RELEASE_PUBKEY(the base64 of its X.509 encoding). With no public key in the build, the kiosk refuses every update. versionCodeonly ever goes up. To roll back, rebuild the old code with a higher number.
Upload
POST /frames/admin/api/kiosk-releases (admin session, CSRF token, password re-entered in the last five minutes) with:
Content-Type: application/vnd.android.package-archiveX-Release-Signature: <base64 DER>- the raw APK as the body, at most 16 MiB (
Content-Lengthover the cap is refused before reading)
The server (kiosk_release_store()):
- Streams the body to a temp file in
var/kiosk/(mode 0640) with a running byte count, hashing as it goes. - Reads the file with a pure-PHP reader. It must start with a ZIP local header; the central directory must end exactly at the end-of-central-directory record; every entry's header must lie inside the file body and one must be at offset 0; at most 10,000 entries. That blocks "Janus" tricks. Only the manifest is inflated (1 MiB cap) and its binary XML is read with every offset checked. A file that is not a plain ZIP is
415 not_apk; one that is a ZIP but unusable is a422. - Requires the package name
com.calevans.frame, a version code from 1 to 2,100,000,000, and a version name of 1 to 64 bytes without control characters. - If
kiosk_release_pubkeyis set inconfig.php, verifies the signature against it and rejects the upload on failure. Without it the signature is stored as given, and the kiosk verifies it. - In one transaction, rejects a hash or version code that already exists (
409 duplicate_sha256,409 duplicate_version_code), inserts the row, and atomically renames the file tovar/kiosk/<sha256>.apk.
The server never judges the APK's signing certificate. The device does.
Point frames at it
PATCH /api/frames/{id}withkiosk_target_idsets or clears one frame's target.POST /api/kiosk-releases/{id}/rolloutstargets every active frame that reports a lower version and is not already aimed at this version or a newer one.- Claiming a new frame aims it at the newest proven release, unless the same request names one.
Staged rollout. A release is proven when some frame fetched it in full (a row in kiosk_downloads) and later reported a version_code at least as high. A frame that has no rollback watchdog, and any "roll out to everyone", may only target a proven release (409 release_not_proven). So a release first goes to a frame that can undo it.
2. The offer
The frame's self GET carries a kiosk_update object when all of these hold:
- the request used the
Authorizationheader (never the cookie), - the frame has a target release,
- the frame has not reported a
version_code, or reported a lower one.
"kiosk_update": {
"version_code": 15, "version_name": "15", "sha256": "ab12…", "size": 2201344,
"signature": "MEUCIQ…", "attempt": 0
}
There is no URL in the offer, on purpose. attempt rises each time an admin clears a failure for the frame.
The kiosk calls Updater.offer() after every successful self GET. A missing object clears any prepared update. A malformed object, or a version_code that is not above the installed one, is ignored. An offer is also ignored while an install is being committed, when the same hash already failed permanently and the offer's attempt is not higher than the one recorded with the failure, or when that release is already prepared.
3. Download and verify, on the device
Updater.process() runs on its own thread, in this order. The first failing step ends it.
| # | Check | Failure reason | Permanent |
|---|---|---|---|
| 1 | The hash was marked failed and the attempt is not higher |
(skip) | |
| 2 | The previous attempt is still backing off for this hash | (skip) | |
| 3 | size is 1 byte to 32 MiB |
size |
Yes |
| 4 | The build has a release public key | release_sig |
Yes |
| 5 | ECDSA P-256 signature over <sha256>:<version_code> verifies |
release_sig |
Yes |
| 6 | The app is the device owner | not_owner |
No |
| 7 | Old *.part and *.apk files for other hashes are deleted |
||
| 8 | Download (below), unless <sha256>.apk already exists |
download, size, hash |
See below |
| 9 | Verify (below) | See below | Yes |
Download
The kiosk builds the address itself: SERVER_URL from its build, then api/v1/frames/<its own id>/kiosk-releases/<sha256>. Any address the server sent is ignored.
- Requires free space of at least twice the size plus 32 MiB, and an
https://server address. Authorization: Frame <id>.<key>,Accept-Encoding: identity, 15-second connect and 30-second read timeouts, no redirects, no cache.- The status must be
200(on429it honorsRetry-After), the content type must be exactlyapplication/vnd.android.package-archive, and a declared length must equal the offered size. - The body goes to
<sha256>.partwhile the SHA-256 is computed. It stops withsizeif more than the offered size arrives, and withdownloadafter a 10-minute deadline or if fewer bytes arrive. Afterfsync, a hash mismatch ishash, and a match is renamed to<sha256>.apk.
Verify
verify() runs after the download and again just before the commit:
- The file length equals the offered size (
size) and its SHA-256 equals the offer (hash). - The ZIP layout check (
plainZip): begins withPK\3\4; one disk; no ZIP64; at most 10,000 entries and a 4 MiB central directory; the central directory ends exactly where the end record begins; every entry's local header lies before it and is a real local header; one entry is at offset 0 (not_apk). - Android can read the archive (
getPackageArchiveInfo) (not_apk). - The package name is the app's own (
package). - The archive's
versionCodeequals the offered one (version_mismatch) and is higher than the installed one (not_newer). - The archive's signing certificates are exactly the installed app's: the same set, both ways, never empty (
cert).
All of these failures are permanent for that hash. The downloaded file is deleted.
Back-off
A transient failure (download, not_owner, a missed deadline, a lack of space) retries after 5 minutes, doubling up to 6 hours, or the Retry-After the server sent if that is longer. The delay resets after a success.
4. Install when nobody is looking
A verified build waits in ready. Every 30 seconds the main thread asks the activity whether it is quiet:
- the network is up,
- the admin panel is closed, and
- the screen is dimmed (bedtime, or the Dim button), or nobody has touched it for 30 minutes.
When quiet, install():
- Checks device owner and runs
verify()again. - Adds one to the per-hash try counter (
attempts, at most 3 tries). - Opens a
PackageInstallersession inMODE_FULL_INSTALL, writes the APK, and callsfsync. - Writes
files/update/stateatomically (from,to,sha,attempt, and the kernel's boot id). - Creates an explicit-component
PendingIntentforUpdateResultReceiverand commits.
Android checks the new package's certificate against the installed one, replaces the app and kills it. ReplacedReceiver starts the activity again. The kiosk never shows the installer's confirmation screen: a session that wants the user is abandoned and counted as a failed install.
If the installer reports a failure, UpdateResultReceiver writes the status to files/update/result, and the updater records install_<status>. It retries with back-off until the third try, then holds the failure permanently.
5. Proving health
After the restart the new version must show it works. It writes files/update/healthy (its own versionCode) and deletes state and result when either:
- the first self GET returned
200and the dashboard has loaded, or - the activity has been resumed for five minutes without crashing, whatever the network does.
Health therefore does not depend on the server being reachable.
On every later start, startup() looks at state:
| What it finds | What it concludes |
|---|---|
state.to is not above the running version |
The update succeeded; health will clear it |
state.to is above the running version and result holds a status |
The installer failed: record install_<status> |
state.to is above the running version, no result, and the boot id is the same or three tries are used up |
The new build never ran well and the watchdog rolled back: record unhealthy, permanent |
state.to is above the running version, otherwise |
Power was cut mid-install: the offer comes again and counts as another try |
stateDiagram-v2
[*] --> Idle
Idle --> Checking: offer in self GET, version higher
Checking --> Held: format bad, release_sig, size
Checking --> Downloading: signature ok, device owner
Checking --> Backoff: not_owner
Downloading --> Backoff: network, space, deadline
Downloading --> Held: size, hash
Downloading --> Verifying: file complete
Verifying --> Held: not_apk, package, version, cert
Verifying --> Ready
Ready --> Committing: quiet moment, verified again
Committing --> Probation: installer replaces the app
Committing --> Backoff: install failed, tries left
Committing --> Held: install failed, third try
Probation --> Healthy: self GET and dashboard, or 5 minutes
Probation --> Held: rolled back by the watchdog (unhealthy)
Backoff --> Checking: delay over
Held --> Checking: admin clears the failure (attempt rises)
Healthy --> Idle
Held is a permanent failure for one hash and one attempt number. It lives in files/update/failed, so it survives restarts. It ends when the admin clears the failure (the offer's attempt rises) or when the server offers a different hash.
6. The rollback watchdog
device/frame-fixmac.sh runs as root from boot. Every 10 seconds, once sys.boot_completed is 1 and a timeout command exists, it runs the watchdog tick. It trusts nothing the app wrote.
Files
| File | Owner | Written by | Holds |
|---|---|---|---|
…/files/update/healthy |
App | App | The version that proved itself |
…/files/update/state |
App | App | from, to, sha, attempt, boot |
…/files/update/result, failed, attempts |
App | App | Installer status; permanent failure; try count. Root never reads these |
/data/misc/frame/kiosk-good.apk |
Root (0700 dir) | Root | A copy of the last build that ran well |
/data/misc/frame/kiosk-good.vc |
Root | Root | Its version code |
Property debug.frame.watchdog |
Root | Root | 1 while the watchdog can really work, else 0. Apps cannot set debug.* properties |
Property debug.frame.rolledback |
Root | Root | The version code that was rolled back; not persistent |
Root reads healthy and the to= line of state only after checking each is a regular file, not a symlink, with no symlink in the path above it, owned by the app's user id, and read through timeout dd with a size cap so a named pipe cannot hang the script. Each number must be digits only, with no leading zero, at most 10 characters, and at most 2,100,000,000, before it is used in any comparison or arithmetic. Root writes nothing under the app's data directory.
The tick
- Read
H(healthy),T(thetoinstate) andG(the saved good version). - Publish
debug.frame.watchdogas1ifGexists, else0. - No good build saved yet. Save the installed one, at most 3 tries per boot, but only if it has proven itself (
Hequals the installed version) or no update is in progress at all (nostatefile). The copy is taken frompm path, and only an expected path (/data/app/<package>-*/base.apk) is accepted. - Nothing to do if
His empty or equalsG, andTis empty or equalsH. Reset the counter and clear the rollback marker. - If
Hequals the installed version and differs fromG, the new build earned its place: save it asG. - Suspect if
Tequals the installed version,Tdiffers fromH, andGis lower than the installed version. Count one bad tick. Otherwise reset the counter. - At 90 bad ticks in a row (about 15 minutes), and only if the rollback marker is not already
T: set the marker, copykiosk-good.apkto a fresh unique name in/data/local/tmp, runpm install -r -don it, delete the copy, and start the kiosk.
stateDiagram-v2
[*] --> Unarmed
Unarmed --> Armed: a proven build is saved as the good one
Armed --> Armed: healthy equals good, no update pending
Armed --> Suspect: update installed, not healthy, good is lower
Suspect --> Armed: app writes healthy
Suspect --> RollingBack: 90 bad ticks in a row
RollingBack --> Armed: good build reinstalled, marker set
The restored build is exactly the file root saved, and Android still checks its signature. The most a hostile server can cause is a return to the last build that ran well on that frame. Once the old build runs, its startup() sees an unfinished update with the same boot and records unhealthy; the failure is permanent, so the frame does not try that build again until an admin clears it.
7. What the frame reports
The kiosk's telemetry carries three update fields (PUT /api/v1/frames/{id}/telemetry):
| Field | Source |
|---|---|
version_code |
The installed build's versionCode |
watchdog |
true only if /system/bin/frame-fixmac.sh contains the line # frame-watchdog: 1 and root has set debug.frame.watchdog to 1. Checked every 10 minutes |
kiosk_update_failure |
{sha256, reason} while a failure is held. Omitted when there is none |
Telemetry is a replacement, so an omitted kiosk_update_failure clears the stored failure. The server keeps a failure only if its sha256 is the frame's current target; anything else is dropped. It validates the reason against the list below (install_ followed by an optionally negative number of up to three digits is also allowed).
| Reason | Meaning | Retried by the kiosk |
|---|---|---|
download |
Network error, bad status, wrong content type, deadline, fewer bytes than offered, too little space, or a server address that is not https | Yes, with back-off |
size |
Offered size outside 1 byte to 32 MiB, a declared length that differs, more bytes than offered, or a stored file of the wrong length | No |
hash |
The downloaded SHA-256 does not match the offer | No |
not_apk |
The file is not a plain ZIP, or Android cannot read it | No |
package |
Not the kiosk's package | No |
version_mismatch |
The version inside the file is not the offered one | No |
not_newer |
The version is not above the installed one | No |
cert |
The signing certificates differ from the installed app's | No |
release_sig |
No release public key in the build, or the signature does not verify | No |
unhealthy |
The new build did not become healthy and was rolled back, or three tries ran out | No |
not_owner |
The app is not the device owner, so it may not install silently | Yes, with back-off |
install_<status> |
The installer's status code | Up to 3 tries |
"No" means held until the admin clears the failure (or the server offers a different hash).
The attempt counter
The admin clears a held failure with DELETE /api/frames/{id}/kiosk-update-failure (password re-entry required). The server deletes the stored failure and raises the frame's kiosk_attempt by one. The next offer carries the larger attempt, and the kiosk, seeing a number above the one it recorded with the failure, clears the failure, resets its try counter and back-off, and tries once more.
What the admin sees
The frame's kiosk.status is derived, not stored:
| Status | When |
|---|---|
none |
The frame has no target |
failed |
A failure for the target's hash is held, and the reported version is not already at or above the target's |
unknown |
It has a target, has not yet reported a version, and holds no failure |
current |
It reports a version at or above the target's |
pending |
Otherwise: waiting for the download or a quiet moment |
Rules for changing this
- Never put a URL, a path or a credential in the offer, and never send the offer on the cookie path.
- Never give the server a way to lower the version the device accepts.
- Never add a restriction to the device-owner policy that blocks package installs (
DISALLOW_INSTALL_APPS): updates and rollbacks both need them. - Keep the watchdog's rules: read only validated digits from the app's files, write nothing there, restore only the copy root made, and keep the rollback marker non-persistent.
- Keep the receivers
exported="false". - A new kiosk-side failure reason needs three edits: the
REASONpattern inUpdater.java,KIOSK_FAILURE_REASONSinkiosk_update.php, and the table above. - A release must not write preferences or files that the previous release cannot read, because a rollback keeps the app's data.
- Every change here needs a security review. See Security.
Related
- API reference: the release endpoints
- Database:
kiosk_releases,kiosk_downloadsand theframes.kiosk_*columns - Build the kiosk and set up a frame: keys, the boot script and the first install
- Developer start: the build