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.
- SDK author
- Mar – Sep 2026
- work
- 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.initDSL. - 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-. A206appends; a200means 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
PackageInstallersession 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
isForceUpdateboolean, 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:
- Bypass the SDK’s commit step on Partner B only. The SDK still checks, downloads and verifies. The app
opens its own
PackageInstallersession for the verified APK. When the platform answersSTATUS_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. - Make it silent with a platform-signing lever. The Partner B build is platform-signed, so it can hold
MANAGE_APP_OPS_MODESand flip its own install app-op. On a Partner B panel that produced two fully silent installs, with no prompt and no Settings page. - Revert step 2 the same day.
MANAGE_APP_OPS_MODESis 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
TypeTokenlost its generic signature when minified, which crashed the log manager. The fix wasTypeToken.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
- Separate the slow part from the disruptive part. Downloading quietly and installing at a chosen moment made every other problem easier.
- Default work policies encode assumptions.
REPLACEandUPDATEboth assumed launches are rare, and on a panel they aren’t. - Persisted state lags reality. Design recovery around which source to trust when they disagree.
- Permissions are a product decision on OEM hardware. The most silent path wasn’t the shippable one, and compliance won.
- Verify on the device that will run it. R8, debug-vs-release and OEM installer behaviour all hid on the emulator.