From e00ced808bdefdb0e87e8662fd26fe9c71300967 Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Fri, 18 Sep 2026 18:36:25 +0200 Subject: [PATCH] Make the Android self-update actually install. CI stopped publishing when the iOS job outgrew the macos-14 Xcode, so the builds branch froze; the iOS job now runs on macos-26 and publish keeps the previous package for any platform that failed, with VERSION- markers naming the commit each package came from. APKs are signed with a committed sideload key instead of the runner's random debug key, so Android accepts them as updates, and the version code grows with every CI run. The updater explains the unknown-sources step, keeps the Update button after errors and checks the download is this app. Co-authored-by: Cursor --- .github/workflows/build.yml | 25 +++++- README.md | 12 +++ android/app/build.gradle.kts | 33 ++++++- .../org/maturita/maturita/update/Updater.kt | 83 ++++++++++++------ android/keystore/sideload.jks | Bin 0 -> 2652 bytes ios/Maturita.xcodeproj/project.pbxproj | 8 +- ios/Maturita/Update/Updater.swift | 9 +- src/i18n.c | 9 ++ 8 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 android/keystore/sideload.jks diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1d5293..4785bfa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -196,7 +196,8 @@ jobs: ios: name: iOS IPA - runs-on: macos-14 + # The app targets iOS 26; this image ships Xcode 26 as the default. + runs-on: macos-26 steps: - uses: actions/checkout@v4 @@ -217,25 +218,43 @@ jobs: publish: name: Publish to builds branch needs: [linux, windows, macos, android, ios] + # One broken platform must not freeze the others: publish whatever was + # built and keep the previous package for anything that failed. + if: ${{ !cancelled() && contains(needs.*.result, 'success') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Start from the previous builds branch + run: | + mkdir -p staged + if git fetch --depth 1 origin builds 2>/dev/null; then + git archive FETCH_HEAD | tar -x -C staged + rm -f staged/VERSION + fi + - name: Download artifacts uses: actions/download-artifact@v4 with: - path: staged + path: fresh merge-multiple: true - name: Collect installers run: | + set -euo pipefail + cp -f fresh/* staged/ 2>/dev/null || true chmod +x installers/pack-installer-zips.sh ./installers/pack-installer-zips.sh cp installers/maturita-installer-linux.sh staged/ cp installers/maturita-installer-windows.cmd staged/ cp installers/maturita-installer-*.zip staged/ - # The updater downloads this to see whether it is behind. + # The updaters download this to see whether they are behind. echo "${{ github.sha }}" > staged/VERSION + # Per-platform markers name the commit whose package is really + # here, so a platform that failed this run is not offered as new. + [ -f fresh/maturita-android.apk ] && echo "${{ github.sha }}" > staged/VERSION-android + [ -f fresh/maturita-ios.ipa ] && echo "${{ github.sha }}" > staged/VERSION-ios + ls fresh staged # The branch is rewritten from scratch every time rather than added to, # so the repository keeps one copy of the packages instead of a new diff --git a/README.md b/README.md index 7bdc348..f8f9ad7 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,12 @@ cd android nebo z kořene `make android`. +APK se podepisuje klíčem `android/keystore/sideload.jks` (není tajný – jde o +sideload, ne o Google Play), aby aktualizace z aplikace šla nainstalovat přes +předchozí build. Vlastní klíč lze dodat přes `MATURITA_KEYSTORE` a +`MATURITA_KEYSTORE_PASSWORD`. Buildy `android-5` a starší mají jiný podpis – +jednou odinstalujte a nainstalujte znovu. + iOS ze zdroje (Xcode 26, iOS 26+ / Liquid Glass): ```bash @@ -330,6 +336,12 @@ cd android or `make android` from the repo root. +The APK is signed with `android/keystore/sideload.jks` (not a secret – this is +a sideloaded app, not a Play release) so in-app updates install over the +previous build. Supply your own key through `MATURITA_KEYSTORE` and +`MATURITA_KEYSTORE_PASSWORD`. Builds `android-5` and older carry a different +signature – uninstall once and install again. + iOS from source (Xcode 26, iOS 26+ / Liquid Glass): ```bash diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index af9820e..a13958b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,6 +5,11 @@ plugins { val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev" +// Every CI build must be installable over the previous one, so the version +// code grows with the workflow run and local builds stay below it. +val ciRun: Int? = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() +val appVersionCode: Int = ciRun?.let { 100 + it } ?: 7 + android { namespace = "org.maturita.maturita" compileSdk = 37 @@ -13,17 +18,39 @@ android { applicationId = "org.maturita.maturita" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "android-6" + versionCode = appVersionCode + versionName = "android-$appVersionCode" buildConfigField("String", "COMMIT", "\"$commit\"") buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"") buildConfigField("String", "UPDATE_BRANCH", "\"builds\"") } + // Android only installs an update signed with the same key as the app + // already on the phone. The default debug keystore is generated per + // machine, so CI builds would never update each other; sign everything + // with the repository's sideload key instead. It is not a secret (this + // is a sideloaded open-source app), but a real key can be supplied + // through MATURITA_KEYSTORE / MATURITA_KEYSTORE_PASSWORD. + signingConfigs { + create("sideload") { + val ks = System.getenv("MATURITA_KEYSTORE")?.takeIf { it.isNotBlank() } + ?: rootProject.file("keystore/sideload.jks").path + val pass = System.getenv("MATURITA_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() } + ?: "maturita-sideload" + storeFile = file(ks) + storePassword = pass + keyAlias = System.getenv("MATURITA_KEY_ALIAS")?.takeIf { it.isNotBlank() } ?: "maturita" + keyPassword = System.getenv("MATURITA_KEY_PASSWORD")?.takeIf { it.isNotBlank() } ?: pass + } + } + buildTypes { + debug { + signingConfig = signingConfigs.getByName("sideload") + } release { isMinifyEnabled = false - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("sideload") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", diff --git a/android/app/src/main/java/org/maturita/maturita/update/Updater.kt b/android/app/src/main/java/org/maturita/maturita/update/Updater.kt index 74292ef..d19c18a 100644 --- a/android/app/src/main/java/org/maturita/maturita/update/Updater.kt +++ b/android/app/src/main/java/org/maturita/maturita/update/Updater.kt @@ -22,6 +22,12 @@ data class UpdateState( val canInstall: Boolean = false, ) +/** + * Self-update from the `builds` branch. CI rewrites that branch on every + * push to main with a commit titled "Build from "; the APK there is + * signed with the repository's sideload key, so Android accepts it as an + * update over any earlier build signed the same way. + */ class Updater(private val app: Application) { private val io = Executors.newSingleThreadExecutor() private val main = Handler(Looper.getMainLooper()) @@ -37,15 +43,14 @@ class Updater(private val app: Application) { emit(cb, UpdateState(status = "dev", messageKey = "update_err_devbuild", messageArg = null)) return } - emit(cb, UpdateState(status = "checking", messageKey = "update_checking", messageArg = null)) + if (interactive) { + emit(cb, UpdateState(status = "checking", messageKey = "update_checking", messageArg = null)) + } io.execute { try { val atom = get("https://github.com/$repo/commits/$branch.atom") - val remote = atomMainSha(atom) - ?: run { - val tip = atomTip(atom) ?: error("sha") - get("https://raw.githubusercontent.com/$repo/$tip/VERSION").trim() - } + val tip = atomTip(atom) ?: error("sha") + val remote = remoteAndroidCommit(atom, tip) val local = BuildConfig.COMMIT if (sameCommit(remote, local)) { emit(cb, UpdateState("ok", remote, "update_uptodate", remote.take(7))) @@ -59,29 +64,40 @@ class Updater(private val app: Application) { } fun install(cb: (UpdateState) -> Unit) { + // Android 8+ needs a per-app opt-in before we may hand an APK to the + // package installer. Send the user there and keep the Update button + // so the second tap goes straight to the download. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !app.packageManager.canRequestPackageInstalls() + ) { + val settings = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${app.packageName}") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + try { + app.startActivity(settings) + } catch (_: Exception) { + } + emit(cb, UpdateState("permission", messageKey = "update_android_allow", messageArg = null, canInstall = true)) + return + } emit(cb, UpdateState(status = "downloading", messageKey = "update_downloading", messageArg = null)) io.execute { + var tip: String? = null try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !app.packageManager.canRequestPackageInstalls() - ) { - val settings = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { - data = Uri.parse("package:${app.packageName}") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - main.post { app.startActivity(settings) } - emit(cb, UpdateState("error", messageKey = "update_err_download", messageArg = null)) - return@execute - } val atom = get("https://github.com/$repo/commits/$branch.atom") - val tip = atomTip(atom) ?: error("sha") - val url = "https://raw.githubusercontent.com/$repo/$tip/maturita-android.apk" + val head = atomTip(atom) ?: error("sha") + tip = head + val remote = remoteAndroidCommit(atom, head) + // Pin the download to the commit: the branch-named URL can be + // served from a stale CDN cache (even a cached 404). + val url = "https://raw.githubusercontent.com/$repo/$head/maturita-android.apk" val dir = File(app.cacheDir, "updates").apply { mkdirs() } val apk = File(dir, "maturita.apk") download(url, apk) - if (!isApk(apk)) { + if (!isOurApk(apk)) { apk.delete() - emit(cb, UpdateState("error", messageKey = "update_err_download", messageArg = null)) + emit(cb, UpdateState("error", remote, "update_err_badapk", null, canInstall = true)) return@execute } val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", apk) @@ -90,13 +106,27 @@ class Updater(private val app: Application) { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } main.post { app.startActivity(intent) } - emit(cb, UpdateState("staged", tip, "update_staged", tip.take(7))) + emit(cb, UpdateState("staged", remote, "update_android_installing", remote.take(7), canInstall = true)) } catch (_: Exception) { - emit(cb, UpdateState("error", messageKey = "update_err_download", messageArg = null)) + emit(cb, UpdateState("error", tip, "update_err_download", null, canInstall = true)) } } } + /** + * Commit whose APK sits in the builds branch. `VERSION-android` is written + * only when the Android job produced a package; older branches lack it, + * so fall back to the commit the whole branch was built from. + */ + private fun remoteAndroidCommit(atom: String, tip: String): String { + val marker = runCatching { + get("https://raw.githubusercontent.com/$repo/$tip/VERSION-android").trim() + }.getOrNull() + if (marker != null && marker.length >= 7) return marker + return atomMainSha(atom) + ?: get("https://raw.githubusercontent.com/$repo/$tip/VERSION").trim() + } + private fun atomTip(atom: String): String? = Regex("""Commit/([0-9a-f]{40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1) ?: Regex("""/commit/([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1) @@ -109,13 +139,16 @@ class Updater(private val app: Application) { return n >= 7 && a.take(n).equals(b.take(n), ignoreCase = true) } - private fun isApk(file: File): Boolean { + /** Reject anything that is not a parseable APK of this very app. */ + private fun isOurApk(file: File): Boolean { if (file.length() < 1_000_000L) return false file.inputStream().use { input -> val header = ByteArray(2) if (input.read(header) != 2) return false - return header[0] == 'P'.code.toByte() && header[1] == 'K'.code.toByte() + if (header[0] != 'P'.code.toByte() || header[1] != 'K'.code.toByte()) return false } + val info = app.packageManager.getPackageArchiveInfo(file.absolutePath, 0) ?: return false + return info.packageName == app.packageName } private fun open(url: String, readMs: Int): HttpURLConnection { diff --git a/android/keystore/sideload.jks b/android/keystore/sideload.jks new file mode 100644 index 0000000000000000000000000000000000000000..a61d8386e6004d255d086bc65c6b43be99ca8da2 GIT binary patch literal 2652 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 literal 0 HcmV?d00001 diff --git a/ios/Maturita.xcodeproj/project.pbxproj b/ios/Maturita.xcodeproj/project.pbxproj index a280a88..cf1c07c 100644 --- a/ios/Maturita.xcodeproj/project.pbxproj +++ b/ios/Maturita.xcodeproj/project.pbxproj @@ -335,11 +335,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-"; + CODE_SIGN_STYLE = Automatic; COMMIT = dev; - DEVELOPMENT_TEAM = ""; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Maturita/Info.plist; @@ -371,11 +371,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-"; + CODE_SIGN_STYLE = Automatic; COMMIT = dev; - DEVELOPMENT_TEAM = ""; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Maturita/Info.plist; diff --git a/ios/Maturita/Update/Updater.swift b/ios/Maturita/Update/Updater.swift index 8c6949a..5952b9b 100644 --- a/ios/Maturita/Update/Updater.swift +++ b/ios/Maturita/Update/Updater.swift @@ -23,7 +23,14 @@ final class Updater { do { let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom") let remote: String - if let sha = self.atomMainSha(atom) { + // VERSION-ios names the commit whose IPA is really in the + // branch; it is missing only when the iOS job failed or on + // older branches, where the branch-wide commit is used. + if let tip = self.atomTip(atom), + let marker = try? self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION-ios").trimmingCharacters(in: .whitespacesAndNewlines), + marker.count >= 7 { + remote = marker + } else if let sha = self.atomMainSha(atom) { remote = sha } else if let tip = self.atomTip(atom) { remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION").trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/src/i18n.c b/src/i18n.c index 2a8e806..7c408c3 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -69,6 +69,15 @@ const TrEntry tr_ui[] = { {"update_ios_sideload", "Nový build je na GitHubu. Nainstalujte ho znovu přes Xcode nebo AltStore (%s).", "A newer build is on GitHub. Reinstall it with Xcode or AltStore (%s)."}, + {"update_android_allow", + "Povolte této aplikaci instalovat aplikace a klepněte znovu na Aktualizovat.", + "Allow this app to install apps, then tap Update again."}, + {"update_android_installing", + "Systém teď nabídne instalaci nové verze (%s).", + "The system will now offer to install the new version (%s)."}, + {"update_err_badapk", + "Stažený soubor není platný build maturita.c.", + "The downloaded file is not a valid maturita.c build."}, {"welcome_feat1_title", "Efektivní procvičování", "Focused practice"}, {"welcome_feat1_body", "Cvičení, nápovědy a vokabeltraining. Aplikace si pamatuje, kde jste skončili.",