From 292144308d71488727494ef0198ee8a9aad970ef Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 1 Aug 2026 16:41:34 -0600 Subject: [PATCH 1/5] ADFA-4984: revive OTA self-update; split manifest and binary to k2go-download - UpdateController: point the manifest and APK base URLs to k2go-download; the install stays gated by same-certificate signature verification (no hash needed). - LibraryActivity: own the UpdateController (silent auto-check per launch, download receiver lifecycle) and expose it to the About screen. - Settings > About: add a "Check for updates" entry. - CI (tag workflow): build update.json from the top ota-release-notes.md entry and the built APK filenames, uploaded after the APKs (fixed key -> always latest). - Add ota-release-notes.md (minimal editorial OTA notes) and ota-migration-bridge.md (one-time cutover runbook for installs on the old host). --- .github/workflows/android-release-build.yml | 59 +++++++++++++++++++ .../controller/redesign/LibraryActivity.java | 23 ++++++++ .../redesign/SettingsSubFragment.java | 8 +++ .../update/presentation/UpdateController.java | 11 +++- .../app/src/main/res/values/strings_k2go.xml | 1 + ota-migration-bridge.md | 45 ++++++++++++++ ota-release-notes.md | 17 ++++++ 7 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 ota-migration-bridge.md create mode 100644 ota-release-notes.md diff --git a/.github/workflows/android-release-build.yml b/.github/workflows/android-release-build.yml index deec0ab4..817d16cd 100644 --- a/.github/workflows/android-release-build.yml +++ b/.github/workflows/android-release-build.yml @@ -120,6 +120,65 @@ jobs: --content-type "application/vnd.android.package-archive" done + # --- OTA MANIFEST (update.json) --- + # ADFA-4984: publish the OTA manifest LAST, after the APKs are already in R2, so it never + # points at a missing binary. update.json is a fixed key -> it overwrites the previous manifest + # and always resolves to the latest release (no .1/.2 copies). The changelog is copied verbatim + # from the top entry of ota-release-notes.md, which must match the release tag. + - name: Generate and upload update.json + if: startsWith(github.ref, 'refs/tags/v') + working-directory: ${{ github.workspace }} + env: + AWS_ACCESS_KEY_ID: ${{ vars.CLOUDFLARE_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + BUCKET_NAME: "iiaboa-apk-repo" + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + echo "Release tag: $TAG" + + # versionCodeBase = the app module's raw versionCode (the app divides the installed, + # ABI-multiplied code by 10 to compare). First versionCode in controller/app/build.gradle. + VCODE=$(grep -oE 'versionCode[[:space:]]+[0-9]+' controller/app/build.gradle | head -1 | grep -oE '[0-9]+') + echo "versionCodeBase: $VCODE" + + # Top entry of ota-release-notes.md; its header must equal the tag (guard against stale notes). + HEADER=$(grep -m1 '^## ' ota-release-notes.md | sed 's/^##[[:space:]]*//') + if [ "$HEADER" != "$TAG" ]; then + echo "::error::ota-release-notes.md top entry '$HEADER' does not match tag '$TAG'. Update the notes before tagging." + exit 1 + fi + # Body = the lines between the first '## ' header and the next one (blank lines trimmed). + CHANGELOG=$(awk '/^## /{n++; next} n==1{print}' ota-release-notes.md | sed '/^[[:space:]]*$/d') + echo "Changelog:"; printf '%s\n' "$CHANGELOG" + + # Built APK basenames per ABI (the binaries just uploaded to R2). + find_apk() { find controller -path "*/build/outputs/apk/release/*$1*.apk" -printf '%f\n' | head -1; } + APK_ARM64=$(find_apk "arm64-v8a") + APK_ARM32=$(find_apk "armeabi-v7a") + APK_UNIVERSAL=$(find_apk "universal") + echo "arm64=$APK_ARM64 | arm32=$APK_ARM32 | universal=$APK_UNIVERSAL" + + # jq escapes the multiline changelog safely into a JSON string. + jq -n \ + --argjson vcode "$VCODE" \ + --arg vname "$TAG" \ + --arg changelog "$CHANGELOG" \ + --arg arm64 "$APK_ARM64" \ + --arg arm32 "$APK_ARM32" \ + --arg universal "$APK_UNIVERSAL" \ + '{versionCodeBase: $vcode, versionName: $vname, changelog: $changelog, + apk_arm64_v8a: $arm64, apk_armeabi_v7a: $arm32, apk_universal: $universal}' \ + > update.json + echo "----- update.json -----"; cat update.json + + # Upload LAST (after the APKs). Fixed key -> overwrites the previous manifest. + aws s3 cp update.json "s3://$BUCKET_NAME/update.json" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type "application/json" + # TODO (Future): Add Jira finalization step here to automatically mark # the Jira version as "Released" and close the corresponding tickets. diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java index a507df0f..12cc87fd 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java @@ -63,6 +63,11 @@ public class LibraryActivity extends AppCompatActivity implements ServerControll private boolean recovering = false; // ADFA-4919 (2c-ii): checking a possibly-damaged killed install private long lastDeepOpSeq = -1L; // ADFA-4957: boot the server once per finished deep-env op + // ADFA-4984: own the OTA self-updater (revived; entry point is Settings -> About). We forward the + // DownloadManager receiver via onResume/onPause and run one silent auto-check per launch. + private org.iiab.controller.update.presentation.UpdateController updateController; + private boolean otaAutoChecked = false; + // ADFA-4837/4947: animated "…" on the boot status + extract-detail lines, via the shared // EllipsisAnimator (fixed-width mode so the centered lines don't jiggle as the dots grow). private org.iiab.controller.util.EllipsisAnimator bootEllipsis; @@ -83,6 +88,10 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_library); + // ADFA-4984: OTA self-updater, active on the library screen. The manual entry lives in + // Settings -> About; onResume runs one silent check and wires the download receiver. + updateController = new org.iiab.controller.update.presentation.UpdateController(this); + bottomNav = findViewById(R.id.k2go_bottom_nav); railNav = findViewById(R.id.k2go_nav_rail); NavigationBarView.OnItemSelectedListener navListener = item -> { @@ -457,12 +466,26 @@ protected void onNewIntent(Intent intent) { protected void onResume() { super.onResume(); if (serverController != null) serverController.onResume(); + if (updateController != null) { + updateController.registerDownloadReceiver(); + // One silent auto-check per launch; skip while a first install is running to avoid noise. + if (!otaAutoChecked && !installing) { + otaAutoChecked = true; + updateController.checkForUpdates(false); + } + } } @Override protected void onPause() { super.onPause(); if (serverController != null) serverController.onPause(); + if (updateController != null) updateController.unregisterDownloadReceiver(); + } + + /** ADFA-4984: exposed so Settings -> About can trigger a manual "Check for updates". */ + public org.iiab.controller.update.presentation.UpdateController updateController() { + return updateController; } @Override diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SettingsSubFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/SettingsSubFragment.java index 42c82d44..e82aa133 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SettingsSubFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SettingsSubFragment.java @@ -178,6 +178,14 @@ private String endonymOf(String tag) { // ---- About ---- private void buildAbout(Context ctx, LinearLayout list) { SettingsUi.infoRow(ctx, list, getString(R.string.k2go_settings_app_version), versionName(ctx)); + // ADFA-4984: manual OTA entry ("update on the air"). LibraryActivity owns the UpdateController. + SettingsUi.row(ctx, list, getString(R.string.k2go_settings_check_updates), null, null, v -> { + if (getActivity() instanceof LibraryActivity) { + org.iiab.controller.update.presentation.UpdateController uc = + ((LibraryActivity) getActivity()).updateController(); + if (uc != null) uc.checkForUpdatesManual(); + } + }); SettingsUi.row(ctx, list, getString(R.string.k2go_settings_permissions), null, null, v -> openAppSettings(ctx)); SettingsUi.toggle(ctx, list, getString(R.string.k2go_settings_usage_stats), AnalyticsConsent.isEnabled(ctx), checked -> { AnalyticsConsent.setEnabled(ctx, checked); diff --git a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java index 914b1a45..2cbadb4d 100644 --- a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java +++ b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java @@ -48,8 +48,15 @@ public class UpdateController { private static final String TAG = "IIAB-UpdateController"; private static final long COOLDOWN_MS = 10_000L; - private static final String UPDATE_JSON = "https://iiab.switnet.org/android/apk/update.json"; - private static final String APK_BASE_URL = "https://iiab.switnet.org/android/apk/"; + // ADFA-4984: OTA split. The manifest (with the minimal release notes) and the APK binaries + // both live in the k2go-download R2 bucket as separate objects, decoupled from the old + // iiab.switnet.org host. update.json is a fixed key (overwritten each tag -> always latest); + // the APKs carry version-specific filenames (they accumulate, never overwrite). The install is + // still gated by same-certificate signature verification (ApkVerifier), so the binary can live + // on any server without a manifest hash. Installs on the old host (<= vCode 52) are migrated + // once via a bridge manifest+APK seeded at the old switnet location. + private static final String UPDATE_JSON = "https://k2go-download.appdevforall.org/update.json"; + private static final String APK_BASE_URL = "https://k2go-download.appdevforall.org/"; private final AppCompatActivity activity; diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index a827700e..4c9a3be3 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -102,6 +102,7 @@ Turn off Sets the app language and the default content language. App version + Check for updates Permissions Share usage statistics Open-source licenses diff --git a/ota-migration-bridge.md b/ota-migration-bridge.md new file mode 100644 index 00000000..e54cbc6c --- /dev/null +++ b/ota-migration-bridge.md @@ -0,0 +1,45 @@ +# OTA migration bridge (one-time, for the 0.6.0 cutover) — ADFA-4984 + +Installs from before this change have the **old** OTA host baked into the APK +(`iiab.switnet.org`). They check the old manifest and download from the old base +URL, so they cannot reach `k2go-download.appdevforall.org` on their own. To move +them over, seed one bridge release at the **old** location. After a device takes +it once, the new APK carries the new URLs and every later update comes from +`k2go-download`. + +## Why it works +- The new APK is signed with the **same production keystore**, so it installs as + an update over the old one (and passes the app's same-certificate check). +- Old installs build the download URL as `OLD_APK_BASE_URL + `, + so the APK files must sit next to the bridge manifest at the old location. + +## One-time steps (do this for the 0.6.0 release) +1. Publish 0.6.0 normally (push the `v0.6.0-beta` tag). CI puts the signed APKs and + `update.json` in R2 → `k2go-download.appdevforall.org`. +2. Download the three published APKs from `k2go-download` (arm64-v8a, armeabi-v7a, + universal) — the exact signed artifacts, do not rebuild. +3. Upload those same APK files to the **old** location: + `https://iiab.switnet.org/android/apk/` +4. Write a **bridge** `update.json` at `https://iiab.switnet.org/android/apk/update.json` + with the new version and the same APK filenames, e.g.: + + ```json + { + "versionCodeBase": 60, + "versionName": "v0.6.0-beta", + "changelog": "A complete redesign of the app. Tap to update to the latest version.", + "apk_arm64_v8a": "org.iiab.controller-v0.6.0-beta-arm64-v8a-release.apk", + "apk_armeabi_v7a": "org.iiab.controller-v0.6.0-beta-armeabi-v7a-release.apk", + "apk_universal": "org.iiab.controller-v0.6.0-beta-universal-release.apk" + } + ``` + +5. Verify on a device still running an old build (versionCode ≤ 52): it should be + offered the update, download from the old host, install, and afterward check + `k2go-download` for future updates. + +## After the cutover +- Once enough old installs have migrated, the old `iiab.switnet.org` files can be + retired (or left in place for stragglers — the bridge manifest is harmless). +- This bridge is only needed **once**. Every 0.6.0+ install already points at + `k2go-download`, so future releases need no bridge. diff --git a/ota-release-notes.md b/ota-release-notes.md new file mode 100644 index 00000000..c54e05d2 --- /dev/null +++ b/ota-release-notes.md @@ -0,0 +1,17 @@ +# OTA update notes + +Minimal, hand-curated notes shown in the in-app "update available" dialog. +One entry per published version, newest on top, just 1-3 editorial lines about the +main idea of the release — NOT a full changelog. The CI copies the topmost entry +verbatim into `update.json`; it never auto-generates it. + +The full/official history lives in the GitHub Release notes (auto-generated on the +tag). This file is only the short summary end users read when they update. + +Rule: the version header must match the release tag / `versionName` so the CI picks +the right entry. + +## v0.6.0-beta +A complete redesign of the app and the setup experience — the biggest update since 0.5. +Setting up, backing up, restoring, and phone-to-phone sharing are now smoother and more reliable. +Installing this update is recommended. From a71365970ad8dc66ee4114ca022cf10654867da3 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 1 Aug 2026 16:47:03 -0600 Subject: [PATCH 2/5] ADFA-4984: move OTA notes/runbook out of repo root into controller/ci --- .github/workflows/android-release-build.yml | 10 +++++----- .../ci/ota-migration-bridge.md | 0 .../ci/ota-release-notes.md | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename ota-migration-bridge.md => controller/ci/ota-migration-bridge.md (100%) rename ota-release-notes.md => controller/ci/ota-release-notes.md (100%) diff --git a/.github/workflows/android-release-build.yml b/.github/workflows/android-release-build.yml index 817d16cd..4af03bf2 100644 --- a/.github/workflows/android-release-build.yml +++ b/.github/workflows/android-release-build.yml @@ -124,7 +124,7 @@ jobs: # ADFA-4984: publish the OTA manifest LAST, after the APKs are already in R2, so it never # points at a missing binary. update.json is a fixed key -> it overwrites the previous manifest # and always resolves to the latest release (no .1/.2 copies). The changelog is copied verbatim - # from the top entry of ota-release-notes.md, which must match the release tag. + # from the top entry of controller/ci/ota-release-notes.md, which must match the release tag. - name: Generate and upload update.json if: startsWith(github.ref, 'refs/tags/v') working-directory: ${{ github.workspace }} @@ -144,14 +144,14 @@ jobs: VCODE=$(grep -oE 'versionCode[[:space:]]+[0-9]+' controller/app/build.gradle | head -1 | grep -oE '[0-9]+') echo "versionCodeBase: $VCODE" - # Top entry of ota-release-notes.md; its header must equal the tag (guard against stale notes). - HEADER=$(grep -m1 '^## ' ota-release-notes.md | sed 's/^##[[:space:]]*//') + # Top entry of controller/ci/ota-release-notes.md; its header must equal the tag (guard against stale notes). + HEADER=$(grep -m1 '^## ' controller/ci/ota-release-notes.md | sed 's/^##[[:space:]]*//') if [ "$HEADER" != "$TAG" ]; then - echo "::error::ota-release-notes.md top entry '$HEADER' does not match tag '$TAG'. Update the notes before tagging." + echo "::error::controller/ci/ota-release-notes.md top entry '$HEADER' does not match tag '$TAG'. Update the notes before tagging." exit 1 fi # Body = the lines between the first '## ' header and the next one (blank lines trimmed). - CHANGELOG=$(awk '/^## /{n++; next} n==1{print}' ota-release-notes.md | sed '/^[[:space:]]*$/d') + CHANGELOG=$(awk '/^## /{n++; next} n==1{print}' controller/ci/ota-release-notes.md | sed '/^[[:space:]]*$/d') echo "Changelog:"; printf '%s\n' "$CHANGELOG" # Built APK basenames per ABI (the binaries just uploaded to R2). diff --git a/ota-migration-bridge.md b/controller/ci/ota-migration-bridge.md similarity index 100% rename from ota-migration-bridge.md rename to controller/ci/ota-migration-bridge.md diff --git a/ota-release-notes.md b/controller/ci/ota-release-notes.md similarity index 100% rename from ota-release-notes.md rename to controller/ci/ota-release-notes.md From 7f6d2561b0564e56955e9fbcfe6c1fbc66983ce6 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 1 Aug 2026 16:52:01 -0600 Subject: [PATCH 3/5] ADFA-4984: mark k2go_settings_check_updates translatable=false (lint, file convention) --- controller/app/src/main/res/values/strings_k2go.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index 4c9a3be3..7b20d554 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -102,7 +102,7 @@ Turn off Sets the app language and the default content language. App version - Check for updates + Check for updates Permissions Share usage statistics Open-source licenses From 6ea26e1d2a3717b6cd69e98f72b2b7fd9f50bdcf Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 1 Aug 2026 16:54:22 -0600 Subject: [PATCH 4/5] ADFA-4984: drop ota-migration-bridge.md from the branch --- controller/ci/ota-migration-bridge.md | 45 --------------------------- 1 file changed, 45 deletions(-) delete mode 100644 controller/ci/ota-migration-bridge.md diff --git a/controller/ci/ota-migration-bridge.md b/controller/ci/ota-migration-bridge.md deleted file mode 100644 index e54cbc6c..00000000 --- a/controller/ci/ota-migration-bridge.md +++ /dev/null @@ -1,45 +0,0 @@ -# OTA migration bridge (one-time, for the 0.6.0 cutover) — ADFA-4984 - -Installs from before this change have the **old** OTA host baked into the APK -(`iiab.switnet.org`). They check the old manifest and download from the old base -URL, so they cannot reach `k2go-download.appdevforall.org` on their own. To move -them over, seed one bridge release at the **old** location. After a device takes -it once, the new APK carries the new URLs and every later update comes from -`k2go-download`. - -## Why it works -- The new APK is signed with the **same production keystore**, so it installs as - an update over the old one (and passes the app's same-certificate check). -- Old installs build the download URL as `OLD_APK_BASE_URL + `, - so the APK files must sit next to the bridge manifest at the old location. - -## One-time steps (do this for the 0.6.0 release) -1. Publish 0.6.0 normally (push the `v0.6.0-beta` tag). CI puts the signed APKs and - `update.json` in R2 → `k2go-download.appdevforall.org`. -2. Download the three published APKs from `k2go-download` (arm64-v8a, armeabi-v7a, - universal) — the exact signed artifacts, do not rebuild. -3. Upload those same APK files to the **old** location: - `https://iiab.switnet.org/android/apk/` -4. Write a **bridge** `update.json` at `https://iiab.switnet.org/android/apk/update.json` - with the new version and the same APK filenames, e.g.: - - ```json - { - "versionCodeBase": 60, - "versionName": "v0.6.0-beta", - "changelog": "A complete redesign of the app. Tap to update to the latest version.", - "apk_arm64_v8a": "org.iiab.controller-v0.6.0-beta-arm64-v8a-release.apk", - "apk_armeabi_v7a": "org.iiab.controller-v0.6.0-beta-armeabi-v7a-release.apk", - "apk_universal": "org.iiab.controller-v0.6.0-beta-universal-release.apk" - } - ``` - -5. Verify on a device still running an old build (versionCode ≤ 52): it should be - offered the update, download from the old host, install, and afterward check - `k2go-download` for future updates. - -## After the cutover -- Once enough old installs have migrated, the old `iiab.switnet.org` files can be - retired (or left in place for stragglers — the bridge manifest is harmless). -- This bridge is only needed **once**. Every 0.6.0+ install already points at - `k2go-download`, so future releases need no bridge. From 00df414481bf033c04c910c52e5c3112fcdfdfb7 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 1 Aug 2026 16:59:59 -0600 Subject: [PATCH 5/5] ADFA-4984: harden OTA revive per review (dialog guard, gate-deferred check, CI apk guard) - UpdateController: skip the "update available" dialog if the Activity is finishing/ destroyed, since the check returns async (avoids BadTokenException). - LibraryActivity: defer the one-per-launch auto-check until the boot gate opens (onServerReady) so the dialog never lands over the gate; still runs at most once. - CI: fail the update.json step early if any ABI APK is missing, instead of publishing an incomplete manifest. --- .github/workflows/android-release-build.yml | 4 ++++ .../controller/redesign/LibraryActivity.java | 20 +++++++++++-------- .../update/presentation/UpdateController.java | 3 +++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/android-release-build.yml b/.github/workflows/android-release-build.yml index 4af03bf2..82fc5f7c 100644 --- a/.github/workflows/android-release-build.yml +++ b/.github/workflows/android-release-build.yml @@ -160,6 +160,10 @@ jobs: APK_ARM32=$(find_apk "armeabi-v7a") APK_UNIVERSAL=$(find_apk "universal") echo "arm64=$APK_ARM64 | arm32=$APK_ARM32 | universal=$APK_UNIVERSAL" + if [ -z "$APK_ARM64" ] || [ -z "$APK_ARM32" ] || [ -z "$APK_UNIVERSAL" ]; then + echo "::error::Missing one or more built APKs (arm64='$APK_ARM64' arm32='$APK_ARM32' universal='$APK_UNIVERSAL'); aborting instead of publishing an incomplete manifest." + exit 1 + fi # jq escapes the multiline changelog safely into a JSON string. jq -n \ diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java index 12cc87fd..76c16a46 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/LibraryActivity.java @@ -361,6 +361,7 @@ private void onServerReady() { } gateDismissed = true; hideInstallProgress(); + maybeAutoCheckUpdate(); // ADFA-4984: gate is open now — safe to run the one-per-launch check // ADFA-4932: mount the feedback FAB only once the library is usable — never over the boot // gate / install progress. 88dp bottom margin clears the bottom nav. Idempotent. org.iiab.controller.feedback.presentation.FeedbackFab.installOn(this, "library", 88); @@ -466,14 +467,8 @@ protected void onNewIntent(Intent intent) { protected void onResume() { super.onResume(); if (serverController != null) serverController.onResume(); - if (updateController != null) { - updateController.registerDownloadReceiver(); - // One silent auto-check per launch; skip while a first install is running to avoid noise. - if (!otaAutoChecked && !installing) { - otaAutoChecked = true; - updateController.checkForUpdates(false); - } - } + if (updateController != null) updateController.registerDownloadReceiver(); + maybeAutoCheckUpdate(); // ADFA-4984: deferred until the boot gate has opened } @Override @@ -488,6 +483,15 @@ public org.iiab.controller.update.presentation.UpdateController updateController return updateController; } + /** ADFA-4984: one silent OTA check per launch, but only once the boot gate has opened (so an + * "update available" dialog never lands over the gate) and never during a first install. Called + * from onResume and from onServerReady, whichever settles last; guarded to run at most once. */ + private void maybeAutoCheckUpdate() { + if (updateController == null || otaAutoChecked || installing || !gateDismissed) return; + otaAutoChecked = true; + updateController.checkForUpdates(false); + } + @Override protected void onDestroy() { // ADFA-4947: stop the ellipsis animators so their self-reposting Runnable can't outlive the diff --git a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java index 2cbadb4d..31d34eec 100644 --- a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java +++ b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateController.java @@ -169,6 +169,9 @@ public void checkForUpdates(boolean isManual) { } private void showUpdateDialog(String versionName, String changelog, String downloadUrl) { + // The check runs async; by the time it returns the Activity may be finishing/destroyed. + // Showing a dialog on a dead window throws BadTokenException, so bail out quietly. + if (activity.isFinishing() || activity.isDestroyed()) return; new BrandDialog(activity) .setTitle(activity.getString(R.string.update_dialog_title, versionName)) .setMessage(activity.getString(R.string.update_dialog_message, changelog))