We hand out Android tablets to people who use our app in the field. Most of the devices are ours, nobody signs in with a personal Google account, and we do not want the app in a public store. Still, when a release is mandatory, every tablet has to pick it up, and whoever publishes the release should not need a developer's laptop and an adb cable.
This article describes how we update the app without a marketplace: a small JSON manifest, a single APK, two authenticated endpoints on the backend, and a publishing path built on a read-only NFS share and a WebDAV folder. I keep the hostnames and paths generic on purpose.
Why the app is not in a store
The app is built for police forces. It is part of a service we provide to them under contract, and it works together with our backend. A listing in a public store would put the APK within reach of anyone, and anyone who holds a copy can decompile it.
Keeping the file out of a public listing does not make reverse engineering impossible. It narrows who can get the file in the first place. The real boundary stays on the backend, where every call is authenticated and scoped to a tenant.
Some customers also want to use tablets they already own, which we do not manage. That rules out the approach of enrolling devices in a management system of our own, so we deliver the update ourselves.
What I wanted from the design
The fleet is small and known. Updates can be mandatory. The tablets already log in to our backend and carry a JWT, so the backend is the one place every device is willing to talk to. Adding a public static web server just for APKs would mean a second thing to secure, so I decided against it and let the backend serve the files.
I also wanted the publishing side to be boring. One person uploads two files and the tablets do the rest.
Two files, one version at a time
The share contains exactly two files: a manifest and the APK. There is no version history and no per-device targeting.
{
"version": "1.4.2",
"force": true,
"note": "Mandatory update with fixes for the inspection form.",
"size": 48213377,
"sha256": "9f2c...e41a"
}
The manifest has no URL field. An earlier idea was to put the download URL in the manifest, so the APK could carry its version in the file name. I dropped it. With a fixed file name, the manifest cannot point the app anywhere: the app downloads from the backend it is logged in to, and the file is always the same one. Having one fixed name was also easier to explain to the person who publishes.
size and sha256 describe the APK. The app uses them to check that the download is complete before it tries to install anything.
What happens on the tablet
After login the app calls GET /api/app-version and compares the version in the manifest with the one it is running. If the manifest is newer and force is true, the app blocks the normal screens until the update is installed. If force is false, it offers the update and lets the user postpone.
The download is GET /api/app-apk, with the same bearer token as every other call. The response carries Content-Length, which is what lets the app draw a real progress bar with percentage and megabytes. When the file is complete, the app compares size and sha256 with the manifest and hands the APK to the Android package installer.
Android puts two conditions on this. The first is that the app needs the "install unknown apps" permission, which since Android 8 is granted per app, so someone has to allow it once on each tablet. The second is that an update must be signed with the same key as the installed version and must have a higher versionCode.
The signature check is the real safety net. Whoever can write to the share cannot make a tablet install an APK signed with a different key. They could still make every tablet download a file, so the write credential deserves care, and the signing key stays off the server entirely.
The two endpoints
Both endpoints sit behind the same middleware as the rest of the API: JWT validation and the tenant header. Both read from a directory given by one environment variable, APP_RELEASES_PATH, so no path is hard-coded.
/api/app-version returns the manifest as it is, or a 404 if the file is missing. /api/app-apk streams the APK with Content-Type: application/vnd.android.package-archive, Content-Length and a Content-Disposition header, or a 404.
Where the files live
The backend runs in a container on one machine, while the people who publish upload to another. I wanted a path between the two that the backend can only read.
On the file server, the directory is exported over NFSv4, read-only, to the private network of the application nodes. It is a dedicated export. The account that can write through WebDAV only ever reaches this directory and nothing else on the server.
# /etc/exports on the file server
/export/releases 10.0.0.0/16(ro,sync,no_subtree_check)
On the backend node the export is mounted read-only, and the container gets the same directory as a read-only bind mount at the same path:
# /etc/fstab on the backend node
fileserver:/releases /mnt/releases nfs4 _netdev,auto,nofail,noatime,ro,tcp 0 0
# docker-compose.yml
volumes:
- "${APP_RELEASES_PATH:-/mnt/releases}:${APP_RELEASES_PATH:-/mnt/releases}:ro"
The default value after :- makes the compose file start even when the variable has not been set yet. If the mount is missing, the directory on the host is empty and the endpoints answer with an error. I accepted that: the container still starts and the failure is visible.
The directory is owned by the web server user, with mode 0755 for the folder and 0644 for the files. The backend reads them as "other", so there is no need to line up user ids across machines.
Publishing with WebDAV
The WebDAV folder is served by the Apache that already runs on the file server, with mod_dav and a dedicated account.
Alias /releases /srv/releases
<Directory /srv/releases>
Dav On
Options -Indexes
AuthType Basic
AuthName "Releases"
AuthUserFile /etc/apache2/htpasswd_releases
<RequireAll>
Require method GET HEAD OPTIONS PUT MOVE DELETE PROPFIND
Require user publisher
</RequireAll>
</Directory>
Two details are worth explaining. Require method sits inside a RequireAll because several Require lines at the same level are combined with OR, so a bare method restriction next to Require user would let any authenticated user use any method. And the folder is never listed publicly: there is no directory index, and every request needs credentials.
A plain PUT is not atomic, and an APK is tens of megabytes. The backend can start reading a file that is still being written. So each file is uploaded under a temporary name and then renamed with MOVE, which is a rename on the same filesystem. The order matters too. The APK goes first and the manifest last, because the manifest is what the tablets read to find out that a new version exists.
BASE=https://files.example.com/releases
curl --netrc-file ~/.netrc -T app.apk "$BASE/app.apk.tmp"
curl --netrc-file ~/.netrc -X MOVE "$BASE/app.apk.tmp" \
-H "Destination: $BASE/app.apk" -H "Overwrite: T"
curl --netrc-file ~/.netrc -T version.json "$BASE/version.json.tmp"
curl --netrc-file ~/.netrc -X MOVE "$BASE/version.json.tmp" \
-H "Destination: $BASE/version.json" -H "Overwrite: T"
In my tests the PUT answered 201, the MOVE answered 201 for a new file and 204 when it overwrote one, and a wrong password gave 401. The credentials live in a .netrc file with mode 600, so the password never appears on the command line or in the shell history.
I wrapped the four commands in a small script for Linux and one for Windows. It checks every status code, deletes the .tmp file if a step fails, and at the end downloads both files again to compare their sha256 with the local ones.
Two limits I know about
Between the MOVE of the APK and the MOVE of the manifest there are a few seconds in which the new APK sits next to the old manifest. A tablet that starts a download in that window sees a sha256 that does not match and discards the file. It tries again at the next check, which is acceptable for a fleet that checks at every login.
The other limit is that a release cannot be rolled back on devices that have already installed it, because Android refuses to install an APK with a lower versionCode. The share keeps a single version, so a bad release is fixed by publishing a new build with a higher version, and the previous APK has to be kept by whoever builds it.
There is also no staged rollout. Every tablet that checks after the manifest is replaced gets the new version, and for this fleet I prefer that to the extra machinery.
Top comments (0)