OTA Update System

How kiosk devices receive, verify, and install updates without USB or manual intervention.

Last updated: April 27, 2026

Architecture

Request Flow
text
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.apk

Response Contract: GET /update/latest

The kiosk's only hardcoded URL. Everything else (APK URL, checksum, rollout decision) comes from this response.

Request
text
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>
Response 200
json
{
  "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)

ClassModulePurpose
KioskUpdaterappOrchestrates check -> download -> verify -> window -> install
UpdateApicore/networkRetrofit interface for update endpoints
UpdateResultReceiverappBroadcastReceiver for PackageInstaller results
TelemetryCollectorappBatches + flushes update events to server
KioskEngineServiceappRuns 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
i
Why 22:00-03:00? Bus trips start as early as 04:00. Updates must complete before drivers begin morning pickups.

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_percentage

This gives deterministic, evenly distributed rollout without tracking per-device state.

Resumable Downloads

If WiFi drops mid-download:

  1. The partial file is saved as update-{version}.apk.partial
  2. Next attempt sends Range: bytes={existingBytes}- header
  3. Server returns 206 (partial content) and client appends
  4. 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.checksums JSONB column
  • Verified by: KioskUpdater.computeSha256() (Kotlin)
  • Checked: after download AND before install (paranoid double-check)
A compromised CDN cannot push a malicious APK because the checksum comes from the trusted auth service, not from the APK file itself.

Telemetry Events

EventWhenData
update.checkUpdate check startsversion
update.availableUpdate foundfrom_version, to_version
update.download_startedDownload beginsversion, apk_url
update.download_completedDownload finishesbytes, duration_ms
update.checksum_verifiedSHA-256 matchesversion
update.checksum_failedSHA-256 mismatchexpected, actual
update.install_startedPackageInstaller commitversion
update.install_waiting_windowWaiting for safe windownext_check_minutes
update.failedAny failurereason, from_version, to_version

Database: kiosk_rollout_config

Schema
sql
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  TIMESTAMP

Force 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.

!
Use sparingly. Force updates interrupt drivers mid-shift. Valid use cases: NFC crash on every scan, authentication bypass, database corruption. Do NOT use for feature releases or visual changes.

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.2
!
Engineering rule: Never make breaking changes between consecutive versions. Database migrations, API contracts, and internal storage formats must support multi-version forward jumps. If you need a breaking change, do it in two phases: release N introduces compatibility for both old and new, release N+1 removes the old.

CI Webhook

When GitHub Actions builds a release, it POSTs to:

Webhook
bash
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.