Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/android-release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 -> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions controller/app/src/main/res/values/strings_k2go.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
<string name="k2go_settings_turnoff_confirm">Turn off</string>
<string name="k2go_settings_lang_caption">Sets the app language and the default content language.</string>
<string name="k2go_settings_app_version">App version</string>
<string name="k2go_settings_check_updates" translatable="false">Check for updates</string>
<string name="k2go_settings_permissions">Permissions</string>
<string name="k2go_settings_usage_stats">Share usage statistics</string>
<string name="k2go_settings_licenses">Open-source licenses</string>
Expand Down
17 changes: 17 additions & 0 deletions controller/ci/ota-release-notes.md
Original file line number Diff line number Diff line change
@@ -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.
Loading