← Back to projects

work · active

How I Made Classroom Panels Update Themselves, Even Through Crashes and Reboots

Panels sit in classrooms with nobody to tap "Update". An SDK that lets a preinstalled app update itself on four panel brands, pick up where it left off after a crash, and work on firmware that refused the permission it needed.

Android · System apps · DeliveryKotlin · WorkManager · PackageInstaller · OkHttp · DataStore

Role
SDK author
Duration
Mar – Sep 2026
Type
work
Status
active

Key results

  • 4Panel brands updated from one SDK
  • 100–180 MBApp updates that pick up where they left off after a crash or reboot
  • 1 tapOn one brand's first update, then automatic (before: every update failed)

Overview

Edu AI is an AI assistant that comes preinstalled on Teachmint’s classroom panels as a system app. It isn’t distributed through Play. When we ship a new version, the panel has to download and install it by itself, usually while the room is empty, and on four hardware flavours from different OEMs.

The app started with a small hand-rolled class that downloaded an APK and installed it. In March 2026 I replaced it with a standalone SDK, ota. It polls for a new version, downloads with resume, verifies, and installs through PackageInstaller, with the update flow separated from the UI that triggers it.

The problem

A self-update on a panel fails in ways a phone update doesn’t:

  • The files are large. Release APKs are 100–180 MB. The Partner B build is the largest because it bundles a vendor writing SDK. On classroom Wi-Fi, a download can easily outlive the process.
  • No one is there. There is no user to tap “Update” at 8 a.m. on a panel that just booted.
  • Each OEM ROM grants different permissions. What installs silently on one flavour prompts or fails on another.
  • A bad update can’t be undone by hand. The panels are spread across schools.

Constraints

  • One SDK has to serve four flavours: two of our own panel models plus two partner brands.
  • Work must survive process death, reboots and connectivity loss without re-downloading 100+ MB.
  • No dependency injection framework in the SDK. The consuming app passes configuration through an Ota.init DSL.
  • The Partner B ROM goes through a compliance scan that audits privileged permissions.

My approach

The core decision was to split download from install. It was a breaking change, made early. The SDK’s job ends when a verified APK is on disk, and the app decides when the disruptive part happens: a boot-time install, or a teacher tapping “Update”.

Ota.init ─► recover state from disk ─► schedule periodic check (WorkManager)

Checking ─► UpToDate
        └─► UpdateAvailable ─► Downloading(%, bytes) ─► Verifying ─► Available
                                                                    (on disk, verified)
app calls Ota.install() ─► Installing ─► InstalledSuccess | Error(retryable)
  • Resumable downloads. The APK downloads to a partial file with Range: bytes=N-. A 206 appends; a 200 means the server ignored the range, so it starts over. Progress is persisted every 10 seconds, after flushing the stream.
  • Process-death recovery. On init, a validator reconciles the persisted record with reality. The result is one of: nothing to do, the verified file has gone missing, the update is already installed, or the update is ready. The UI’s state is rebuilt from that, not from memory.
  • Verification before promotion. The downloaded APK must be signed by the same certificate as the installed app and, unless the app opts out, carry a higher version code. Only then is it moved out of the partial folder.
  • Installation goes through a PackageInstaller session for the app’s own package, asking for no user action on Android 12+. If the platform still wants a confirmation, the SDK launches the system prompt.
  • A server-side manifest per flavour. It names the version, the APK and a list of versions that must force-update. The list replaced a single isForceUpdate boolean, so the server can say exactly which installed versions must update instead of forcing everyone.

Important decisions

Let WorkManager own retries and scheduling

The first version polled in a while (true) loop with its own retry logic. I moved both to WorkManager: a periodic check every 30 minutes on a network constraint, with download retries on WorkManager’s exponential backoff. The OS knows about Doze and reboots, and my loop didn’t.

KEEP, not REPLACE

Both work policies started out wrong.

  • The one-shot download used REPLACE. “The latest trigger wins” sounds right until you notice that every app launch triggers a check, so every launch cancelled the download in flight.
  • The periodic check used UPDATE. That re-armed the schedule on every launch.

