‹app-self-update

app-self-update

all docs

App self-update — keeping the native app current without the app store

A seed + roadmap document. Written for the next agent (human or model). It captures *why* the app should keep itself updated directly (not via Google Play) and *how* we intend to deliver that, so the intent survives the gap until we build it. Status: design only, deliberately PARKED. Nothing here is built yet. The load-bearing pieces it leans on already ship today (desktop selfUpdate(), the remote-store cache, the mobile.yml publish layout — cited below). The decision this session was to not build it yet and revisit at public "deploy for all." See *Decision & status* at the end.

The one idea

[VISION.md] says Zero is one install that becomes what the moment demands, deployed for everyone. A thing deployed for everyone must keep itself current without anyone shepherding it — including without going through an app-store publish cycle (we intend to push to Play rarely, if ever).

So: once installed, the app should update itself, directly from our own distribution (the DO Space / S3), forever — both the running system and, over time, everything it can do.

The crucial split: two layers, updated two ways

"The zero main system" is not one thing. It is two layers, and Android treats them completely differently. Getting this distinction right is the whole game.

Layer

What it is

How it updates

Friction

DSL core + classes

the interpreted _core + class library + every .action (the actual behavior, pages, logic)

silent OTA — fetched from the store, no APK, no publish cycle

zero taps

Native engine

libzeroengine.so — the compiled C++ interpreter itself

a new signed APK install — Android has no in-place .so swap

one tap (system installer dialog)

This mirrors the project's own two-tier law (mobile architecture notes): *"DSL capabilities = store-pushed, instant, no release; native capabilities = compiled into the shell, need an app update."* This document is the native half. The DSL half is a separate track (see *Ownership* below).

The good news the split buys us: ~90% of real change is DSL, which is already silent. A new C++ engine build is rare. So the one-tap native path is the infrequent exception, not the norm.

Why desktop can self-update its binary and Android can't (the same way)

On desktop, the app *is* the binary, so zero update just downloads the new binary and renames it over the running file — fully silent. See [kernel/main.cpp](../../kernel/main.cpp) selfUpdate() (~line 82): GET dist/VERSION, compare to ZERO9_VERSION, download dist/zero-<platform>, atomically replace, then refresh the DSL core with zremote::fetch_dir(base, "zero.9.stores/_core", core, /*force=*/true).

On Android the native code lives inside a signed, read-only APK in the install dir; System.loadLibrary loads it from there. Android's security model only lets a signed APK install replace native code — there is no supported way to hot-swap a .so from downloaded storage on a stock device. So the Android analog of desktop's binary swap is *"download a new APK and ask the installer to replace ourselves"* — same idea, APK granularity, and one unavoidable tap.

The desktop → Android mapping:

Desktop

Android

DSL core/classes

zero update refreshes core, silent

silent OTA refresh (no APK, no tap) — *other team*

Native binary

rename binary in place, silent

full APK self-update (auto-download, one-tap install) — *this doc*

The one honest limitation

A normal (non-device-owner) Android app cannot install an APK silently — the system always shows a one-tap *"Update Zero?"* dialog. Truly zero-tap installs require provisioning each device as device-owner via ADB / factory-reset (managed/kiosk), which is not viable for "deploy for all." So the realistic best for a public app is: fetch + download + stage fully automatically; the user taps Install once per native update. That is what this design delivers.

The design (blueprint for when we un-park)

Distribution decided: direct from the DO Space (S3) only; Google Play deferred ("set up Play later"). The canonical build is the self-updating sideload APK.

Reuse — mobile.yml already publishes everything the updater needs

On every app-v* tag, [.github/workflows/mobile.yml](../../.github/workflows/mobile.yml) already produces:

• dist/mobile/Zero/Zero-android.apk — the stable APK URL

• dist/mobile/Zero/android-VERSION — a plain-text version marker

• release-signed with the keystore secrets (one stable key ⇒ updates install over

each other)

So the updater is exactly the desktop pattern: GET android-VERSION, compare to the installed version, and if different download the stable APK and launch the installer. No change to the publish layout is needed.

Shell pieces to add (mobile/android/)

1. Updater.kt — the core, modeled on selfUpdate():

