mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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-<platform> 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 <cursoragent@cursor.com>
This commit is contained in:
parent
2eec46329a
commit
e00ced808b
8 changed files with 143 additions and 36 deletions
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
README.md
12
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <sha>"; 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 {
|
||||
|
|
|
|||
BIN
android/keystore/sideload.jks
Normal file
BIN
android/keystore/sideload.jks
Normal file
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue