diff --git a/.github/workflows/android-release-build.yml b/.github/workflows/android-release-build.yml index deec0ab4..82fc5f7c 100644 --- a/.github/workflows/android-release-build.yml +++ b/.github/workflows/android-release-build.yml @@ -120,6 +120,69 @@ 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 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 }} + 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 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::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}' 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). + 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" + 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 \ + --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..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 @@ -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 -> { @@ -352,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); @@ -457,12 +467,29 @@ protected void onNewIntent(Intent intent) { protected void onResume() { super.onResume(); if (serverController != null) serverController.onResume(); + if (updateController != null) updateController.registerDownloadReceiver(); + maybeAutoCheckUpdate(); // ADFA-4984: deferred until the boot gate has opened } @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; + } + + /** 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 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..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 @@ -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; @@ -162,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)) diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index a827700e..7b20d554 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/controller/ci/ota-release-notes.md b/controller/ci/ota-release-notes.md new file mode 100644 index 00000000..c54e05d2 --- /dev/null +++ b/controller/ci/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.