Both are KEEP now. An in-progress download is never cancelled by a new request, and the 30-minute schedule survives restarts.

Trust the file, not the record, when they disagree

Progress is persisted on a timer, so after a crash the partial file is often ahead of the saved record. That’s normal, and the SDK resumes from the file’s length. A file shorter than the record means corruption, so it restarts.

The edge case was the end of the file. If the process dies after the last byte is written but before verification, the next resume asks for bytes=<file size>-, and the server answers HTTP 416: Range Not Satisfiable. The first version treated that as a failure. Now a 416 on resume means “already complete”: the SDK falls through to verification, and the signature check guards against a file that isn’t what it should be.

What went wrong: the permission the ROM wouldn’t grant

The SDK’s manifest declared INSTALL_PACKAGES, the privileged permission that allows silent installs. On Partner B panels it was never granted, because the ROM has no privileged-permission allowlist entry for our package. The declaration did nothing except trip the privileged-permission audit in the ROM’s compliance scan.

Removing it for Partner B was a one-line manifest-merger rule, and it broke every update on that flavour. The SDK checked for INSTALL_PACKAGES or the user-grantable install permission before opening a session. With neither, it refused, so every OTA on Partner B ended as “Update failed”.

I tried three ways out, in one day:

  1. Bypass the SDK’s commit step on Partner B only. The SDK still checks, downloads and verifies. The app opens its own PackageInstaller session for the verified APK. When the platform answers STATUS_PENDING_USER_ACTION, the app starts the system installer. That walks the teacher to the “Install unknown apps” toggle the first time, then back to the install prompt.
  2. Make it silent with a platform-signing lever. The Partner B build is platform-signed, so it can hold MANAGE_APP_OPS_MODES and flip its own install app-op. On a Partner B panel that produced two fully silent installs, with no prompt and no Settings page.
  3. Revert step 2 the same day. MANAGE_APP_OPS_MODES is a protected permission, the same class of thing the compliance scan audits. Silent installs weren’t worth putting certification at risk.

The shipped path combines the first approach with one more piece. The Partner B manifest declares UPDATE_PACKAGES_WITHOUT_USER_ACTION, a normal permission on Android 12+. Once a panel’s install toggle has been allowed, the platform commits later self-updates silently. So only the first update on a Partner B panel needs a tap. Boot-time installs on Partner B wait until that toggle is on, because no one is there to confirm.

Debugging and investigation

Most of the hard bugs only appeared on real panels:

  • R8 broke the update log in release builds. An anonymous Gson TypeToken lost its generic signature when minified, which crashed the log manager. The fix was TypeToken.getParameterized, -keepattributes Signature, and keep rules for the custom deserializer.
  • Debug builds didn’t survive on a panel. The production OTA flow saw a newer release, replaced the debug install within minutes, and made on-device testing confusing until we understood why.
  • “Silent” isn’t one gate. Asking for no user action only suppresses the install confirmation. The package verifier and Play Protect are separate dialogs with separate rules.

Result

The SDK updates the app across all four flavours from one code path, and resumes 100–180 MB downloads across crashes and reboots. Partner B went from “Update failed” on every attempt to one tap on a panel’s first update and silent after that.

What I’d still change, and what isn’t measured:

  • Fleet-level numbers. I don’t yet have success rates or download durations across the fleet. The SDK keeps a local log, but it has no remote telemetry, and that’s the first gap I’d close.
  • A timeout on the install result. A missing install broadcast would leave the SDK waiting forever. The companion-app updater in the same app already reclaims a stale install lock after ten minutes, and the SDK should do the same.

What I learned

  1. Separate the slow part from the disruptive part. Downloading quietly and installing at a chosen moment made every other problem easier.
  2. Default work policies encode assumptions. REPLACE and UPDATE both assumed launches are rare, and on a panel they aren’t.
  3. Persisted state lags reality. Design recovery around which source to trust when they disagree.
  4. Permissions are a product decision on OEM hardware. The most silent path wasn’t the shippable one, and compliance won.
  5. Verify on the device that will run it. R8, debug-vs-release and OEM installer behaviour all hid on the emulator.