Skip to content

feat(update): tell sideloaded installs that a newer version exists - #179

Merged
dzianisv merged 1 commit into
mainfrom
age-110-in-app-update-check
Aug 14, 2026
Merged

feat(update): tell sideloaded installs that a newer version exists#179
dzianisv merged 1 commit into
mainfrom
age-110-in-app-update-check

Conversation

@dzianisv

Copy link
Copy Markdown
Owner

Why

AGE-110. Sentry release health, 30d to 2026-08-14:

version   users   share   sessions  share   gated
0.4.10     1858   64.0%     18489   61.6%   no
0.4.4       601   20.7%      4086   13.6%   no
0.4.12      291   10.0%      5231   17.4%   no
0.4.14        7    0.2%        17    0.1%   yes   <- the AGE-105 noise gate

The gate ships inside the binary, so its ceiling is the share of the base that installs it. Two channels are why that share is 0.2%:

  1. Play lagged eight weeks (fixed in ci(play): publish release tags straight to production, not internal #177 - tag push now publishes to production).
  2. Everything else has no update mechanism at all. No expo-updates, no releases check. A direct-APK install, a self-hosted F-Droid install without auto-update, or a third-party mirror install (APKCombo currently advertises v0.4.10 as its newest listing) cannot learn that a newer version exists. This PR is that half.

What changed

  • src/lib/update-check-policy.ts - pure decision logic, runs under node --test with no RN, no network, no clock:
    • numeric version compare. A string compare orders 0.4.10 before 0.4.9, i.e. it would tell the single largest stale cohort in the install base that it is up to date. Covered by a test named after exactly that.
    • one check per 24h, and a lastCheckedAt in the future (restored backup, NTP correction) re-enables checking instead of locking it out
    • dismissal is remembered per version: "Not now" on 0.4.15 is never re-asked; 0.4.16 may ask once
    • last successful lookup is cached, so the affordance survives between checks instead of appearing only on the one launch that fetched
    • a failed fetch does not consume the interval - a device offline for a week checks on its next launch with network
  • src/lib/update-check.ts - Android-only runtime wiring. One unauthenticated GET to the GitHub releases-latest endpoint per 24h, 8s timeout, drafts/prereleases ignored, never throws. GitHub releases (not expo-updates) because OTA JS cannot replace a native binary, and every non-Play channel is downstream of a GitHub release.
  • src/components/UpdateBanner.tsx - one dismissible strip above the session list. Not a modal: a launch modal is the fastest way to get uninstalled, and this ships to the whole base. Renders nothing when there is no update, on iOS, or after dismissal.
  • Settings Version row showed a hard-coded "1.0.0" for every build ever shipped. Now shows the real version, and 0.4.10 -> 0.4.14 with a tap-through when an update exists. It passes ignoreDismissed: true - a dismissal silences the interruption, not the place a user goes to check.
  • en / zh-Hans strings; catalog-parity.test.ts stays green.

Privacy

One unauthenticated GET per 24h, no query params, no identifiers, no analytics event. GitHub sees an IP that already downloaded the APK from GitHub. On-device state is a timestamp plus two version strings (AsyncStorage, not SecureStore - nothing here is a credential, and the persisted-keys tripwire covers SecureStore writes, of which this adds none).

Verification

  • node --test src/lib/update-check-policy.test.ts -> 19 pass
  • npm test -> 300 pass
  • npx tsc --noEmit -> clean

How we will know it worked

node scripts/release-uptake.mjs (#176) is the meter. This lands in the next release, so it can only move the cohort from that version forward - the v0.4.10 users get told once they are on any build that carries this. Play production automation (#177) is what moves the already-installed base.

AGE-110: 64% of 30d-active users sit on v0.4.10 and 0.2% on the newest
build, so a client-side fix (the AGE-105 Sentry noise gate) reaches almost
nobody. Play hygiene fixes one channel; the direct APK, the self-hosted
F-Droid repo and third-party mirrors have no update mechanism at all — a
device installed from a downloaded APK has literally no way to learn a newer
version exists.

- src/lib/update-check-policy.ts: pure, node --test'able decision logic —
  numeric version compare (a string compare puts 0.4.10 BEFORE 0.4.9, i.e.
  it would have told the largest stale cohort it was current), a 24h check
  throttle that survives a backwards clock, per-version dismissal, and a
  cached last-known-latest so the affordance survives between checks
- src/lib/update-check.ts: Android-only runtime wiring. One unauthenticated
  GET per 24h to the GitHub releases API (every non-Play channel is
  downstream of a GitHub release; expo-updates cannot replace a native
  binary, which is what this cohort needs). Never throws.
- UpdateBanner on the sessions list: one dismissible strip, no modal.
  "Not now" sticks for that version only.
- Settings "Version" row showed a hard-coded "1.0.0" for every build ever
  shipped. It now shows the real version, plus "0.4.10 -> 0.4.14" when an
  update exists (ignoreDismissed: dismissal silences the banner, not the
  place a user goes to check).
- en/zh-Hans strings, catalog parity kept.

Tests: 19 new cases in update-check-policy.test.ts; full suite 300 pass,
tsc --noEmit clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@dzianisv
dzianisv merged commit 54ddc64 into main Aug 14, 2026
8 checks passed
@dzianisv
dzianisv deleted the age-110-in-app-update-check branch August 14, 2026 16:45
dzianisv pushed a commit that referenced this pull request Aug 14, 2026
Carries the two AGE-110 mechanisms: the in-app update check (#179) and the
first release whose tag push publishes straight to Play production (#177).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
omgoshjosh pushed a commit to omgoshjosh/opencode-mobile that referenced this pull request Aug 16, 2026
19 commits behind, three versionNames behind. Notable fixes this pulls in
that the evaluation build was missing: 401/403 classified as auth-failed
so a wrong password stops the retry loop (dzianisv#170), the Sentry noise gate,
the waitlist retry queue, and the in-app "a newer version exists" notice
for sideloaded installs (dzianisv#179).

Three conflicts, all resolved toward keeping both sides:

  - android/app/build.gradle: took upstream's versionCode 42 / 0.4.15 but
    kept the BUILD_VERSION_CODE env override. Evaluation builds need a
    monotonic code from CI so each side-by-side install supersedes the
    last; upstream release builds leave it unset and use the literal.

  - app/(tabs)/index.tsx: the group-by picker and UpdateBanner are
    unrelated additions to the same region. Kept both, banner above the
    picker so an update notice reads before the list controls.

  - app/(tabs)/settings.tsx: upstream and this branch independently fixed
    the same hard-coded "1.0.0". Took upstream's update-aware row but fed
    it appVersionLabel rather than CURRENT_VERSION -- sideloaded builds
    share a versionName across many CI builds, so the version alone
    cannot identify which APK is actually installed, which is the whole
    reason that row is being read.

382 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant