How it works
The check lifecycle, the inputs Upwell gathers, and what runs where.
The pieces
| Piece | Where | Job |
|---|---|---|
| Policy | A JSON file you host | Says what's latest, what's the minimum, and how hard to push |
| Native module | Kotlin and Swift | Reads the installed version, build and install source, talks to Play Core and StoreKit, stores small values in SharedPreferences / UserDefaults |
| OTA adapter | upwell/expo-updates | Reports the running runtime and update id, checks for, downloads and applies OTA updates |
| Decision engine | Pure TypeScript (decide()) | Turns all of the above into exactly one decision |
| Controller | UpwellController | Gathers the inputs, runs the engine, performs the actions, keeps state |
| React bindings | upwell/react | Owns a controller, runs launch and foreground checks, exposes hooks |
| UI kit | upwell/ui | Optional components that render the current decision |
The engine has no side effects, no network access and no native dependencies. Everything that touches the outside world lives in the controller and the platform layer. That split is what makes the engine table-testable and the whole flow simulatable.
A check, step by step
- Read the device. Version name, build number, app id, OS version, install source and a random per-install id. This is read once per session.
- Ask the OTA adapter which runtime version and update id are running.
- Fetch the policy (and
releases.json, if configured) and check for an OTA update, in parallel. Fetches time out after 10 seconds (timeoutMs). - Validate the policy. A valid policy is cached. If the fetch fails or the policy is invalid, the last known good copy is used instead and the error is recorded in the trace.
- Ask the store. On Android, Play Core's
AppUpdateInfo(only for installs from Play). On iOS, the iTunes lookup API. - Decide.
decide()runs every rule and returns one decision plus a trace. - Act. If an OTA update is available and
autoDownloadOtaisn'tfalse, it starts downloading. WithapplyMode: "immediate", the app restarts into it once the download finishes.
After the check, Upwell keeps listening. Play's download progress events re-run the engine without
touching the network, so progress bars stay live. When the user acts (startUpdate(), dismiss()),
the engine runs again on the latest inputs.
When checks run
| Trigger | Behavior |
|---|---|
| Provider mounts | Forced check (checkOnMount, default true) |
| App returns to the foreground | Check, throttled by minCheckIntervalMs (default 60 s) (checkOnForeground, default true) |
| App goes to the background | If a flexible update is downloaded and the user postponed it, Upwell installs it quietly |
| A Play update finishes installing | Everything is read again |
You call check() | useAppUpdate().check() always forces; controller.check() respects the throttle unless you pass { force: true } |
Concurrent checks share one run, so calling check() while a check is running is safe.
What gets stored on the device
Upwell keeps a few small values in SharedPreferences (Android) or UserDefaults (iOS), with no extra
storage dependency:
| Key | Purpose |
|---|---|
installationId | Random id used for rollout bucketing. Never leaves the device |
policy, releases | Last known good copies, used when a fetch fails |
prompts | When a recommendation was dismissed and when prompts were shown, for snoozes and caps |
lastSeenUpdateId | The last OTA update the user was told about, for the "Updated" sheet |
Simulation uses separate in-memory storage, so it never touches these values.
What Upwell doesn't do
- Host OTA bundles. expo-updates (or your OTA tool) still does that.
- Run a backend. The policy is a static file. You can serve it from an API if you want to, but nothing requires one.
- Collect analytics. Nothing is sent anywhere except the policy and releases fetches you configure and the
store lookups. Use
onDecisionto log to your own analytics. - Behave differently for App Review. It never will. See Transparent OTA.