OTA Update System
How kiosk devices receive, verify, and install updates without USB or manual intervention.
Last updated: April 27, 2026
Architecture
Kiosk Device
|
|-- GET cdn.familypocket.io/update/latest
| (version metadata, rollout gating, checksum)
|
|-- GET cdn.familypocket.io/update/v{version}/apk
| (302 redirect to GitHub Releases)
|
|-- POST cdn.familypocket.io/update/confirm
| (post-install confirmation)
|
+-- POST cdn.familypocket.io/telemetry/events
(update.check, update.available, update.download_started,
update.download_completed, update.checksum_verified,
update.install_started, update.failed)
cdn.familypocket.io (thin proxy)
|
|-- Proxies to: authentication service (port 8030)
| |-- GET /api/auth/kiosk/update/latest
| |-- POST /api/auth/kiosk/update/confirm
| |-- GET /api/auth/kiosk/update/{version}/apk -> 302 to GitHub
| +-- GET /api/auth/kiosk/health
|
+-- Proxies to: api.familypocket.io (port 8050)
+-- POST /api/kiosk/telemetry/events
GitHub Releases (storage backend)
+-- familypocket/familypocket-kiosk/releases/download/v{version}/familypocket-kiosk.apkResponse Contract: GET /update/latest
The kiosk's only hardcoded URL. Everything else (APK URL, checksum, rollout decision) comes from this response.
GET https://cdn.familypocket.io/update/latest
Headers:
X-Device-Id: <device serial number>
X-Current-Version: <e.g. 2.4.1>
X-Android-Sdk: <e.g. 30>{
"updateAvailable": true,
"targetVersion": "2.5.0",
"apkUrl": "https://cdn.familypocket.io/update/v2.5.0/apk",
"checksum": { "sha256": "a1b2c3d4..." },
"forceUpdate": false,
"minRequiredVersion": "2.0.0",
"rolloutPercentage": 25,
"notes": "Release v2.5.0"
}The backend resolves targetVersion vs previousStableVersion per device using the rollout gating algorithm. Devices outside the rollout percentage receive the previous stable version (effectively "no update available").
Key Components (Kotlin)
| Class | Module | Purpose |
|---|---|---|
| KioskUpdater | app | Orchestrates check -> download -> verify -> window -> install |
| UpdateApi | core/network | Retrofit interface for update endpoints |
| UpdateResultReceiver | app | BroadcastReceiver for PackageInstaller results |
| TelemetryCollector | app | Batches + flushes update events to server |
| KioskEngineService | app | Runs update check loop every 4 hours |
Safe Install Window
Updates install ONLY between 22:00 and 03:00 when:
- No active trip (session is IDLE or COMPLETE)
- Device is charging (AC, USB, or wireless)
- Time is within the window
Force updates (forceUpdate=true) bypass this window entirely. Used for critical security patches only.
Rollout Percentage Gating
Not every device gets the update immediately. The backend hashes the device ID with SHA-256 and takes modulo 100:
devicePct = SHA256(deviceId).readUInt16BE(0) % 100
inRollout = devicePct < rollout_percentageThis gives deterministic, evenly distributed rollout without tracking per-device state.
Resumable Downloads
If WiFi drops mid-download:
- The partial file is saved as
update-{version}.apk.partial - Next attempt sends
Range: bytes={existingBytes}-header - Server returns 206 (partial content) and client appends
- Falls back to full download if server doesn't support Range
Checksum Verification
- Format: hex-encoded SHA-256 (e.g.,
a1b2c3d4...) - Computed by CI:
sha256sum familypocket-kiosk.apk | awk '{print $1}' - Stored in:
kiosk_rollout_config.checksumsJSONB column - Verified by:
KioskUpdater.computeSha256()(Kotlin) - Checked: after download AND before install (paranoid double-check)
Telemetry Events
| Event | When | Data |
|---|---|---|
| update.check | Update check starts | version |
| update.available | Update found | from_version, to_version |
| update.download_started | Download begins | version, apk_url |
| update.download_completed | Download finishes | bytes, duration_ms |
| update.checksum_verified | SHA-256 matches | version |
| update.checksum_failed | SHA-256 mismatch | expected, actual |
| update.install_started | PackageInstaller commit | version |
| update.install_waiting_window | Waiting for safe window | next_check_minutes |
| update.failed | Any failure | reason, from_version, to_version |
Database: kiosk_rollout_config
id UUID PRIMARY KEY
active BOOLEAN (only one row can be active)
target_version VARCHAR(20) -- e.g., "2.5.0"
previous_stable_version VARCHAR(20) -- e.g., "2.4.1"
rollout_percentage INT 0-100
min_required_version VARCHAR(20) -- below this, force update
force_update_versions TEXT[] -- versions forced to update immediately
checksums JSONB -- { "2.5.0": { "sha256": "hex..." }, ... }
notes TEXT
created_at, updated_at TIMESTAMPForce Update and Min Version
Two mechanisms force devices to update outside the normal rollout:
min_required_version
Devices running a version below min_required_version update immediately, regardless of rollout percentage or safe window. Use when old versions have security vulnerabilities or breaking API changes.
force_update_versions
An array of specific versions forced to update immediately:
UPDATE kiosk_rollout_config
SET force_update_versions = ARRAY['2.4.0', '2.4.1', '2.4.2']
WHERE active = TRUE;Devices on those versions bypass the safe window on the next update check.
Multi-Version Jumps
When a device returns from extended downtime (school holidays, mechanical issues), it may be many versions behind. The update system handles this in one jump:
Device: current_version = 2.0.0
Server: target_version = 2.8.3
Result: Device downloads v2.8.3 directly, skipping 2.1 through 2.8.2CI Webhook
When GitHub Actions builds a release, it POSTs to:
POST https://auth.familypocket.co.ke/api/auth/kiosk/release-webhook
Headers: Authorization: Bearer <SERVICE_TOKEN>
Body: { "version": "v2.5.0", "checksum": "a1b2c3..." }The auth service auto-stores the checksum in kiosk_rollout_config.checksums. No manual checksum copy-paste needed.