Mobile Computing · Mobile Release
Version Codes, Monotonic Ordering, and Downgrade Refusal
Why the installer compares an integer rather than a version string, how a version code that wraps or repeats poisons an update path, and where platform policies override monotonicity.
An update is not a download. It is a decision by the installer that the bytes it holds are a legitimate successor to the bytes on disk, and the only evidence it has for that decision is a pair of integers plus a signature. Everything else — the marketing version, the changelog, the release date — is metadata the installer never consults.
The installer’s comparison is an integer comparison
Android’s versionCode is a signed 32-bit integer in the manifest, and the package manager’s rule for a normal update is that the new code must be greater than the installed one. Equal is not greater. The comparison is not a string comparison, so 1.10 and 1.9 never appear here; the developer chooses the integer, and the platform only orders it.
This is a deliberate narrowing. A version string carries human semantics — major, minor, patch, pre-release — that a general comparison cannot order without a parser, and a parser is an attack surface and a source of ambiguity. An integer has one order. The cost is that the developer must encode the semantics into the integer themselves, and any encoding error becomes an installer error.
The common encoding is segmented: the integer is divided into fixed-width fields for major, minor, and patch, with a multiplier per field. A field of 7 bits holds 0–127; a scheme with major in bits 24–30, minor in 12–23, and patch in 0–11 allows 127 majors, 4096 minors, and 4096 patches. The alternative is to let the store or the build system assign a monotonic build number, which removes the arithmetic but also removes the ability to reason about ordering from the version string.
iOS has two integers with different jobs. CFBundleVersion is the build number, compared as a period-separated sequence of integers where each component is compared numerically and a missing component counts as zero, so 1.10 is greater than 1.9. CFBundleShortVersionString is the user-visible version and is not used for ordering at all — the store requires it to increase for a release but the device does not compare it. Splitting the two lets a build number advance per submission while the marketing version stays fixed across a series of betas.
Why monotonicity is load-bearing
Every derived decision inherits the ordering. A delta update is computed between a specific installed version and a specific target version, and the patch applies only if the installed bytes match the source exactly; a version code that repeats across two different builds means the store cannot distinguish which source a device has, so it must fall back to a full download. A staged rollout is addressed by version code, so a build that reuses a previous code cannot be staged separately from its predecessor. Crash reports keyed by version code merge two distinct binaries into one series.
The failure that hurts most is a reuse after a rollback. A release is published with code 105, is halted because of a defect, and the fix is built. If the fix keeps 105, devices that already updated will not accept it, because 105 is not greater than 105. If the fix is 106, devices that never updated jump from 104 to 106 and skip the bad build, which is the desired behaviour but means 105 is permanently poisoned: it must never be published again under that number, and any build artifacts carrying it are unusable.
Wrapping is the same problem at the boundary. The 32-bit field overflows, and a scheme that increments per build without a guard will eventually produce a smaller integer than its predecessor, at which point no device accepts the update. Guarding requires reserving headroom in the encoding rather than discovering the limit during a release.
The high-order field and the 64-bit effective code
The 32-bit signed range is roughly 2.1 billion, which is generous for a per-build counter but tight for an encoding scheme that packs a major version into the top bits. Android addresses this with a second manifest field, versionCodeMajor, a 32-bit integer that occupies the high half of a 64-bit effective version code. The effective value is (versionCodeMajor << 32) | versionCode, and the installer compares that composite.
The composite form matters because it changes what an overflow means. A scheme that puts the major version in the high field can advance it without touching the low field, so a release that changes nothing but the major number does not need to renumber every build. It also means the low field can be used as a pure monotonic counter, which is easier to automate: the build system increments it, and the major field changes only when a human decides the release line has moved.
Not every component of the distribution chain understands the composite. Older tooling that reads only versionCode sees the low half, and a comparison performed there can invert the true order when the major fields differ. That is the argument for keeping the major field at zero until a scheme actually needs it, and for verifying which of the two fields the store’s own ordering uses before relying on the composite.
Downgrade is a separate policy from upgrade
The installer’s default is to refuse a lower version code. That refusal exists because a downgrade can violate state assumptions: a database schema migrated forward by version 105 is not readable by version 104, and the installer has no way to know whether the app can tolerate it. Refusing by default makes the developer responsible for declaring when a downgrade is safe.
On Android that declaration is a manifest attribute for debuggable builds and an installer flag for privileged callers; ordinary store updates never take the path. On iOS, installing an older build over a newer one requires a restore or a development install, and the store itself does not offer it. Both platforms converge on the same position: downgrade is a developer or operator action, not a user action.
There is a real exception, and it is instructive. When a signing key is rotated and a device has not yet received the new certificate chain, the platform must accept an APK signed by the new key over one signed by the old key even though the version code relationship may be unusual. That path is governed by the proof-of-rotation lineage rather than by the version code, which is why key rotation is treated as a change to the trust anchor and not to the ordering.
Versions inside the app are a second, weaker contract
The installer’s integer does not reach the app’s own logic. A database migration ladder, an on-disk format version, and a cache key scheme each have their own version numbers, and those numbers must advance independently of the store’s. Coupling them to versionCode is tempting and wrong: a store metadata-only release can bump the code without any data change, and a data change can land in a build whose code stays the same across two store submissions.
The workable rule is that the installer’s integer orders releases, and a separate stored integer orders on-disk state. On first launch after an update, the app reads the stored integer, runs migrations from it to the current schema, and writes the new value in the same transaction that commits the migrated data. If the migration is interrupted, the stored integer is still the old one and the migration reruns; if it commits, the data and the marker agree.
// The schema marker and the data it describes commit together.
fun migrate(db: SQLiteDatabase, target: Int) {
db.beginTransaction()
try {
val from = readSchemaVersion(db)
if (from > target) throw IllegalStateException("downgrade: $from > $target")
for (step in from until target) applyStep(db, step)
writeSchemaVersion(db, target)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
The from > target check is the app-level analogue of the installer’s refusal, and it is worth having even though the installer already enforces ordering, because a restore from a backup or a manually installed older build bypasses the installer’s comparison.
Testing the ordering rather than the build
An update path is a graph, not a line: devices in the field sit at many versions, and each edge from an installed version to a candidate version is a path that has to work. The edges that matter are not only the latest-to-next one. A device that skipped three releases takes the direct edge, and if that edge runs a migration ladder from schema 4 to schema 11, it exercises ten steps that a device updating every release exercises one at a time.
Enumerating the graph is mechanical if the set of released versions is known. For each released version, install it, then install the candidate over it, and assert that the app launches and that the migrated data is readable. The set grows without bound unless old versions are retired, so the practical subset is the versions still installed on a meaningful share of devices plus the oldest supported version. Retiring a version is itself a decision with a cost: devices below the minimum are forced to reinstall rather than update, losing local state.
The other edge worth testing is the one that must fail. Installing an equal or lower version code over a newer install should be refused, and a test that asserts the refusal catches an encoding mistake — an accidental wrap, a reused code, a build system that resets the counter per branch — before it reaches a device. That check is cheap, and it is the only place where the encoding is verified against the platform’s actual rule rather than against the developer’s intent.
References
- Version your app — Android Developers
- Android App Bundle format — Android Developers
- CFBundleVersion — Core Foundation Keys — Apple Developer Documentation
- Android Package Manager — PackageManager — Android Developers