From 3f37e6f047752656f6f5df2f3b6c7c92c31e569d Mon Sep 17 00:00:00 2001 From: BiasF Date: Mon, 31 Aug 2026 11:49:07 +0200 Subject: [PATCH] =?UTF-8?q?Android:=20Release-Build=20signieren=20und=20ve?= =?UTF-8?q?r=C3=B6ffentlichen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Release-Build lieferte bisher app-release-unsigned.apk - und die installiert Android nicht, auch nicht per Sideload. Ohne Signatur kann das System nicht feststellen, ob ein Update vom selben Absender stammt wie die Erstinstallation; das ist unabhängig davon, woher die Datei kommt. Der Build signiert jetzt. Liegt Android/keystore.properties vor, mit dem eigenen Schlüssel; sonst wird aus der Umgebung gelesen, damit ein Bauknecht sie aus seinen Geheimnissen setzen kann, ohne dass ein Passwort im Verlauf landet. Fehlt beides, wird mit dem Debug-Schlüssel signiert - der Build sagt das dann deutlich, denn eine so signierte Release-APK sieht sonst aus wie eine richtige, lässt sich aber vom nächsten Rechner aus nicht mehr aktualisieren. Schlüssel und Passwörter sind ausgeschlossen. Version und Versionszähler lassen sich beim Bauen setzen, damit sie aus einem Etikett kommen können. Dazu ein Ablauf für Gitea Actions: ein Etikett hochschieben baut die APK, legt das Release an, falls es noch nicht besteht, und hängt sie an. Beides muss gehen - sonst scheitert es je nachdem, ob man das Release vorher von Hand angelegt hat. Android/RELEASE.md erklärt beide Wege und sagt auch, warum die APK nicht ins Repository gehört: Release-Anhänge liegen in Gitea neben dem Repository. Eine 11-MB-Binärdatei je Version im Verlauf wäre unwiderruflich - Git vergisst nichts, ein späteres Löschen bringt den Platz nicht zurück. Geprüft: die signierte APK installiert sich im Emulator, meldet sich mit der übergebenen Version und trägt keine Debug-Kennzeichnung mehr. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/release.yml | 94 ++++++++++++++++++++++++++++++++ Android/.gitignore | 5 ++ Android/RELEASE.md | 100 +++++++++++++++++++++++++++++++++++ Android/app/build.gradle.kts | 66 ++++++++++++++++++++++- 4 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 Android/RELEASE.md diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..371d033 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,94 @@ +# Baut die Android-APK und hängt sie an ein Gitea-Release. +# +# Ausgelöst durch ein Etikett der Form v1.2.3. Voraussetzungen und die +# Einrichtung der Geheimnisse stehen in Android/RELEASE.md. +name: APK veröffentlichen + +on: + push: + tags: + - "v*" + +jobs: + apk: + runs-on: ubuntu-latest + steps: + - name: Quelltext holen + uses: actions/checkout@v4 + with: + # Der Versionszähler kommt aus der Anzahl der Commits; dafür wird + # der ganze Verlauf gebraucht, nicht nur der letzte Stand. + fetch-depth: 0 + + - name: Java einrichten + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Android SDK einrichten + uses: android-actions/setup-android@v3 + + - name: Signaturschlüssel bereitlegen + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: | + # Ohne hinterlegten Schlüssel wird mit dem Debug-Schlüssel signiert. + # Der Build sagt das selbst deutlich; hier nur der Hinweis im Protokoll. + if [ -z "$KEYSTORE_BASE64" ]; then + echo "Kein Schlüssel hinterlegt – es wird mit dem Debug-Schlüssel signiert." + exit 0 + fi + echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + echo "KEYSTORE_FILE=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV" + + - name: APK bauen + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Die Anzahl der Commits steigt verlässlich und erfüllt damit, was + # Android vom Versionszähler verlangt: er darf nie kleiner werden. + CODE="$(git rev-list --count HEAD)" + echo "Version $VERSION (Zähler $CODE)" + cd Android + chmod +x gradlew + ./gradlew :app:assembleRelease \ + -PversionName="$VERSION" \ + -PversionCode="$CODE" --no-daemon + mv app/build/outputs/apk/release/app-release.apk \ + "../CamperMonitor-$VERSION.apk" + + - name: An das Release hängen + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + VERSION="${GITHUB_REF_NAME#v}" + API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases" + AUTH="Authorization: token $TOKEN" + + # Wurde das Release schon von Hand angelegt, wird es genommen; + # sonst hier erzeugt. Beides muss gehen, sonst scheitert der Ablauf + # je nachdem, in welcher Reihenfolge man vorgeht. + ID="$(curl -sf -H "$AUTH" "$API/tags/$GITHUB_REF_NAME" \ + | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1 || true)" + + if [ -z "$ID" ]; then + ID="$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \ + "$API" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)" + fi + + if [ -z "$ID" ]; then + echo "Release konnte weder gefunden noch angelegt werden." >&2 + exit 1 + fi + + curl -sf -X POST -H "$AUTH" \ + -F "attachment=@CamperMonitor-$VERSION.apk" \ + "$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null + + echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME." diff --git a/Android/.gitignore b/Android/.gitignore index 83b9a92..580dc6c 100644 --- a/Android/.gitignore +++ b/Android/.gitignore @@ -6,3 +6,8 @@ local.properties .idea/ captures/ .cxx/ + +# Signaturschlüssel und Passwörter gehören nirgends ins Repository. +keystore.properties +*.jks +*.keystore diff --git a/Android/RELEASE.md b/Android/RELEASE.md new file mode 100644 index 0000000..324f0b1 --- /dev/null +++ b/Android/RELEASE.md @@ -0,0 +1,100 @@ +# Eine APK veröffentlichen + +## Vorweg: die APK gehört nicht ins Repository + +Ein Release-Anhang in Gitea liegt **neben** dem Repository, nicht darin. Gitea +legt ihn in seinem Anhang-Speicher ab; im Git-Verlauf taucht er nicht auf. + +Das ist auch gut so. Eine APK ist eine 11-MB-Binärdatei, die sich bei jedem +Build komplett ändert. Läge sie im Repository, wüchse der Verlauf mit jeder +Version um 11 MB — und zwar **unwiderruflich**: Git vergisst nichts, ein +späteres Löschen bringt den Platz nicht zurück. Nach zehn Versionen wäre das +Auschecken des Projekts ein 110-MB-Download, obwohl der Quelltext selbst nur +wenige hundert Kilobyte hat. + +Deshalb: bauen, an ein Release hängen, fertig. `.gitignore` sorgt dafür, dass +die gebauten Dateien gar nicht erst versehentlich hineinrutschen. + +## Signieren + +Android installiert **keine unsignierte APK** — auch nicht per Sideload. Das +hat nichts mit Googles Sperren zu tun, sondern damit, dass das System ohne +Signatur nicht feststellen kann, ob ein Update vom selben Absender stammt wie +die Erstinstallation. + +Ohne eigenen Schlüssel signiert der Build mit dem Android-Debug-Schlüssel und +sagt das beim Bauen deutlich. Das funktioniert, hat aber einen Haken: Dieser +Schlüssel wird pro Rechner erzeugt. Baust du die nächste Version auf einem +anderen Rechner, hat sie eine andere Signatur, und Android verweigert das +Update — es bliebe nur Deinstallieren und neu einrichten. + +### Einen eigenen Schlüssel anlegen + +Einmalig. Das Passwort tippst du dabei selbst. + +```bash +keytool -genkeypair -v \ + -keystore ~/camper-monitor.jks \ + -alias camper -keyalg RSA -keysize 4096 -validity 10000 +``` + +Danach `Android/keystore.properties` anlegen (die Datei ist in `.gitignore`, +sie landet nicht im Repository): + +```properties +storeFile=/Users/DEINNAME/camper-monitor.jks +storePassword=DEIN_PASSWORT +keyAlias=camper +keyPassword=DEIN_PASSWORT +``` + +> **Diesen Schlüssel aufbewahren.** Geht er verloren, lässt sich für alle, die +> die App installiert haben, nie wieder ein Update ausliefern. Eine Kopie an +> einen zweiten Ort, getrennt vom Rechner. + +## Von Hand veröffentlichen + +```bash +source Android/env.sh +cd Android +./gradlew :app:assembleRelease -PversionName=1.0.0 -PversionCode=1 +``` + +Die APK liegt dann unter +`Android/app/build/outputs/apk/release/app-release.apk`. + +In Gitea: **Releases → New Release**, ein Etikett wie `v1.0.0` wählen, und die +APK unter *Attachments* hochladen. Fertig — genau das ist das „Wie sage ich +Gitea, dass die APK dort hinein soll". + +## Von selbst veröffentlichen + +`.gitea/workflows/release.yml` nimmt das ab: Ein Etikett hochschieben genügt. + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Der Ablauf baut die APK, legt das Release an, falls es noch nicht besteht, und +hängt die Datei an. Die Versionsnummer kommt aus dem Etikett, der +Versionszähler aus der Anzahl der Commits — so steigt er verlässlich. + +### Voraussetzungen + +1. **Ein Runner.** Actions brauchen in Gitea einen registrierten Runner mit dem + Etikett `ubuntu-latest`. Unter *Site Administration → Actions → Runners* + nachsehen. Ist keiner da, bleibt der Weg von Hand — der tut es genauso. +2. **Actions eingeschaltet**, im Repository unter *Settings → Advanced*. +3. **Der Schlüssel als Geheimnis**, falls mit eigenem Schlüssel signiert werden + soll. Unter *Settings → Actions → Secrets* anlegen: + + | Name | Inhalt | + | ------------------ | ------------------------------------------ | + | `KEYSTORE_BASE64` | `base64 -i ~/camper-monitor.jks \| pbcopy` | + | `KEYSTORE_PASSWORD`| das Passwort des Schlüsselspeichers | + | `KEY_ALIAS` | `camper` | + | `KEY_PASSWORD` | das Passwort des Schlüssels | + + Fehlen sie, baut der Ablauf trotzdem — dann eben mit dem Debug-Schlüssel, + und die APK heisst entsprechend. diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index ed7fd43..c548df8 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -1,9 +1,32 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") } +/** + * Angaben zum Signaturschlüssel. + * + * Sie stehen in `Android/keystore.properties` - einer Datei, die bewusst nicht + * im Repository liegt. Fehlt sie, wird stattdessen aus der Umgebung gelesen; + * so kann ein Bauknecht sie aus seinen Geheimnissen setzen, ohne dass ein + * Passwort je im Verlauf landet. + */ +val signing = Properties().apply { + val file = rootProject.file("keystore.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } else { + System.getenv("KEYSTORE_FILE")?.let { setProperty("storeFile", it) } + System.getenv("KEYSTORE_PASSWORD")?.let { setProperty("storePassword", it) } + System.getenv("KEY_ALIAS")?.let { setProperty("keyAlias", it) } + System.getenv("KEY_PASSWORD")?.let { setProperty("keyPassword", it) } + } +} +val hasSigningKey = signing.getProperty("storeFile") != null + android { namespace = "de.fritob.campermonitor" compileSdk = 35 @@ -13,13 +36,35 @@ android { // Android 8: älter lohnt nicht, dort fehlt zu viel an Bluetooth LE. minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "1.0" + versionCode = (findProperty("versionCode") as String?)?.toInt() ?: 1 + // Beim Bauen aus einem Etikett heraus überschreibbar: + // ./gradlew :app:assembleRelease -PversionName=1.2.0 + versionName = (findProperty("versionName") as String?) ?: "1.0" + } + + signingConfigs { + if (hasSigningKey) { + create("release") { + storeFile = file(signing.getProperty("storeFile")) + storePassword = signing.getProperty("storePassword") + keyAlias = signing.getProperty("keyAlias") + keyPassword = signing.getProperty("keyPassword") + } + } } buildTypes { release { isMinifyEnabled = false + // Ohne eigenen Schlüssel wird mit dem Debug-Schlüssel signiert. + // Unsigniert wäre die APK schlicht nicht installierbar - Android + // weist sie ab, auch beim Sideloading. Der Hinweis unten sagt, was + // das für Folgen hat. + signingConfig = if (hasSigningKey) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } @@ -59,3 +104,20 @@ dependencies { // Die Ablage der Geräte und Profile nutzt org.json aus Android selbst - // eine weitere Bibliothek lohnt für eine Handvoll Felder nicht. } + +// Sichtbar machen, womit signiert wurde - eine mit dem Debug-Schlüssel +// signierte Release-APK sieht sonst aus wie eine richtige. +tasks.matching { it.name == "assembleRelease" }.configureEach { + doLast { + if (!hasSigningKey) { + logger.lifecycle( + "\n ACHTUNG: mit dem Debug-Schlüssel signiert." + + "\n Installierbar, aber an diesen Rechner gebunden: auf einem anderen" + + "\n entsteht ein anderer Debug-Schlüssel, und ein Update liesse sich" + + "\n nicht mehr über die alte Installation legen." + + "\n Für einen eigenen Schlüssel: Android/keystore.properties anlegen." + + "\n Anleitung in Android/RELEASE.md.\n" + ) + } + } +}