• read base + app from assets/manifest.data (name = Zero already exists;

add update.base = https://oneaurica-store.sgp1.cdn.digitaloceanspaces.com). Build URLs as <base>/dist/mobile/<app>/… to match what CI publishes.

• check() → GET android-VERSION (short timeout), trim; update when

remote.isNotEmpty() && remote != BuildConfig.VERSION_NAME (string inequality, same as selfUpdate's ver == ZERO9_VERSION).

• download() → stream Zero-android.apk to cacheDir/updates/.

• Loop guard (Android form of selfUpdate's inconsistency check): before

installing, parse the downloaded file with PackageManager.getPackageArchiveInfo and install only if its versionName == remote AND longVersionCode > installed. Abort loudly otherwise — stops the "updates forever" loop a stale/mismatched publish causes.

• install() → Intent(ACTION_VIEW) with a FileProvider URI, type

application/vnd.android.package-archive, FLAG_GRANT_READ_URI_PERMISSION → the one-tap system installer.

• ensureCanInstall() → on API 26+, packageManager.canRequestPackageInstalls();

if false, send the user once to Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES.

• throttle via SharedPreferences (last-checked time, last-prompted version) so

we don't re-download / re-nag every launch.

2. UpdateWorker.kt (WorkManager) — covers "long breaks" where the app is

rarely opened: a periodic (~12h, network-constrained) job that downloads + stages the APK and posts a *"Zero update ready — tap to install"* notification (a background worker can't pop the install dialog directly). On-launch path installs immediately.

3. MainActivity.kt — after awaitPort succeeds and the home loads, fire

Updater.checkAndUpdate() off the boot thread (never block boot); schedule the worker once.

4. AndroidManifest.xml — add REQUEST_INSTALL_PACKAGES + POST_NOTIFICATIONS;

add the FileProvider (res/xml/file_paths.xml, cache-path updates/).

Two latent build-config fixes (true today, independent of the updater)

These already exist as bugs in [app/build.gradle](../../mobile/android/app/build.gradle) and bite the moment anything relies on app version identity:

• versionCode is hardcoded 1. Every release ships versionCode 1. Sideload

reinstalls still work (same-version replace), but: the loop-guard's versionCode > test can't work, and Google Play rejects a second upload with a duplicate versionCode — so Play would only ever accept one build. Fix: derive it from the version (a.b.c → a*10000 + b*100 + c).

• versionName can mismatch android-VERSION. Gradle derives versionName

from its own git describe --tags, which can resolve to an engine v* tag instead of the app-v* tag the CI publishes as android-VERSION. If they differ, a self-updater would loop forever. Fix: have mobile.yml pass -PzeroVersion=${{ steps.ver.outputs.v }} and have build.gradle prefer it.

Handoff / runbook (for un-park time)

1. Builds are CI-only (no NDK locally); tag app-vX.Y.Z → CI builds + publishes.

2. First install is manual, once. The app already on a phone has no updater, so

it can't pull the first self-updating build — sideload that one APK by hand. Every release after that is automatic.

3. Signing continuity. All builds must share one keystore key (the CI secrets).

If a phone's current app was signed with a *different* key, Android rejects the update — uninstall once before installing the first self-updating build.

4. Verify after a release: …/dist/mobile/Zero/android-VERSION and

…/Zero-android.apk return 200; adb logcat -s zero shows the updater's update available X -> Y / up to date; confirm it does not re-prompt after updating (the versionName-equals-android-VERSION guard).

5. Kotlin trap to pre-scan: block comments *nest* in Kotlin — a /* inside a

KDoc eats the rest of the file (the v0.0.5..0.0.7 bug). Keep Updater.kt comments simple.

Ownership

• Native APK self-update (this doc): parked; revisit before "deploy for all."

• DSL / store updates + syncs: owned by a different team. The substrate

already supports it — fetch_dir(force=true) in [kernel/remote_store.h](../../kernel/remote_store.h) is the exact primitive desktop's zero update uses to refresh the core; the mobile engine is networked and seeds its store on first launch ([MainActivity.kt](../../mobile/android/app/src/main/java/com/oneaurica/zero/MainActivity.kt) seedStore). The gap there is that fetch_dir(force=false) is a no-op once a class is cached, so a refresh needs force=true driven by some freshness signal. Out of scope here; noted for that team's continuity.

Decision & status

Decided this session (2026-06-17):

• Native APK self-updater: PARKED. Rationale: it can only ever be one-tap on

stock Android (not the zero-tap dream), C++ engine changes are rare, and for the owner's own devices manual sideload is acceptable. Its real payoff is at public "deploy for all," where one-tap-for-everyone beats "nobody updates the engine." Revisit then.

• Stores/DSL OTA: a different team's track — removed from this scope.

• This document and the saved plan are the pickup point. The two latent

build-config fixes (versionCode, versionName threading) are worth doing independently if/when Play or the updater is touched.