When you install an Android app from an APK, outside the Play Store, it never updates itself. The Play Store won’t touch it, the OS won’t check for new versions, and your users stay on whatever they installed until somebody tells them otherwise.
I maintain a small app that’s distributed as a direct APK download. Every release meant messaging people to go and download it again. That stops being viable at about three users, so I built a basic over-the-air updater into the app.
How it works
The app already polled a small server on my home network every couple of seconds for state updates. I attached version information to that existing poll, so there was no new connection to make, no background service, and no extra battery cost.
There are three parts to it:
- Server. It keeps two files ready: the new APK and a small JSON manifest with the
version_code,version_nameand the APK’ssha256hash. The manifest’s contents go out with the existing state response, and the APK sits at a download endpoint. - App. On each poll it compares the advertised
version_codewith its ownBuildConfig.VERSION_CODE. If the server’s is higher, it downloads the APK over the existing HTTPS connection, checks the SHA-256 against the manifest, and hands it toPackageInstaller. - A manual check. A “check for updates” button in settings that forces a poll straight away. Useful for testing, and for impatient users.
// update.json — written by the release script
{
"version_code": 12,
"version_name": "1.9.0",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"apk_url": "/api/apk/myapp.apk"
}
Publishing a release is now four steps: bump versionCode, build the APK, copy it to the server’s data directory, write update.json. Easy to script down to one.
The Android details
A few things that only become obvious when you implement it:
You need REQUEST_INSTALL_PACKAGES in the manifest:
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
And the user has to grant the one-off “install unknown apps” permission for your app, under Settings → Apps → Special access. There is no way round that prompt, and there shouldn’t be. But it’s once, and then it’s done.
Use PackageInstaller, not intents. The old way of installing APKs — firing an ACTION_VIEW intent through a FileProvider — is deprecated behaviour on current Android. The supported API is PackageInstaller: open a session, stream the APK bytes in, commit. You get proper install-status callbacks as well, so the app knows whether the update actually went through.
Check the hash before installing. The SHA-256 verification happens before anything reaches PackageInstaller. The download already comes over pinned TLS from my own server, so this is a second line of defence rather than the first. It’s three lines of code, so there’s no reason to skip it.
Compare version codes, not names. Version names are for humans ("1.9.0", "1.9.0-beta2"). Version codes are integers that always go up. Compare the integers.
What I left out
- Delta updates. Every update downloads the full APK. Mine is about 7 MB, so deltas would add complexity for almost no saving.
- Staged rollouts. One version, everyone gets it. I’m not Google.
- A background download service. The check rides the existing poll loop. If the app isn’t running, there’s no update — and that’s fine, because the user opens the app to use it, and that’s when the prompt appears.
Is it secure enough?
The server sits on my home LAN, the connection uses HTTPS with certificate pinning, and the APK hash is verified on the device. Someone who can defeat pinned TLS and forge a SHA-256 has easier ways to cause trouble. For a hobbyist distribution channel, that’s proportionate.
The whole thing was a few hundred lines of Kotlin and a bit of server-side Python. It turned “please reinstall this manually” into something users never think about.