Shipping an Android app that can never be on the Play Store
The UPEO SMS Gateway is a finished, production Android app that will never appear in the Google Play Store. Not because it was rejected, and not because nobody got round to submitting it. Because the thing it does is the thing Play does not allow, and no amount of engineering changes that.
That turns out to be a fine place to be, as long as you understand what you have signed up for. What you have signed up for is owning distribution: the signing key, the version numbers, the update mechanism, and every mistake in any of them.
Play forbids READ_SMS and RECEIVE_SMS for anything that is not the default SMS handler, so the app is distributed as a signed APK to devices the deployer owns.
- Create a stable release keystore before the first install goes out, and back it up off the build machine.
- A Gradle config that falls back to the debug keystore when
key.propertiesis missing will silently ship an uninstallable build. - Android compares
versionCode, notversionName. A build stuck at code 1 is a downgrade no matter what the label says. - With no store, the app updates itself, which is why
REQUEST_INSTALL_PACKAGESis in the manifest.
The policy wall, stated plainly
Google Play treats SMS and call log access as restricted permissions. The core of the rule is that an app requesting READ_SMS or RECEIVE_SMS must be the device's default SMS handler, or fit one of a small set of declared exceptions, and that an app which could accomplish its purpose through an official API is expected to use that API instead.
Both halves of that apply here and neither is arguable. The gateway is emphatically not a messaging app; making it the default SMS handler on a till phone would be absurd. And for M-Pesa there is an official API, Daraja, which is exactly the reason the app exists: it serves the merchants who cannot get onto Daraja. From Play's perspective that distinction is invisible. The API exists, therefore use it.
So the app is built for private distribution on devices the deployer owns and controls. Once you accept that, several things get simpler. There is no review cycle. There is no target-API deadline forcing a rebuild on somebody else's schedule. There is no store listing to write. The app can request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS and deep-link into OEM autostart screens without arguing with a reviewer about it.
And several things get harder, all of them at once, in the same incident.
The incident
Devices in the field were carrying an early build. A new build was produced, put on the download page, and the first person to try it got this:
App not installed
On other devices, a different message about signatures not matching. On the ones that did get past that, nothing happened at all: the install appeared to succeed and the app stayed on the old version.
Two independent traps, both triggered by the same first release, and each one capable of producing this on its own. Separating them is most of the diagnosis:
| Symptom on the device | Cause | Fix |
|---|---|---|
| "App not installed", or a signatures-do-not-match message | The installed build was signed with a different key, usually a machine-specific debug key | Stable release keystore, plus one manual uninstall on every already-affected device |
| Install appears to succeed, app stays on the old version | versionCode never moved, so the package manager treats it as a downgrade | Bump the number after the plus in pubspec.yaml |
| The app never offers an update at all | The update feed reports a build number that is not higher than the installed one | Keep the feed, the served APK and the pubspec in step |
Trap one: the silent fallback to a debug key
Here is the Gradle configuration, which is a perfectly ordinary pattern that appears in a great many Flutter projects:
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
// ...
buildTypes {
release {
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
// Falls back to debug keys so flutter build apk works without a
// keystore. Provide key.properties before distributing a real APK.
signingConfigs.getByName("debug")
}
isMinifyEnabled = false
isShrinkResources = false
}
}
The intent is reasonable: a developer who clones the repository can run flutter build apk without being handed production secrets. The consequence is that flutter build apk --release succeeds, prints no warning, and produces a release APK signed with the debug keystore.
The debug keystore is generated per machine. So the APK is signed with a key that exists on exactly one laptop, and:
- it cannot be replaced by a build from any other machine,
- it cannot be replaced by a properly signed release build,
- and the failure surfaces as "App not installed", which is the least informative string in Android.
The fix was to create a stable release keystore, upeo-sms-gateway.jks with alias upeo, so that every future build shares one key. Devices carrying the debug-signed build had to uninstall once. Every update after that has been seamless.
What I would change about the Gradle file is not the fallback itself, which is genuinely useful, but its silence. A release build that quietly downgrades its own signing key should say so loudly enough that nobody uploads the artifact by accident. That pattern is common enough that its CI variant has its own article, where the missing keystore fails the build instead of quietly signing with the wrong key.
And the rule that comes out of it is short. Back up the keystore somewhere that is not the build machine. Lose it and you can never again ship an update that installs over the existing app. For a fleet of phones sitting on counters in shops, that means visiting every one of them. It is the only mistake in this article that cannot be fixed with another release.
Trap two: the versionCode that never moved
The served build had been sitting at 1.0.0+1. The version name changed in conversation and on the download page. The number Android actually looks at did not.
Android compares versionCode, an integer, and refuses to install a package whose code is not higher than the installed one. versionName is a human-facing string that the package manager does not care about at all. So a build called "1.0.5" carrying versionCode 1 is a downgrade, and downgrades are rejected.
In Flutter both come from one line:
version: 1.0.5+5
The part before the plus becomes versionName, the part after becomes versionCode. Which is convenient and is exactly why the failure happens: the left half is the one people think about and the right half is the one that matters. Moving 1.0.0+1 to 1.0.5+5 resolved it.
Both traps fired on the same release, which made the diagnosis slower than either would have been alone: some devices failed on signatures, some silently no-opped on the version code, and the pattern looked random until the two causes were separated.
Three surfaces that must agree
With no store, the version number exists in three places and there is nothing to keep them in step except attention.
The update feed is the interesting one, because with no store the app updates itself. That is why REQUEST_INSTALL_PACKAGES is in the manifest, and it is the one permission in the list that exists purely because of the distribution model rather than the function:
<!-- In-app update flow downloads + installs a new APK (sideload, no Play) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
The feed itself is trivial, four fields, un-enveloped:
return _json({
"version": s.get("gateway_app_version") or "",
"build": cint(s.get("gateway_app_build")),
"url": s.get("gateway_apk_url") or "",
"notes": s.get("gateway_app_notes") or "",
}, 200)
And the client's check is deliberately forgiving, because a failed version check must never be allowed to affect the thing the app is actually for:
Future<Map<String, dynamic>?> checkLatestVersion() async {
try {
final res = await _dio.get(K.versionPath);
if (/* 2xx and a Map body */ true) {
return (res.data as Map).map((k, v) => MapEntry(k.toString(), v));
}
} catch (e) {
AppLog.w(_tag, 'version check failed: $e');
}
return null;
}
A gateway that stopped forwarding payments because it could not reach an update endpoint would be an absurd failure, so the whole path returns null and moves on.
What owning distribution is actually like
Better than expected, on balance.
What you lose is discovery, which this app does not need, and the automatic update pipeline, which is the real cost. Play updates apps overnight without anyone thinking about it. Here, a phone updates when the operator taps a prompt, which means the fleet is never uniformly on one version and the backend has to tolerate that. It does, because the payload contract is stable and the heartbeat reports each device's app_version, so at least the drift is visible.
What you gain is that nobody else's schedule is your schedule. No target-API deadline forcing a rebuild in a quarter when you had other plans. No review reinterpreting a permission you have used for two years. No listing to maintain. For an internal tool deployed to devices you own, that trade is comfortably worth it, and it is close to the argument for shipping updates outside store review generally, minus the part where you still have to keep the store happy.
It is a very different situation from an app that wants to be listed and is arguing with a reviewer about a rejection. There is nothing to argue about here. The permission is restricted, the app is not a messaging client, an official API exists. The correct response is to stop trying and design for the distribution model you actually have, which is the part people postpone until the second release breaks.
What I would do differently
I would make the missing keystore fail the build rather than fall back. The convenience of an unsigned-in developer being able to run flutter build apk --release is worth much less than the cost of one uninstallable artifact reaching a device. Keep the fallback for debug and profile, make release refuse.
I would derive the website's version label and the backend's feed values from pubspec.yaml instead of typing them. Three surfaces kept in step by attention is two surfaces too many, and the fix is a build step that reads one line and writes the other two. This is entirely mechanical and I have simply not done it.
And I would put the versionCode into the heartbeat's own alerting, not just its payload. The app already reports app_version as name+build every five minutes, so the backend knows the exact build of every phone in the field. What it does not do is say anything when a device sits three versions behind for a month, which is precisely the situation where somebody needs to walk into a shop.
For the app those releases are actually delivering, start at the gateway overview, and for the reason it needs so many permissions in the first place, the reliability article is the one that explains them.
Frequently asked questions
- Can an app that reads SMS ever be published on Google Play?
- Only if it is the device's default SMS handler, or if it fits one of the narrow declared exceptions. Play's position is that the SMS and Call Log permissions are restricted, and that an app which could use an official API instead should use the official API. For M-Pesa, Daraja is that official API, so a gateway that reads payment SMS has no route to a listing. It is a policy constraint, not a technical one.
- Why does my sideloaded APK fail to install over the previous version?
- Almost always a signing key mismatch. Android refuses to replace an installed app with one signed by a different key, and the usual cause is a release build silently falling back to the debug keystore, which is machine-specific. The error text is unhelpful: app not installed, or signatures do not match. Fix the key and the affected devices still need one manual uninstall before the first correctly signed build will land.
- Why does Android refuse an install that has a higher version name?
- Because it compares versionCode, not versionName. A build labelled 1.0.5 whose versionCode is still 1 is a downgrade as far as the package manager is concerned if the installed build also has versionCode 1 or higher. In Flutter both come from a single pubspec line of the form version: name plus build number, and it is easy to bump the left half and forget the right.
- What happens if you lose the release keystore?
- You can never again ship an update that installs over the existing app. Every device in the field needs a manual uninstall and reinstall, which for a fleet of phones sitting in shops means visiting them. Back the keystore up somewhere that is not the build machine, before you need it.