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" + ) + } + } +}