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.